@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/LICENSE +22 -0
  3. package/README.md +88 -2
  4. package/astryx.integration.mjs +11 -0
  5. package/blocks/RichTextEditorShowcase.template.mjs +13 -0
  6. package/blocks/RichTextEditorShowcase.tsx +19 -0
  7. package/dist/RichTextEditor.d.ts +282 -0
  8. package/dist/RichTextEditor.d.ts.map +1 -0
  9. package/dist/RichTextEditor.js +657 -0
  10. package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
  11. package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
  12. package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
  13. package/dist/RichTextEditorToolbar.d.ts +186 -0
  14. package/dist/RichTextEditorToolbar.d.ts.map +1 -0
  15. package/dist/RichTextEditorToolbar.js +688 -0
  16. package/dist/RichTextView.d.ts +92 -0
  17. package/dist/RichTextView.d.ts.map +1 -0
  18. package/dist/RichTextView.js +267 -0
  19. package/dist/codeLanguages.d.ts +14 -0
  20. package/dist/codeLanguages.d.ts.map +1 -0
  21. package/dist/codeLanguages.js +34 -0
  22. package/dist/codeLineTokens.d.ts +53 -0
  23. package/dist/codeLineTokens.d.ts.map +1 -0
  24. package/dist/codeLineTokens.js +188 -0
  25. package/dist/editorNodes.d.ts +8 -0
  26. package/dist/editorNodes.d.ts.map +1 -0
  27. package/dist/editorNodes.js +30 -0
  28. package/dist/editorTheme.d.ts +7 -0
  29. package/dist/editorTheme.d.ts.map +1 -0
  30. package/dist/editorTheme.js +328 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +28 -0
  34. package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
  35. package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
  36. package/dist/internal/CodeBackspacePlugin.js +70 -0
  37. package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
  38. package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
  39. package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
  40. package/dist/internal/CodePastePlugin.d.ts +5 -0
  41. package/dist/internal/CodePastePlugin.d.ts.map +1 -0
  42. package/dist/internal/CodePastePlugin.js +104 -0
  43. package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
  44. package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
  45. package/dist/internal/CodeSyntaxPlugin.js +397 -0
  46. package/dist/internal/CodeTabPlugin.d.ts +2 -0
  47. package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
  48. package/dist/internal/CodeTabPlugin.js +256 -0
  49. package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
  50. package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
  51. package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
  52. package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
  53. package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
  54. package/dist/internal/ImageSourceCaretPlugin.js +105 -0
  55. package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
  56. package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
  57. package/dist/internal/ListItemShortcutsPlugin.js +314 -0
  58. package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
  59. package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
  60. package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
  61. package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
  62. package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
  63. package/dist/internal/SelectAllScopePlugin.js +67 -0
  64. package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
  65. package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
  66. package/dist/internal/TableColumnFloorPlugin.js +84 -0
  67. package/dist/internal/TablePastePlugin.d.ts +9 -0
  68. package/dist/internal/TablePastePlugin.d.ts.map +1 -0
  69. package/dist/internal/TablePastePlugin.js +183 -0
  70. package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
  71. package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
  72. package/dist/internal/TableScrollRegionPlugin.js +95 -0
  73. package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
  74. package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
  75. package/dist/internal/TaskCheckboxPlugin.js +193 -0
  76. package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
  77. package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
  78. package/dist/internal/ToolbarLinkPopover.js +292 -0
  79. package/dist/linkEditing.d.ts +6 -0
  80. package/dist/linkEditing.d.ts.map +1 -0
  81. package/dist/linkEditing.js +20 -0
  82. package/dist/linkForm.d.ts +28 -0
  83. package/dist/linkForm.d.ts.map +1 -0
  84. package/dist/linkForm.js +137 -0
  85. package/dist/linkUtils.d.ts +27 -0
  86. package/dist/linkUtils.d.ts.map +1 -0
  87. package/dist/linkUtils.js +101 -0
  88. package/dist/markdown.d.ts +17 -0
  89. package/dist/markdown.d.ts.map +1 -0
  90. package/dist/markdown.js +17 -0
  91. package/dist/markdownCharacterReferences.d.ts +135 -0
  92. package/dist/markdownCharacterReferences.d.ts.map +1 -0
  93. package/dist/markdownCharacterReferences.js +1127 -0
  94. package/dist/markdownCodeFence.d.ts +4 -0
  95. package/dist/markdownCodeFence.d.ts.map +1 -0
  96. package/dist/markdownCodeFence.js +167 -0
  97. package/dist/markdownExtensionNode.d.ts +88 -0
  98. package/dist/markdownExtensionNode.d.ts.map +1 -0
  99. package/dist/markdownExtensionNode.js +248 -0
  100. package/dist/markdownExtensions.d.ts +115 -0
  101. package/dist/markdownExtensions.d.ts.map +1 -0
  102. package/dist/markdownExtensions.js +392 -0
  103. package/dist/markdownHardLineBreak.d.ts +25 -0
  104. package/dist/markdownHardLineBreak.d.ts.map +1 -0
  105. package/dist/markdownHardLineBreak.js +59 -0
  106. package/dist/markdownHeading.d.ts +20 -0
  107. package/dist/markdownHeading.d.ts.map +1 -0
  108. package/dist/markdownHeading.js +30 -0
  109. package/dist/markdownLink.d.ts +5 -0
  110. package/dist/markdownLink.d.ts.map +1 -0
  111. package/dist/markdownLink.js +80 -0
  112. package/dist/markdownListExport.d.ts +3 -0
  113. package/dist/markdownListExport.d.ts.map +1 -0
  114. package/dist/markdownListExport.js +68 -0
  115. package/dist/markdownListIndentation.d.ts +8 -0
  116. package/dist/markdownListIndentation.d.ts.map +1 -0
  117. package/dist/markdownListIndentation.js +101 -0
  118. package/dist/markdownOrderedList.d.ts +3 -0
  119. package/dist/markdownOrderedList.d.ts.map +1 -0
  120. package/dist/markdownOrderedList.js +51 -0
  121. package/dist/markdownQuote.d.ts +22 -0
  122. package/dist/markdownQuote.d.ts.map +1 -0
  123. package/dist/markdownQuote.js +32 -0
  124. package/dist/markdownSerializers.d.ts +49 -0
  125. package/dist/markdownSerializers.d.ts.map +1 -0
  126. package/dist/markdownSerializers.js +83 -0
  127. package/dist/markdownSource.d.ts +71 -0
  128. package/dist/markdownSource.d.ts.map +1 -0
  129. package/dist/markdownSource.js +804 -0
  130. package/dist/markdownSpaceRuns.d.ts +24 -0
  131. package/dist/markdownSpaceRuns.d.ts.map +1 -0
  132. package/dist/markdownSpaceRuns.js +147 -0
  133. package/dist/markdownTable.d.ts +35 -0
  134. package/dist/markdownTable.d.ts.map +1 -0
  135. package/dist/markdownTable.js +227 -0
  136. package/dist/markdownTaskList.d.ts +48 -0
  137. package/dist/markdownTaskList.d.ts.map +1 -0
  138. package/dist/markdownTaskList.js +166 -0
  139. package/dist/markdownThematicBreak.d.ts +3 -0
  140. package/dist/markdownThematicBreak.d.ts.map +1 -0
  141. package/dist/markdownThematicBreak.js +55 -0
  142. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
  143. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
  144. package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
  145. package/dist/richtext.css +213 -0
  146. package/dist/textSemantics.d.ts +3 -0
  147. package/dist/textSemantics.d.ts.map +1 -0
  148. package/dist/textSemantics.js +87 -0
  149. package/package.json +158 -3
  150. package/src/RichTextEditor.doc.mjs +308 -0
  151. package/src/RichTextEditor.tsx +1109 -0
  152. package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
  153. package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
  154. package/src/RichTextEditorToolbar.doc.mjs +92 -0
  155. package/src/RichTextEditorToolbar.tsx +871 -0
  156. package/src/RichTextView.doc.mjs +101 -0
  157. package/src/RichTextView.tsx +351 -0
  158. package/src/codeLanguages.ts +42 -0
  159. package/src/codeLineTokens.ts +241 -0
  160. package/src/doc-mjs.d.ts +23 -0
  161. package/src/editorNodes.ts +44 -0
  162. package/src/editorTheme.ts +550 -0
  163. package/src/index.ts +59 -0
  164. package/src/internal/CodeBackspacePlugin.tsx +96 -0
  165. package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
  166. package/src/internal/CodePastePlugin.tsx +144 -0
  167. package/src/internal/CodeSyntaxPlugin.tsx +493 -0
  168. package/src/internal/CodeTabPlugin.tsx +298 -0
  169. package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
  170. package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
  171. package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
  172. package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
  173. package/src/internal/SelectAllScopePlugin.tsx +86 -0
  174. package/src/internal/TableColumnFloorPlugin.tsx +99 -0
  175. package/src/internal/TablePastePlugin.tsx +223 -0
  176. package/src/internal/TableScrollRegionPlugin.tsx +107 -0
  177. package/src/internal/TaskCheckboxPlugin.tsx +246 -0
  178. package/src/internal/ToolbarLinkPopover.tsx +392 -0
  179. package/src/linkEditing.ts +20 -0
  180. package/src/linkForm.tsx +156 -0
  181. package/src/linkUtils.ts +106 -0
  182. package/src/markdown.ts +25 -0
  183. package/src/markdownCharacterReferences.ts +1137 -0
  184. package/src/markdownCodeFence.ts +216 -0
  185. package/src/markdownExtensionNode.ts +339 -0
  186. package/src/markdownExtensions.ts +496 -0
  187. package/src/markdownHardLineBreak.ts +73 -0
  188. package/src/markdownHeading.ts +33 -0
  189. package/src/markdownLink.ts +86 -0
  190. package/src/markdownListExport.ts +85 -0
  191. package/src/markdownListIndentation.ts +105 -0
  192. package/src/markdownOrderedList.ts +60 -0
  193. package/src/markdownQuote.ts +35 -0
  194. package/src/markdownSerializers.ts +108 -0
  195. package/src/markdownSource.ts +1045 -0
  196. package/src/markdownSpaceRuns.ts +164 -0
  197. package/src/markdownTable.ts +309 -0
  198. package/src/markdownTaskList.ts +222 -0
  199. package/src/markdownThematicBreak.ts +66 -0
  200. package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
  201. package/src/textSemantics.ts +104 -0
@@ -0,0 +1,1137 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file markdownCharacterReferences.ts
5
+ * @input Uses lexical, @lexical/link, @lexical/code, and core Markdown's
6
+ * decodeMarkdownCharacterReferences.
7
+ * @output Exports protectRefusedLinks, protectBackslashEscapes,
8
+ * protectLinkDestinationParentheses, and protectCharacterReferences, which
9
+ * swap every link core refuses, every backslash escape, every parenthesis
10
+ * inside a link destination, and every character reference in Markdown
11
+ * source for a private-use stand-in before Lexical imports it, and
12
+ * $restoreCharacterReferences, which puts the literal or decoded text in
13
+ * place of the stand-ins afterwards.
14
+ * @position Used by importMarkdownKeepingSource (markdownSource.ts), so every
15
+ * block — paragraphs, headings, lists, quotes, table cells, link text and
16
+ * destinations — decodes references with the one decoder core Markdown
17
+ * renders with (spec:AST-061 FR7, DEC-5), in one pass over the source and
18
+ * one over the imported text. The stand-ins mean nothing to Lexical's
19
+ * Markdown import, so a decoded `*` never starts emphasis, and no `&#digits;`
20
+ * reaches Lexical's own decoding, which ignores escapes and throws on a
21
+ * number past Unicode. Code keeps references as written.
22
+ * Backslash escapes go through the same way, so an escaped character is
23
+ * literal text wherever it is — `\[x](y)` is text, not a link, as in core
24
+ * Markdown (spec:AST-061 FR7, spec:AST-062 FR3) — and code keeps its
25
+ * backslashes. Parentheses inside a link destination go through the same
26
+ * way, so Lexical, which reads none in a destination, reads balanced ones
27
+ * as core does.
28
+ */
29
+
30
+ import {$isCodeNode} from '@lexical/code';
31
+ import {$isLinkNode} from '@lexical/link';
32
+ import {$isTableCellNode} from '@lexical/table';
33
+ import {$dfs, $findMatchingParent} from '@lexical/utils';
34
+ import {
35
+ $getState,
36
+ $isElementNode,
37
+ $isTextNode,
38
+ $setState,
39
+ createState,
40
+ type ElementNode,
41
+ type LexicalNode,
42
+ } from 'lexical';
43
+ import {
44
+ decodeMarkdownCharacterReferences,
45
+ parseInlineAst,
46
+ } from '@blue-patterns/core/Markdown/parser';
47
+
48
+ /**
49
+ * What a stand-in replaced: a reference as written, an escaped `&`, or a
50
+ * backslash-escaped character.
51
+ */
52
+ type StandIn =
53
+ | {readonly kind: 'reference'; readonly source: string}
54
+ | {readonly kind: 'ampersand'}
55
+ | {readonly kind: 'escape'; readonly character: string}
56
+ | {readonly kind: 'backslash'}
57
+ | {readonly kind: 'literal'; readonly text: string};
58
+
59
+ export interface ProtectedMarkdown {
60
+ /** The source with stand-ins in place of references. */
61
+ readonly markdown: string;
62
+ /** What each stand-in replaced. */
63
+ readonly standIns: ReadonlyMap<string, StandIn>;
64
+ }
65
+
66
+ // Something that looks like a reference; the decoder decides if it is one.
67
+ const REFERENCE_LIKE = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/y;
68
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
69
+
70
+ const PRIVATE_USE_RANGES: ReadonlyArray<readonly [number, number]> = [
71
+ [0xe000, 0xf8ff],
72
+ [0xf0000, 0xffffd],
73
+ [0x100000, 0x10fffd],
74
+ ];
75
+
76
+ /** Private-use characters that do not occur in `text`, in order. */
77
+ export function* absentCharacters(text: string): Generator<string> {
78
+ const present = new Set(text);
79
+ for (const [first, last] of PRIVATE_USE_RANGES) {
80
+ for (let codePoint = first; codePoint <= last; codePoint++) {
81
+ const character = String.fromCodePoint(codePoint);
82
+ if (!present.has(character)) {
83
+ yield character;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The ranges of `text` that are code: fenced code blocks, and code spans,
91
+ * where a backtick run opens a span only if a later run of the same length
92
+ * closes it (CommonMark 0.31). One scan collects the runs; runs of each length
93
+ * are searched by position, so the whole is O(n log n).
94
+ */
95
+ function codeRanges(text: string): Array<readonly [number, number]> {
96
+ const {fenced, spans} = codeRangesByKind(text);
97
+ return [...fenced, ...spans].sort((a, b) => a[0] - b[0]);
98
+ }
99
+
100
+ /** The fenced code blocks of `text`, and its code spans, each in order. */
101
+ function codeRangesByKind(text: string): {
102
+ fenced: Array<readonly [number, number]>;
103
+ spans: Array<readonly [number, number]>;
104
+ } {
105
+ const ranges: Array<readonly [number, number]> = [];
106
+ // Fenced code blocks, line by line.
107
+ let lineStart = 0;
108
+ let fence: {readonly marker: string; readonly start: number} | null = null;
109
+ while (lineStart <= text.length) {
110
+ const lineEnd = text.indexOf('\n', lineStart);
111
+ const end = lineEnd === -1 ? text.length : lineEnd;
112
+ const line = text.slice(lineStart, end);
113
+ if (fence == null) {
114
+ const open = FENCE_OPEN.exec(line);
115
+ if (open != null) {
116
+ fence = {marker: open[1], start: lineStart};
117
+ }
118
+ } else if (
119
+ new RegExp(
120
+ `^ {0,3}\\${fence.marker[0]}{${fence.marker.length},}[ \\t]*$`,
121
+ ).test(line)
122
+ ) {
123
+ ranges.push([fence.start, end]);
124
+ fence = null;
125
+ }
126
+ if (lineEnd === -1) {
127
+ break;
128
+ }
129
+ lineStart = lineEnd + 1;
130
+ }
131
+ if (fence != null) {
132
+ ranges.push([fence.start, text.length]);
133
+ }
134
+ const fencedCount = ranges.length;
135
+ // Code spans, outside fenced code. A backtick run is a string of backticks
136
+ // with no backtick on either side. Backslashes are literal inside a code
137
+ // span, so they never hide a closing run; an odd number of backslashes
138
+ // before a run only makes its first backtick literal, so the rest of the
139
+ // run (if any) may still open a span.
140
+ const runs: Array<{readonly start: number; readonly length: number}> = [];
141
+ let index = 0;
142
+ let fenceIndex = 0;
143
+ while (index < text.length) {
144
+ const current = ranges[fenceIndex];
145
+ if (current != null && index >= current[0]) {
146
+ index = current[1];
147
+ fenceIndex++;
148
+ continue;
149
+ }
150
+ if (text[index] === '`') {
151
+ let end = index;
152
+ while (text[end] === '`') {
153
+ end++;
154
+ }
155
+ runs.push({start: index, length: end - index});
156
+ index = end;
157
+ continue;
158
+ }
159
+ index++;
160
+ }
161
+ // Each run's position in the list of runs of its length, for finding the
162
+ // next run of a given length after a point by binary search.
163
+ const byLength = new Map<number, Array<number>>();
164
+ for (const run of runs) {
165
+ const starts = byLength.get(run.length) ?? [];
166
+ starts.push(run.start);
167
+ byLength.set(run.length, starts);
168
+ }
169
+ const nextRunAfter = (length: number, after: number): number | null => {
170
+ const starts = byLength.get(length);
171
+ if (starts == null) {
172
+ return null;
173
+ }
174
+ let low = 0;
175
+ let high = starts.length;
176
+ while (low < high) {
177
+ const middle = (low + high) >> 1;
178
+ if (starts[middle] < after) {
179
+ low = middle + 1;
180
+ } else {
181
+ high = middle;
182
+ }
183
+ }
184
+ return starts[low] ?? null;
185
+ };
186
+ let resumeAt = 0;
187
+ for (const run of runs) {
188
+ if (run.start < resumeAt) {
189
+ continue;
190
+ }
191
+ let backslashes = 0;
192
+ while (text[run.start - 1 - backslashes] === '\\') {
193
+ backslashes++;
194
+ }
195
+ const isEscaped = backslashes % 2 === 1;
196
+ const openStart = isEscaped ? run.start + 1 : run.start;
197
+ const openLength = isEscaped ? run.length - 1 : run.length;
198
+ if (openLength === 0) {
199
+ continue;
200
+ }
201
+ const closer = nextRunAfter(openLength, run.start + run.length);
202
+ if (closer == null) {
203
+ continue;
204
+ }
205
+ ranges.push([openStart, closer + openLength]);
206
+ resumeAt = closer + openLength;
207
+ }
208
+ return {
209
+ fenced: ranges.slice(0, fencedCount),
210
+ spans: ranges.slice(fencedCount).sort((a, b) => a[0] - b[0]),
211
+ };
212
+ }
213
+
214
+ /** The characters a backslash escapes (CommonMark 0.31 §2.4). */
215
+ const ESCAPABLE = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~');
216
+
217
+ /**
218
+ * Returns `markdown` with every backslash escape outside code — a backslash
219
+ * before ASCII punctuation — replaced by a private-use stand-in for the
220
+ * escaped character, and every backslash in code by a stand-in for itself,
221
+ * which Lexical would otherwise drop from a code span — except before `|`,
222
+ * which a table reads before its code. A backslash before anything else
223
+ * outside code stays as written. One pass over the source.
224
+ */
225
+ export function protectBackslashEscapes(markdown: string): ProtectedMarkdown {
226
+ const standIns = new Map<string, StandIn>();
227
+ if (!markdown.includes('\\')) {
228
+ return {markdown, standIns};
229
+ }
230
+ const available = absentCharacters(markdown);
231
+ const byKey = new Map<string, string>();
232
+ const standInFor = (key: string, value: StandIn): string | null => {
233
+ const existing = byKey.get(key);
234
+ if (existing != null) {
235
+ return existing;
236
+ }
237
+ const next = available.next();
238
+ if (next.done === true) {
239
+ // Every private-use character is in the text: leave the rest as
240
+ // written.
241
+ return null;
242
+ }
243
+ byKey.set(key, next.value);
244
+ standIns.set(next.value, value);
245
+ return next.value;
246
+ };
247
+ const code = codeRanges(markdown);
248
+ let output = '';
249
+ let copied = 0;
250
+ let codeIndex = 0;
251
+ let index = markdown.indexOf('\\');
252
+ while (index !== -1) {
253
+ while (code[codeIndex] != null && code[codeIndex][1] <= index) {
254
+ codeIndex++;
255
+ }
256
+ const range = code[codeIndex];
257
+ const character = markdown[index + 1];
258
+ const inCode = range != null && range[0] <= index;
259
+ // A table splits its cells before it reads code, so `\|` in code is the
260
+ // table's to read.
261
+ if (inCode && character === '|') {
262
+ index = markdown.indexOf('\\', index + 2);
263
+ continue;
264
+ }
265
+ const isEscape = !inCode && character != null && ESCAPABLE.has(character);
266
+ if (inCode || isEscape) {
267
+ const standIn = inCode
268
+ ? standInFor('code', {kind: 'backslash'})
269
+ : standInFor(`escape:${character}`, {
270
+ kind: 'escape',
271
+ character: character ?? '',
272
+ });
273
+ if (standIn == null) {
274
+ break;
275
+ }
276
+ output += markdown.slice(copied, index) + standIn;
277
+ copied = index + (inCode ? 1 : 2);
278
+ index = markdown.indexOf('\\', copied);
279
+ continue;
280
+ }
281
+ index = markdown.indexOf('\\', index + 1);
282
+ }
283
+ return {markdown: output + markdown.slice(copied), standIns};
284
+ }
285
+
286
+ /**
287
+ * For each `[`, `]`, `(`, and `)` in `text` outside code and not escaped,
288
+ * the index of the bracket or parenthesis it pairs with by nesting; -1 for
289
+ * the rest. One pass, so a candidate never rescans the text.
290
+ */
291
+ function pairedDelimiters(
292
+ text: string,
293
+ code: ReadonlyArray<readonly [number, number]>,
294
+ ): Int32Array {
295
+ const pair = new Int32Array(text.length).fill(-1);
296
+ const brackets: number[] = [];
297
+ const parentheses: number[] = [];
298
+ let codeIndex = 0;
299
+ for (let index = 0; index < text.length; index++) {
300
+ while (codeIndex < code.length && code[codeIndex][1] <= index) {
301
+ codeIndex++;
302
+ }
303
+ if (codeIndex < code.length && code[codeIndex][0] <= index) {
304
+ index = code[codeIndex][1] - 1;
305
+ continue;
306
+ }
307
+ const character = text[index];
308
+ const open =
309
+ character === ']'
310
+ ? brackets.pop()
311
+ : character === ')'
312
+ ? parentheses.pop()
313
+ : undefined;
314
+ if (character === '\\') {
315
+ index++;
316
+ } else if (character === '[') {
317
+ brackets.push(index);
318
+ } else if (character === '(') {
319
+ parentheses.push(index);
320
+ } else if (open !== undefined) {
321
+ pair[index] = open;
322
+ pair[open] = index;
323
+ }
324
+ }
325
+ return pair;
326
+ }
327
+
328
+ /**
329
+ * Where a destination that opens with `<` ends — the index of the `)` that
330
+ * closes the link — read as core Markdown reads it (CommonMark 0.31 §6.3):
331
+ * `'refused'` when it opens with `<` but is no angle-bracket destination,
332
+ * and null when the content after `open` (the `(`) does not open with `<`.
333
+ * Inside the brackets parentheses are plain characters; a line ending, even
334
+ * escaped, or an unescaped `<` makes it no destination; after the `>` come
335
+ * only spaces, an optional title, and the `)`.
336
+ */
337
+ function angleDestinationEnd(
338
+ text: string,
339
+ open: number,
340
+ ): number | 'refused' | null {
341
+ let index = open + 1;
342
+ while (text[index] === ' ' || text[index] === '\t') {
343
+ index++;
344
+ }
345
+ if (text[index] !== '<') {
346
+ return null;
347
+ }
348
+ for (index++; index < text.length; index++) {
349
+ const character = text[index];
350
+ if (
351
+ character === '\\' &&
352
+ text[index + 1] !== '\n' &&
353
+ text[index + 1] !== '\r'
354
+ ) {
355
+ index++;
356
+ } else if (character === '\n' || character === '\r' || character === '<') {
357
+ return 'refused';
358
+ } else if (character === '>') {
359
+ break;
360
+ }
361
+ }
362
+ if (index >= text.length) {
363
+ return 'refused';
364
+ }
365
+ index++;
366
+ while (/\s/.test(text[index] ?? '')) {
367
+ index++;
368
+ }
369
+ const quote = text[index];
370
+ if (quote === '"' || quote === "'" || quote === '(') {
371
+ const closer = quote === '(' ? ')' : quote;
372
+ for (index++; index < text.length && text[index] !== closer; index++) {
373
+ if (text[index] === '\\') {
374
+ index++;
375
+ } else if (quote === '(' && text[index] === '(') {
376
+ return 'refused';
377
+ }
378
+ }
379
+ index++;
380
+ while (/\s/.test(text[index] ?? '')) {
381
+ index++;
382
+ }
383
+ }
384
+ return text[index] === ')' ? index : 'refused';
385
+ }
386
+
387
+ /**
388
+ * Whether core Markdown refuses a link to `destination`, as written in the
389
+ * source: its own parser decides, so both surfaces refuse the same
390
+ * destinations by one policy.
391
+ */
392
+ function coreRefusesDestination(destination: string): boolean {
393
+ const probe = `[x](${destination})`;
394
+ const [only, ...rest] = parseInlineAst(probe);
395
+ return rest.length === 0 && only?.type === 'text' && only.value === probe;
396
+ }
397
+
398
+ /**
399
+ * Whether core Markdown refuses a link to `url`, a destination already
400
+ * decoded: the probe writes it back with every special character escaped.
401
+ * A URL that cannot be written back is refused.
402
+ */
403
+ export function coreRefusesUrl(url: string): boolean {
404
+ if (/[\n\r]/.test(url)) {
405
+ return true;
406
+ }
407
+ // Angle brackets never decide a scheme, so the probe percent-encodes them:
408
+ // the URL then fits one angle-bracket destination even with spaces in it.
409
+ const escaped = url
410
+ .replace(/[<>]/g, character => (character === '<' ? '%3C' : '%3E'))
411
+ .replace(/[\\()&[\]"']/g, character => `\\${character}`);
412
+ return coreRefusesDestination(/\s/.test(url) ? `<${escaped}>` : escaped);
413
+ }
414
+
415
+ /**
416
+ * Returns `markdown` with each inline link whose destination core Markdown
417
+ * refuses — `javascript:` and the like — kept from becoming a link
418
+ * (spec:AST-061 FR7). Where core shows the link as its source text, exactly
419
+ * as written, the whole source becomes one stand-in that comes back as that
420
+ * text. Where core reads no link there at all (its link text holds brackets
421
+ * of its own), only the `(` that opens the destination becomes a stand-in,
422
+ * so the text around it reads as core reads it, marks included. Brackets and
423
+ * parentheses pair by nesting, so link text holding brackets is found whole.
424
+ * Linear in the source: pairs come from one pass, and a destination inside a
425
+ * found link is not probed again. Run first, on the source as written;
426
+ * `$unwrapRefusedLinks` catches any link the editor still makes.
427
+ */
428
+ export function protectRefusedLinks(markdown: string): ProtectedMarkdown {
429
+ const standIns = new Map<string, StandIn>();
430
+ if (!markdown.includes('](')) {
431
+ return {markdown, standIns};
432
+ }
433
+ const pair = pairedDelimiters(markdown, codeRanges(markdown));
434
+ // Ranges to replace, in order: a whole link, or the `(` that opens its
435
+ // destination.
436
+ const refused: Array<{start: number; end: number; whole: boolean}> = [];
437
+ // Where the last probed destination ends: a link cannot sit inside one.
438
+ let probedTo = -1;
439
+ for (
440
+ let index = markdown.indexOf('](');
441
+ index !== -1;
442
+ index = markdown.indexOf('](', index + 1)
443
+ ) {
444
+ const open = pair[index];
445
+ // A destination in angle brackets ends where core ends it — parentheses
446
+ // inside the brackets are plain characters — so `[a](<b)c>)` is probed
447
+ // whole, not as `<b`.
448
+ const angleClose = angleDestinationEnd(markdown, index + 1);
449
+ const close = typeof angleClose === 'number' ? angleClose : pair[index + 1];
450
+ if (index < probedTo || open === -1 || close === -1) {
451
+ continue;
452
+ }
453
+ probedTo = close;
454
+ if (!coreRefusesDestination(markdown.slice(index + 2, close))) {
455
+ continue;
456
+ }
457
+ const source = markdown.slice(open, close + 1);
458
+ const [only, ...rest] = parseInlineAst(source);
459
+ if (rest.length === 0 && only?.type === 'text' && only.value === source) {
460
+ // A whole link stands for the refused links found inside it.
461
+ while (refused.length > 0 && refused[refused.length - 1].start >= open) {
462
+ refused.pop();
463
+ }
464
+ refused.push({start: open, end: close + 1, whole: true});
465
+ } else {
466
+ refused.push({start: index + 1, end: index + 2, whole: false});
467
+ }
468
+ }
469
+ if (refused.length === 0) {
470
+ return {markdown, standIns};
471
+ }
472
+ const available = absentCharacters(markdown);
473
+ let output = '';
474
+ let copied = 0;
475
+ for (const {start, end, whole} of refused) {
476
+ const next = available.next();
477
+ if (next.done === true || start < copied) {
478
+ break;
479
+ }
480
+ standIns.set(
481
+ next.value,
482
+ whole
483
+ ? {kind: 'literal', text: markdown.slice(start, end)}
484
+ : {kind: 'escape', character: '('},
485
+ );
486
+ output += markdown.slice(copied, start) + next.value;
487
+ copied = end;
488
+ }
489
+ return {markdown: output + markdown.slice(copied), standIns};
490
+ }
491
+
492
+ /**
493
+ * Marks text that holds one image's Markdown source. Export writes it
494
+ * unescaped, so it stays an image.
495
+ */
496
+ const imageSourceState = createState('astryxMdImage', {
497
+ parse: (value: unknown): boolean => value === true,
498
+ });
499
+
500
+ /** Whether `node` is text that holds an image's Markdown source. */
501
+ export function $isImageSource(node: LexicalNode): boolean {
502
+ return $isTextNode(node) && $getState(node, imageSourceState);
503
+ }
504
+
505
+ /**
506
+ * Returns `markdown` with every image outside code replaced by a private-use
507
+ * stand-in, and the source each stand-in replaced. An image starts with a `!`
508
+ * that is not escaped and the paired brackets of its text. An inline image
509
+ * (`![a](b)`) ends with the destination and title that close it, read whole
510
+ * by core's inline parser. A reference image (`![a][b]`, `![a][]`, `![a]`)
511
+ * ends with its label; its definition can sit in another block, so it is kept
512
+ * whether or not one exists. A link around an image keeps its own brackets,
513
+ * so the image stays inside the link's text.
514
+ */
515
+ export function protectImages(markdown: string): {
516
+ readonly markdown: string;
517
+ readonly images: ReadonlyMap<string, string>;
518
+ } {
519
+ const images = new Map<string, string>();
520
+ if (!markdown.includes('![')) {
521
+ return {markdown, images};
522
+ }
523
+ const pair = pairedDelimiters(markdown, codeRanges(markdown));
524
+ const found: Array<{readonly start: number; readonly end: number}> = [];
525
+ for (
526
+ let start = markdown.indexOf('![');
527
+ start !== -1;
528
+ start = markdown.indexOf('![', start + 1)
529
+ ) {
530
+ const open = start + 1;
531
+ const textClose = pair[open];
532
+ if (textClose === -1 || textClose < open) {
533
+ continue;
534
+ }
535
+ // An image inside another image's text belongs to the outer one.
536
+ if (found.length > 0 && start < found[found.length - 1].end) {
537
+ continue;
538
+ }
539
+ let backslashes = 0;
540
+ while (markdown[start - 1 - backslashes] === '\\') {
541
+ backslashes++;
542
+ }
543
+ if (backslashes % 2 === 1) {
544
+ continue;
545
+ }
546
+ const after = markdown[textClose + 1];
547
+ if (after === '(') {
548
+ const angleClose = angleDestinationEnd(markdown, textClose + 1);
549
+ const close =
550
+ typeof angleClose === 'number' ? angleClose : pair[textClose + 1];
551
+ if (close === -1 || angleClose === 'refused') {
552
+ continue;
553
+ }
554
+ const [only, ...rest] = parseInlineAst(markdown.slice(start, close + 1));
555
+ if (rest.length > 0 || only?.type !== 'image') {
556
+ continue;
557
+ }
558
+ found.push({start, end: close + 1});
559
+ } else if (after === '[' && pair[textClose + 1] > textClose + 1) {
560
+ found.push({start, end: pair[textClose + 1] + 1});
561
+ } else {
562
+ found.push({start, end: textClose + 1});
563
+ }
564
+ }
565
+ if (found.length === 0) {
566
+ return {markdown, images};
567
+ }
568
+ const available = absentCharacters(markdown);
569
+ let output = '';
570
+ let copied = 0;
571
+ for (const {start, end} of found) {
572
+ const next = available.next();
573
+ if (next.done === true) {
574
+ break;
575
+ }
576
+ images.set(next.value, markdown.slice(start, end));
577
+ output += markdown.slice(copied, start) + next.value;
578
+ copied = end;
579
+ }
580
+ return {markdown: output + markdown.slice(copied), images};
581
+ }
582
+
583
+ /**
584
+ * Puts each image's source back where its stand-in sits under `node`, as
585
+ * text marked as an image's source. The text is a token: a person deletes it
586
+ * whole, and autolinking leaves its URL alone. ImageSourceCaretPlugin keeps
587
+ * the caret out of it, so typing never replaces it.
588
+ */
589
+ export function $restoreImages(
590
+ node: ElementNode,
591
+ images: ReadonlyMap<string, string>,
592
+ ): void {
593
+ if (images.size === 0) {
594
+ return;
595
+ }
596
+ for (const text of node.getAllTextNodes()) {
597
+ const content = text.getTextContent();
598
+ const offsets: number[] = [];
599
+ let offset = 0;
600
+ for (const character of content) {
601
+ if (images.has(character)) {
602
+ offsets.push(offset, offset + character.length);
603
+ }
604
+ offset += character.length;
605
+ }
606
+ if (offsets.length === 0) {
607
+ continue;
608
+ }
609
+ const pieces = text.splitText(
610
+ ...offsets.filter(point => point > 0 && point < content.length),
611
+ );
612
+ for (const piece of pieces) {
613
+ const source = images.get(piece.getTextContent());
614
+ if (source == null) {
615
+ continue;
616
+ }
617
+ piece.setTextContent(source);
618
+ piece.setMode('token');
619
+ $setState(piece, imageSourceState, true);
620
+ }
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Takes every link under `node` whose URL core Markdown refuses out of its
626
+ * link, leaving its text in place, so no form of link text or destination
627
+ * the source pass did not find can store or draw an unsafe URL.
628
+ */
629
+ export function $unwrapRefusedLinks(node: ElementNode): void {
630
+ for (const {node: descendant} of $dfs(node)) {
631
+ if ($isLinkNode(descendant) && coreRefusesUrl(descendant.getURL())) {
632
+ for (const child of descendant.getChildren()) {
633
+ descendant.insertBefore(child);
634
+ }
635
+ descendant.remove();
636
+ }
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Returns `markdown` with every parenthesis inside an inline link
642
+ * destination — between the destination's own parentheses, balanced, as core
643
+ * Markdown and CommonMark read them — replaced by a private-use stand-in for
644
+ * itself, outside code. Run after protectBackslashEscapes, so an escaped
645
+ * parenthesis is already a stand-in and never counts toward the balance.
646
+ */
647
+ export function protectLinkDestinationParentheses(
648
+ markdown: string,
649
+ ): ProtectedMarkdown {
650
+ const standIns = new Map<string, StandIn>();
651
+ if (!markdown.includes('](')) {
652
+ return {markdown, standIns};
653
+ }
654
+ const available = absentCharacters(markdown);
655
+ const byCharacter = new Map<string, string>();
656
+ const standInFor = (character: '(' | ')'): string | null => {
657
+ const existing = byCharacter.get(character);
658
+ if (existing != null) {
659
+ return existing;
660
+ }
661
+ const next = available.next();
662
+ if (next.done === true) {
663
+ return null;
664
+ }
665
+ byCharacter.set(character, next.value);
666
+ standIns.set(next.value, {kind: 'escape', character});
667
+ return next.value;
668
+ };
669
+ const code = codeRanges(markdown);
670
+ const inner: Array<number> = [];
671
+ let codeIndex = 0;
672
+ let index = markdown.indexOf('](');
673
+ while (index !== -1) {
674
+ while (code[codeIndex] != null && code[codeIndex][1] <= index) {
675
+ codeIndex++;
676
+ }
677
+ const range = code[codeIndex];
678
+ if (range != null && range[0] <= index) {
679
+ index = markdown.indexOf('](', range[1]);
680
+ continue;
681
+ }
682
+ // The destination's own parentheses are depth one; any inside it are
683
+ // deeper. A space at depth one ends the destination (a title follows);
684
+ // a space deeper, or no closing parenthesis, means no link.
685
+ const parentheses: Array<number> = [];
686
+ let depth = 1;
687
+ let position = index + 2;
688
+ let isLink = markdown[position] !== '<';
689
+ for (; isLink && position < markdown.length; position++) {
690
+ const character = markdown[position];
691
+ if (/\s/.test(character)) {
692
+ isLink = depth === 1;
693
+ break;
694
+ }
695
+ if (character === '(') {
696
+ depth++;
697
+ parentheses.push(position);
698
+ } else if (character === ')') {
699
+ depth--;
700
+ if (depth === 0) {
701
+ break;
702
+ }
703
+ parentheses.push(position);
704
+ }
705
+ }
706
+ if (isLink && depth <= 1) {
707
+ inner.push(...parentheses);
708
+ }
709
+ index = markdown.indexOf('](', position + 1);
710
+ }
711
+ if (inner.length === 0) {
712
+ return {markdown, standIns};
713
+ }
714
+ let output = '';
715
+ let copied = 0;
716
+ for (const position of inner) {
717
+ const standIn = standInFor(markdown[position] as '(' | ')');
718
+ if (standIn == null) {
719
+ break;
720
+ }
721
+ output += markdown.slice(copied, position) + standIn;
722
+ copied = position + 1;
723
+ }
724
+ return {markdown: output + markdown.slice(copied), standIns};
725
+ }
726
+
727
+ /** The code spans a stand-in pass replaced: each stand-in's code. */
728
+ export interface ProtectedCodeSpans {
729
+ readonly markdown: string;
730
+ readonly spans: ReadonlyMap<string, string>;
731
+ /** Stand-ins for backticks in link destinations and titles. */
732
+ readonly standIns: ReadonlyMap<string, StandIn>;
733
+ }
734
+
735
+ /** Whether `text` holds a `|` with no backslash before it. */
736
+ function hasUnescapedPipe(text: string): boolean {
737
+ for (let index = text.indexOf('|'); index !== -1;) {
738
+ let backslashes = 0;
739
+ while (text[index - 1 - backslashes] === '\\') {
740
+ backslashes++;
741
+ }
742
+ if (backslashes % 2 === 0) {
743
+ return true;
744
+ }
745
+ index = text.indexOf('|', index + 1);
746
+ }
747
+ return false;
748
+ }
749
+
750
+ /**
751
+ * Test each cell on its own. One regex over the whole row backtracks in
752
+ * quadratic time on a long run of spaces.
753
+ */
754
+ function isTableDelimiterRow(line: string): boolean {
755
+ let body = line.trim();
756
+ if (body.startsWith('|')) {
757
+ body = body.slice(1);
758
+ }
759
+ if (body.endsWith('|')) {
760
+ body = body.slice(0, -1);
761
+ }
762
+ return body.split('|').every(cell => /^:?-+:?$/.test(cell.trim()));
763
+ }
764
+
765
+ /**
766
+ * The ranges of `markdown` a code span must not start in: inline link
767
+ * destinations and titles, from the `(` after a `]` to its paired `)`. The
768
+ * link reads its destination before any code span there, as core and
769
+ * CommonMark read it.
770
+ */
771
+ function linkTargetRanges(
772
+ markdown: string,
773
+ code: ReadonlyArray<readonly [number, number]>,
774
+ ): Array<readonly [number, number]> {
775
+ const ranges: Array<readonly [number, number]> = [];
776
+ if (!markdown.includes('](')) {
777
+ return ranges;
778
+ }
779
+ const pair = pairedDelimiters(markdown, code);
780
+ for (
781
+ let index = markdown.indexOf('](');
782
+ index !== -1;
783
+ index = markdown.indexOf('](', index + 1)
784
+ ) {
785
+ // An angle-bracket destination ends where core ends it: a parenthesis
786
+ // inside the brackets pairs with nothing.
787
+ const angleClose = angleDestinationEnd(markdown, index + 1);
788
+ const close = typeof angleClose === 'number' ? angleClose : pair[index + 1];
789
+ if (pair[index] !== -1 && close !== -1) {
790
+ ranges.push([index + 1, close + 1]);
791
+ }
792
+ }
793
+ return ranges;
794
+ }
795
+
796
+ /** The ranges of `markdown` that are GFM table rows, header row included. */
797
+ function tableRowRanges(markdown: string): Array<readonly [number, number]> {
798
+ const ranges: Array<readonly [number, number]> = [];
799
+ const lines = markdown.split('\n');
800
+ const starts: number[] = [];
801
+ let offset = 0;
802
+ for (const line of lines) {
803
+ starts.push(offset);
804
+ offset += line.length + 1;
805
+ }
806
+ for (let index = 1; index < lines.length; index++) {
807
+ const line = lines[index];
808
+ if (
809
+ line.includes('|') &&
810
+ isTableDelimiterRow(line) &&
811
+ lines[index - 1].includes('|')
812
+ ) {
813
+ let end = index + 1;
814
+ while (end < lines.length && lines[end].trim() !== '') {
815
+ end++;
816
+ }
817
+ ranges.push([starts[index - 1], starts[end - 1] + lines[end - 1].length]);
818
+ index = end;
819
+ }
820
+ }
821
+ return ranges;
822
+ }
823
+
824
+ /**
825
+ * Returns `markdown` with each code span outside fenced code replaced by a
826
+ * private-use stand-in, and the code each stands for, as core Markdown reads
827
+ * it. Code spans bind tighter than emphasis and links (CommonMark 0.31
828
+ * §6.1), but Lexical's import applies a code span before an earlier mark on
829
+ * the same line, which breaks every mark around code that follows another
830
+ * (`~~a~~ ~~`c`~~`); with the code out of the way, the marks pair first and
831
+ * the code comes back inside them. Backticks in a link's destination or
832
+ * title are the link's, never a code span: they become stand-ins too, put
833
+ * back into the link's address and title after import, so Lexical cannot
834
+ * pair them into code either. In a table row, a span holding an unescaped
835
+ * `|` is left alone, so the row still splits its cells there, as GFM does.
836
+ */
837
+ export function protectCodeSpans(markdown: string): ProtectedCodeSpans {
838
+ const spans = new Map<string, string>();
839
+ const standIns = new Map<string, StandIn>();
840
+ if (!markdown.includes('`')) {
841
+ return {markdown, spans, standIns};
842
+ }
843
+ const code = codeRangesByKind(markdown);
844
+ const targets = linkTargetRanges(markdown, [...code.fenced, ...code.spans]);
845
+ const rows = tableRowRanges(markdown);
846
+ // Whether a position lies in one of ranges sorted by start, for positions
847
+ // asked in order: one pass over the ranges in all.
848
+ const cursor = (ranges: ReadonlyArray<readonly [number, number]>) => {
849
+ let next = 0;
850
+ let coveredTo = -1;
851
+ return (position: number): boolean => {
852
+ while (next < ranges.length && ranges[next][0] <= position) {
853
+ coveredTo = Math.max(coveredTo, ranges[next][1]);
854
+ next++;
855
+ }
856
+ return position < coveredTo;
857
+ };
858
+ };
859
+ const inTarget = cursor(targets);
860
+ const inRow = cursor(rows);
861
+ // Replacements in source order: code spans, and backticks in targets.
862
+ const replacements: Array<{
863
+ readonly start: number;
864
+ readonly end: number;
865
+ readonly code: string | null;
866
+ }> = [];
867
+ for (const [start, end] of code.spans) {
868
+ const source = markdown.slice(start, end);
869
+ const isInRow = inRow(start);
870
+ if (inTarget(start) || (isInRow && hasUnescapedPipe(source))) {
871
+ continue;
872
+ }
873
+ const [only, ...rest] = parseInlineAst(source);
874
+ if (rest.length > 0 || only?.type !== 'inlineCode') {
875
+ continue;
876
+ }
877
+ replacements.push({start, end, code: only.value});
878
+ }
879
+ for (const [from, to] of targets) {
880
+ for (
881
+ let index = markdown.indexOf('`', from);
882
+ index !== -1 && index < to;
883
+ index = markdown.indexOf('`', index + 1)
884
+ ) {
885
+ replacements.push({start: index, end: index + 1, code: null});
886
+ }
887
+ }
888
+ replacements.sort((a, b) => a.start - b.start);
889
+ const available = absentCharacters(markdown);
890
+ let output = '';
891
+ let copied = 0;
892
+ for (const {start, end, code: content} of replacements) {
893
+ if (start < copied) {
894
+ continue;
895
+ }
896
+ const next = available.next();
897
+ if (next.done === true) {
898
+ break;
899
+ }
900
+ if (content == null) {
901
+ standIns.set(next.value, {kind: 'literal', text: '`'});
902
+ } else {
903
+ spans.set(next.value, content);
904
+ }
905
+ output += markdown.slice(copied, start) + next.value;
906
+ copied = end;
907
+ }
908
+ return {markdown: output + markdown.slice(copied), spans, standIns};
909
+ }
910
+
911
+ /**
912
+ * Puts each code span back where its stand-in sits under `node`: the code as
913
+ * text with the code format, inside whatever marks the stand-in took. In a
914
+ * table cell an escaped `\|` in code is a `|`, as GFM reads cells.
915
+ */
916
+ export function $restoreCodeSpans(
917
+ node: ElementNode,
918
+ spans: ReadonlyMap<string, string>,
919
+ ): void {
920
+ if (spans.size === 0) {
921
+ return;
922
+ }
923
+ for (const text of node.getAllTextNodes()) {
924
+ const content = text.getTextContent();
925
+ const offsets: number[] = [];
926
+ let offset = 0;
927
+ for (const character of content) {
928
+ if (spans.has(character)) {
929
+ offsets.push(offset, offset + character.length);
930
+ }
931
+ offset += character.length;
932
+ }
933
+ if (offsets.length === 0) {
934
+ continue;
935
+ }
936
+ const pieces = text.splitText(
937
+ ...offsets.filter(point => point > 0 && point < content.length),
938
+ );
939
+ for (const piece of pieces) {
940
+ const code = spans.get(piece.getTextContent());
941
+ if (code == null) {
942
+ continue;
943
+ }
944
+ const inCell = $findMatchingParent(piece, $isTableCellNode) != null;
945
+ piece.setTextContent(inCell ? code.replace(/\\\|/g, '|') : code);
946
+ if (!piece.hasFormat('code')) {
947
+ piece.toggleFormat('code');
948
+ }
949
+ }
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Returns `markdown` with every character reference outside code replaced by
955
+ * a private-use stand-in that occurs nowhere else in it, and the escaped `&`
956
+ * of an escaped reference replaced by one too. One pass over the source.
957
+ */
958
+ export function protectCharacterReferences(
959
+ markdown: string,
960
+ ): ProtectedMarkdown {
961
+ const standIns = new Map<string, StandIn>();
962
+ if (!markdown.includes('&')) {
963
+ return {markdown, standIns};
964
+ }
965
+ const available = absentCharacters(markdown);
966
+ const byReference = new Map<string, string>();
967
+ let ampersand: string | null = null;
968
+ const standInFor = (reference: string | null): string | null => {
969
+ const existing = reference == null ? ampersand : byReference.get(reference);
970
+ if (existing != null) {
971
+ return existing;
972
+ }
973
+ const next = available.next();
974
+ if (next.done === true) {
975
+ // Every private-use character is in the text or already used: leave
976
+ // the rest as written.
977
+ return null;
978
+ }
979
+ if (reference == null) {
980
+ ampersand = next.value;
981
+ standIns.set(next.value, {kind: 'ampersand'});
982
+ } else {
983
+ byReference.set(reference, next.value);
984
+ standIns.set(next.value, {kind: 'reference', source: reference});
985
+ }
986
+ return next.value;
987
+ };
988
+ const code = codeRanges(markdown);
989
+ let output = '';
990
+ let copied = 0;
991
+ let codeIndex = 0;
992
+ let index = markdown.indexOf('&');
993
+ while (index !== -1) {
994
+ while (code[codeIndex] != null && code[codeIndex][1] <= index) {
995
+ codeIndex++;
996
+ }
997
+ const range = code[codeIndex];
998
+ if (range != null && range[0] <= index) {
999
+ index = markdown.indexOf('&', range[1]);
1000
+ continue;
1001
+ }
1002
+ REFERENCE_LIKE.lastIndex = index;
1003
+ const match = REFERENCE_LIKE.exec(markdown);
1004
+ if (match == null) {
1005
+ index = markdown.indexOf('&', index + 1);
1006
+ continue;
1007
+ }
1008
+ let backslashes = 0;
1009
+ while (markdown[index - 1 - backslashes] === '\\') {
1010
+ backslashes++;
1011
+ }
1012
+ const reference = match[0];
1013
+ const end = index + reference.length;
1014
+ if (backslashes % 2 === 1) {
1015
+ // An escaped `&`: the reference stays literal, without its backslash.
1016
+ const standIn = standInFor(null);
1017
+ if (standIn != null) {
1018
+ output += markdown.slice(copied, index - 1) + standIn;
1019
+ copied = index + 1;
1020
+ }
1021
+ } else if (decodeMarkdownCharacterReferences(reference) !== reference) {
1022
+ const standIn = standInFor(reference);
1023
+ if (standIn != null) {
1024
+ output += markdown.slice(copied, index) + standIn;
1025
+ copied = end;
1026
+ }
1027
+ } else if (reference.startsWith('&#')) {
1028
+ // Not a reference (too many digits): literal, but kept from Lexical's
1029
+ // own numeric decoding.
1030
+ const standIn = standInFor(null);
1031
+ if (standIn != null) {
1032
+ output += markdown.slice(copied, index) + standIn;
1033
+ copied = index + 1;
1034
+ }
1035
+ }
1036
+ index = markdown.indexOf('&', end);
1037
+ }
1038
+ return {markdown: output + markdown.slice(copied), standIns};
1039
+ }
1040
+
1041
+ /** `text` with each stand-in replaced as `replace` says. */
1042
+ function restored(
1043
+ text: string,
1044
+ standIns: ReadonlyMap<string, StandIn>,
1045
+ replace: (standIn: StandIn) => string,
1046
+ ): string {
1047
+ let output = '';
1048
+ let changed = false;
1049
+ for (const character of text) {
1050
+ const standIn = standIns.get(character);
1051
+ if (standIn == null) {
1052
+ output += character;
1053
+ } else {
1054
+ output += replace(standIn);
1055
+ changed = true;
1056
+ }
1057
+ }
1058
+ return changed ? output : text;
1059
+ }
1060
+
1061
+ const decoded = (standIn: StandIn): string => {
1062
+ switch (standIn.kind) {
1063
+ case 'literal':
1064
+ return standIn.text;
1065
+ case 'ampersand':
1066
+ return '&';
1067
+ case 'backslash':
1068
+ return '\\';
1069
+ case 'escape':
1070
+ return standIn.character;
1071
+ case 'reference':
1072
+ return decodeMarkdownCharacterReferences(standIn.source);
1073
+ }
1074
+ };
1075
+ const asWritten = (standIn: StandIn): string => {
1076
+ switch (standIn.kind) {
1077
+ case 'literal':
1078
+ return standIn.text;
1079
+ case 'ampersand':
1080
+ return '&';
1081
+ case 'backslash':
1082
+ return '\\';
1083
+ case 'escape':
1084
+ return `\\${standIn.character}`;
1085
+ case 'reference':
1086
+ return standIn.source;
1087
+ }
1088
+ };
1089
+
1090
+ /**
1091
+ * Replaces the stand-ins under `node` with the characters their references
1092
+ * name or their escapes stand for — or, in code Lexical read as code, with
1093
+ * the references and escapes as written — in text, link destinations, and
1094
+ * link titles. One pass over the text.
1095
+ */
1096
+ export function $restoreCharacterReferences(
1097
+ node: ElementNode,
1098
+ standIns: ReadonlyMap<string, StandIn>,
1099
+ ): void {
1100
+ if (standIns.size === 0) {
1101
+ return;
1102
+ }
1103
+ const visit = (current: LexicalNode, inCode: boolean) => {
1104
+ if ($isTextNode(current)) {
1105
+ const text = current.getTextContent();
1106
+ const next = restored(
1107
+ text,
1108
+ standIns,
1109
+ inCode || current.hasFormat('code') ? asWritten : decoded,
1110
+ );
1111
+ if (next !== text) {
1112
+ current.setTextContent(next);
1113
+ }
1114
+ return;
1115
+ }
1116
+ if ($isLinkNode(current)) {
1117
+ const url = restored(current.getURL(), standIns, decoded);
1118
+ if (url !== current.getURL()) {
1119
+ current.setURL(url);
1120
+ }
1121
+ const title = current.getTitle();
1122
+ if (title != null) {
1123
+ const nextTitle = restored(title, standIns, decoded);
1124
+ if (nextTitle !== title) {
1125
+ current.setTitle(nextTitle);
1126
+ }
1127
+ }
1128
+ }
1129
+ if ($isElementNode(current)) {
1130
+ const isCode = inCode || $isCodeNode(current);
1131
+ for (const child of current.getChildren()) {
1132
+ visit(child, isCode);
1133
+ }
1134
+ }
1135
+ };
1136
+ visit(node, false);
1137
+ }