@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,804 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file markdownSource.ts
5
+ * @input Uses lexical (node state, root, node classes), @lexical/markdown
6
+ * ($convertFromMarkdownString / $convertToMarkdownString), @lexical/code,
7
+ * and the plugin node shielding and export in markdownExtensions.ts.
8
+ * @output Exports importMarkdownKeepingSource and $exportMarkdownKeepingSource,
9
+ * the source-preserving Markdown import and export behind
10
+ * markdownToEditorStateJSON, editorStateJSONToMarkdown, and getMarkdown().
11
+ * @position Internal to @blue-patterns/richtext. Implements spec:AST-062: an
12
+ * untouched top-level block exports exactly as authored, and an edit
13
+ * regenerates only the blocks it changed.
14
+ *
15
+ * Import splits the source into chunks (runs of non-blank lines; a fenced code
16
+ * block is one chunk even across blank lines) and imports each chunk on its
17
+ * own. Once node transforms settle the tree, the top-level nodes from one or
18
+ * more consecutive chunks form a group, and the group's first node records the
19
+ * exact bytes it came from and its canonical Markdown at import. Facts about
20
+ * the whole document — its byte order mark, the bytes before the first block
21
+ * and after the last, its line ending — live on the root, so moving, copying,
22
+ * or deleting blocks never moves or duplicates them.
23
+ *
24
+ * Export reads the tree without changing it. Each group's canonical Markdown
25
+ * is regenerated (and cached per node, so an unchanged tree costs a lookup per
26
+ * group); when it still matches the recorded one, the recorded bytes are
27
+ * written instead.
28
+ */
29
+
30
+ import { $convertFromMarkdownString } from '@lexical/markdown';
31
+ import { $isCodeNode } from '@lexical/code';
32
+ import { $isLinkNode } from '@lexical/link';
33
+ import { $isListItemNode, $isListNode } from '@lexical/list';
34
+ import { $createParagraphNode, $createTextNode, $getRoot, $getState, $isElementNode, $isLineBreakNode, $isTextNode, $setState, createState } from 'lexical';
35
+ import { isMarkedHardLineBreak } from "./markdownHardLineBreak.js";
36
+ import { normalizeListIndentation } from "./markdownListIndentation.js";
37
+ import { $isImageSource, $restoreCharacterReferences, $restoreCodeSpans, $restoreImages, protectBackslashEscapes, protectCharacterReferences, protectCodeSpans, protectImages, protectLinkDestinationParentheses, protectRefusedLinks, $unwrapRefusedLinks } from "./markdownCharacterReferences.js";
38
+ import { $restoreExtensionSources, absentCharacters, extensionTextView, shieldExtensionSources, withExtensionExport } from "./markdownExtensions.js";
39
+ import { $isRichTextExtensionNode } from "./markdownExtensionNode.js";
40
+ import { $convertToMarkdownKeepingTimeLinear } from "./markdownSpaceRuns.js";
41
+
42
+ /** The whitespace and content one chunk of Markdown source was split into. */
43
+
44
+ /** What a group of top-level nodes was imported from. */
45
+
46
+ /**
47
+ * The serialized form of a group record: short keys, and defaults left out,
48
+ * because every imported group carries one.
49
+ */
50
+
51
+ /** Facts about the whole imported document, kept on the root. */
52
+
53
+ function parseGroupRecord(value) {
54
+ if (typeof value !== 'object' || value === null) {
55
+ return null;
56
+ }
57
+ const serialized = value;
58
+ if (typeof serialized.g !== 'string') {
59
+ return null;
60
+ }
61
+ if (typeof serialized.s !== 'string') {
62
+ return {
63
+ group: serialized.g
64
+ };
65
+ }
66
+ return {
67
+ group: serialized.g,
68
+ size: typeof serialized.n === 'number' ? serialized.n : 1,
69
+ content: serialized.s,
70
+ trailing: typeof serialized.t === 'string' ? serialized.t : '\n\n',
71
+ canonicalHash: typeof serialized.h === 'string' ? serialized.h : '',
72
+ lineEnding: typeof serialized.e === 'string' ? serialized.e : '\n',
73
+ ...(typeof serialized.x === 'string' ? {
74
+ next: serialized.x
75
+ } : {})
76
+ };
77
+ }
78
+ function unparseGroupRecord(record) {
79
+ if (record == null) {
80
+ return null;
81
+ }
82
+ const serialized = {
83
+ g: record.group
84
+ };
85
+ if (record.content != null) {
86
+ serialized.s = record.content;
87
+ if (record.size !== 1) {
88
+ serialized.n = record.size;
89
+ }
90
+ if (record.trailing !== '\n\n') {
91
+ serialized.t = record.trailing;
92
+ }
93
+ serialized.h = record.canonicalHash;
94
+ if (record.next != null) {
95
+ serialized.x = record.next;
96
+ }
97
+ if (record.lineEnding !== '\n') {
98
+ serialized.e = record.lineEnding;
99
+ }
100
+ }
101
+ return serialized;
102
+ }
103
+ function isDocumentRecord(value) {
104
+ return typeof value === 'object' && value !== null && typeof value.importId === 'string';
105
+ }
106
+ const groupState = createState('astryxMd', {
107
+ parse: parseGroupRecord,
108
+ unparse: unparseGroupRecord
109
+ });
110
+ const documentState = createState('astryxMdDocument', {
111
+ parse: value => isDocumentRecord(value) ? value : null
112
+ });
113
+ const BLANK_LINE = /^[ \t]*\r?$/;
114
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
115
+ // A thematic break line (CommonMark 0.31), and the dash-only lines that
116
+ // underline a paragraph line above them as a heading instead.
117
+ const THEMATIC_BREAK_LINE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/;
118
+ const SETEXT_DASHES = /^ {0,3}-+[ \t]*\r?$/;
119
+ // Lines that start a block other than a paragraph.
120
+ const BLOCK_START = /^ {0,3}(?:[-*+][ \t]|\d{1,9}[.)][ \t]|>|#{1,6}(?:[ \t]|\r?$)|\|)/;
121
+
122
+ /**
123
+ * Splits Markdown into chunks: runs of non-blank lines, with a fenced code
124
+ * block kept whole across blank lines, and a thematic break always a chunk of
125
+ * its own, so editing the block beside a rule never rewrites the rule
126
+ * (spec:AST-062 FR2). A dash-only line under paragraph lines underlines them
127
+ * instead, and stays with them. Joining every chunk's leading, content, and
128
+ * trailing text gives back the input exactly.
129
+ */
130
+ /**
131
+ * The chunks of `markdown` that cover the same lines as `like`, the chunks of
132
+ * text with the same line count. Rewriting indentation can move a line across
133
+ * a chunk boundary rule — a thematic break indented into a list item becomes
134
+ * a break of its own — so the chunks follow the authored text, not the rule.
135
+ */
136
+ function sameLines(markdown, like) {
137
+ const lines = markdown.split('\n');
138
+ const lineEndings = text => text.split('\n').length - 1;
139
+ let line = 0;
140
+ return like.map(chunk => {
141
+ line += lineEndings(chunk.leading);
142
+ const count = lineEndings(chunk.content) + 1;
143
+ const content = lines.slice(line, line + count).join('\n');
144
+ line += count - 1 + lineEndings(chunk.trailing);
145
+ return {
146
+ ...chunk,
147
+ content
148
+ };
149
+ });
150
+ }
151
+ export function splitMarkdownChunks(markdown) {
152
+ const lines = markdown.split('\n');
153
+ const chunks = [];
154
+ let leading = '';
155
+ let index = 0;
156
+ // Blank lines before the first chunk.
157
+ while (index < lines.length && BLANK_LINE.test(lines[index])) {
158
+ if (index === lines.length - 1) {
159
+ // Whitespace with no content at all.
160
+ return [];
161
+ }
162
+ leading += `${lines[index]}\n`;
163
+ index++;
164
+ }
165
+ while (index < lines.length) {
166
+ const start = index;
167
+ let fence = null;
168
+ // Whether every line so far is paragraph text.
169
+ let isParagraph = true;
170
+ while (index < lines.length) {
171
+ const line = lines[index];
172
+ if (fence != null) {
173
+ index++;
174
+ const close = new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*\\r?$`);
175
+ if (close.test(line)) {
176
+ fence = null;
177
+ }
178
+ continue;
179
+ }
180
+ if (BLANK_LINE.test(line)) {
181
+ break;
182
+ }
183
+ const isRule = THEMATIC_BREAK_LINE.test(line) && !(isParagraph && index > start && SETEXT_DASHES.test(line));
184
+ if (isRule && index > start) {
185
+ // The rule starts a chunk of its own.
186
+ break;
187
+ }
188
+ const open = FENCE_OPEN.exec(line);
189
+ if (open != null) {
190
+ fence = open[1];
191
+ }
192
+ if (open != null || BLOCK_START.test(line)) {
193
+ isParagraph = false;
194
+ }
195
+ index++;
196
+ if (isRule) {
197
+ // And ends it.
198
+ break;
199
+ }
200
+ }
201
+ let content = lines.slice(start, index).join('\n');
202
+ let trailing = '';
203
+ // A CRLF line ending belongs to the trailing text, not the content.
204
+ if (content.endsWith('\r')) {
205
+ content = content.slice(0, -1);
206
+ trailing = '\r';
207
+ }
208
+ while (index < lines.length && BLANK_LINE.test(lines[index])) {
209
+ trailing += `\n${lines[index]}`;
210
+ index++;
211
+ }
212
+ if (index < lines.length) {
213
+ // The line ending before the next chunk's first line.
214
+ trailing += '\n';
215
+ }
216
+ chunks.push({
217
+ leading: chunks.length === 0 ? leading : '',
218
+ content,
219
+ trailing
220
+ });
221
+ }
222
+ return chunks;
223
+ }
224
+
225
+ /** Lines without the carriage return before each line feed. */
226
+ function withoutCarriageReturns(text) {
227
+ return text.replace(/\r(?=\n|$)/g, '');
228
+ }
229
+
230
+ /** The first line ending in `text`, or `fallback` when it has none. */
231
+ function lineEndingOf(text, fallback) {
232
+ const index = text.indexOf('\n');
233
+ if (index === -1) {
234
+ return fallback;
235
+ }
236
+ return index > 0 && text[index - 1] === '\r' ? '\r\n' : '\n';
237
+ }
238
+
239
+ /** Rewrites every line ending in `text` as `lineEnding`. */
240
+ function withLineEnding(text, lineEnding) {
241
+ const lf = text.replace(/\r\n/g, '\n');
242
+ return lineEnding === '\n' ? lf : lf.replace(/\n/g, lineEnding);
243
+ }
244
+
245
+ /**
246
+ * A 64-bit hash of `text`, from two independent 32-bit FNV-1a passes. Only
247
+ * equality matters: it tells an unchanged group from a changed one without
248
+ * storing the canonical Markdown itself.
249
+ */
250
+ function hashOf(text) {
251
+ let first = 0x811c9dc5;
252
+ let second = 0x01000193 ^ text.length;
253
+ for (let index = 0; index < text.length; index++) {
254
+ const code = text.charCodeAt(index);
255
+ first = Math.imul(first ^ code, 0x01000193) >>> 0;
256
+ second = Math.imul(second ^ code, 0x5bd1e995) >>> 0;
257
+ }
258
+ return first.toString(36) + second.toString(36).padStart(7, '0');
259
+ }
260
+ let importCount = 0;
261
+
262
+ /** An id no other import in this or any other document shares. */
263
+ function nextImportId() {
264
+ importCount++;
265
+ return `${Math.random().toString(36).slice(2, 8)}${importCount.toString(36)}`;
266
+ }
267
+
268
+ /**
269
+ * Imports Markdown into the editor's root and records its authored bytes. Runs
270
+ * two discrete updates: the first imports every chunk and stamps its nodes
271
+ * with the chunk's group id; the second runs after node transforms have
272
+ * settled the tree (adjacent lists merge, for example), so each group covers
273
+ * exactly the chunks that ended up in its nodes.
274
+ */
275
+ export function importMarkdownKeepingSource(editor, markdown, transformers, plugins = []) {
276
+ const importId = nextImportId();
277
+ const byteOrderMark = markdown.startsWith('\uFEFF');
278
+ const body = byteOrderMark ? markdown.slice(1) : markdown;
279
+ const chunks = splitMarkdownChunks(body);
280
+ // Lexical reads the same chunks with list nesting spelled its way; the
281
+ // records keep the authored bytes. Only indentation changes, so each chunk
282
+ // is the same lines of the rewritten text.
283
+ const importChunks = sameLines(normalizeListIndentation(body), chunks);
284
+ const lineEnding = lineEndingOf(body, '\n');
285
+ editor.update(() => {
286
+ const root = $getRoot();
287
+ root.clear();
288
+ chunks.forEach((chunk, index) => {
289
+ // The holder is attached while Lexical imports into it: Lexical only
290
+ // continues a paragraph, list item, or quote onto the next line
291
+ // inside an attached tree, and would otherwise start a new paragraph
292
+ // at every line ending.
293
+ const holder = $createParagraphNode();
294
+ root.append(holder);
295
+ // Lexical imports LF lines; the record keeps the authored endings.
296
+ // Adopted plugins' nodes, links core refuses (as their source text),
297
+ // then code spans, then backslash escapes and parentheses in
298
+ // link destinations, then character references go through as
299
+ // stand-ins: plugin nodes come back as extension nodes holding their
300
+ // source, escapes and parentheses as the literal characters,
301
+ // references decoded.
302
+ const shielded = shieldExtensionSources(withoutCarriageReturns(importChunks[index]?.content ?? chunk.content), plugins);
303
+ // Images go first: RichText has no image node, so each comes back as
304
+ // its source, whole. Links core refuses go next, on the source as
305
+ // written, so one whose text holds code (`[`x`](javascript:y)`)
306
+ // shows whole as core shows it; code spans next, so a safe link's
307
+ // code still reads as code.
308
+ const images = protectImages(shielded.markdown);
309
+ const refused = protectRefusedLinks(images.markdown);
310
+ const code = protectCodeSpans(refused.markdown);
311
+ const escaped = protectBackslashEscapes(code.markdown);
312
+ const destinations = protectLinkDestinationParentheses(escaped.markdown);
313
+ const referenced = protectCharacterReferences(destinations.markdown);
314
+ $convertFromMarkdownString(referenced.markdown, transformers, holder);
315
+ $restoreCharacterReferences(holder, new Map([...refused.standIns, ...code.standIns, ...escaped.standIns, ...destinations.standIns, ...referenced.standIns]));
316
+ $restoreCodeSpans(holder, code.spans);
317
+ $restoreImages(holder, images.images);
318
+ $unwrapRefusedLinks(holder);
319
+ $restoreExtensionSources(holder, shielded.standIns);
320
+ $joinSoftLineBreaks(holder);
321
+ for (const node of holder.getChildren()) {
322
+ $setState(node, groupState, {
323
+ group: `${importId}:${index}`
324
+ });
325
+ }
326
+ for (const node of holder.getChildren()) {
327
+ holder.insertBefore(node);
328
+ }
329
+ holder.remove();
330
+ });
331
+ $nestFollowingLists(root);
332
+ if (root.getChildrenSize() === 0) {
333
+ // Nothing to import: keep one empty paragraph, as Lexical does.
334
+ root.append($createParagraphNode());
335
+ }
336
+ }, {
337
+ discrete: true
338
+ });
339
+ editor.update(() => {
340
+ $recordGroups(chunks, importId, transformers);
341
+ const root = $getRoot();
342
+ const hasGroups = root.getChildren().some(node => $getState(node, groupState)?.content != null);
343
+ $setState(root, documentState, {
344
+ importId,
345
+ byteOrderMark,
346
+ leading: hasGroups ? chunks[0]?.leading ?? '' : '',
347
+ trailing: hasGroups ? chunks[chunks.length - 1]?.trailing ?? '' : body,
348
+ lineEnding
349
+ });
350
+ }, {
351
+ discrete: true
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Joins the lines of a paragraph, list item, or block quote across soft line
357
+ * breaks: a line ending without two trailing spaces or a backslash continues
358
+ * the block, as one space (CommonMark, soft line breaks; spec:AST-061 FR5).
359
+ * Lexical imports every line ending as a line break and marks the hard ones,
360
+ * so the unmarked ones outside code are the soft breaks.
361
+ */
362
+ export function $joinSoftLineBreaks(element) {
363
+ for (const child of element.getChildren()) {
364
+ if ($isElementNode(child) && !$isCodeNode(child)) {
365
+ $joinSoftLineBreaks(child);
366
+ }
367
+ }
368
+ for (const child of element.getChildren()) {
369
+ if (!$isLineBreakNode(child) || isMarkedHardLineBreak(child)) {
370
+ continue;
371
+ }
372
+ // The spaces around a soft break are part of it.
373
+ const previous = child.getPreviousSibling();
374
+ if ($isTextNode(previous) && !previous.hasFormat('code')) {
375
+ // Counted from the end: a pattern such as /[ \t]+$/ retries from every
376
+ // space in a long run that does not end the text, so its time grows
377
+ // with the square of the run.
378
+ const text = previous.getTextContent();
379
+ let end = text.length;
380
+ while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) {
381
+ end--;
382
+ }
383
+ previous.setTextContent(text.slice(0, end));
384
+ }
385
+ const next = child.getNextSibling();
386
+ if ($isTextNode(next) && !next.hasFormat('code')) {
387
+ next.setTextContent(next.getTextContent().replace(/^[ \t]+/, ''));
388
+ }
389
+ child.replace($createTextNode(' '));
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Moves a list that begins nested right after another list into that list's
395
+ * last item. Lexical starts a new top-level list for an item of a different
396
+ * type (a bullet under `1. item`) and nests it there, while CommonMark nests
397
+ * it inside the item above; without this, writing it back would lose the
398
+ * nesting (spec:AST-061 FR5, spec:AST-062 FR3).
399
+ */
400
+ export function $nestFollowingLists(root) {
401
+ for (const node of root.getChildren()) {
402
+ const previous = node.getPreviousSibling();
403
+ if (!$isListNode(node) || !$isListNode(previous)) {
404
+ continue;
405
+ }
406
+ // Lexical keeps a nested list in an item of its own after its parent.
407
+ let first = node.getFirstChild();
408
+ while ($isListItemNode(first) && first.getChildrenSize() === 1 && $isListNode(first.getFirstChild())) {
409
+ previous.append(first);
410
+ first = node.getFirstChild();
411
+ }
412
+ if (node.getChildrenSize() === 0) {
413
+ node.remove();
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Turns the first pass's chunk stamps into groups. Consecutive nodes stamped
420
+ * with one chunk form a group, and the group also covers every later chunk up
421
+ * to the next group's: those chunks merged into its nodes or imported to
422
+ * nothing. Chunks before the first group become part of it.
423
+ */
424
+ function $recordGroups(chunks, importId, transformers) {
425
+ const children = $getRoot().getChildren();
426
+ const chunkOf = node => {
427
+ const group = $getState(node, groupState)?.group;
428
+ if (group == null || !group.startsWith(`${importId}:`)) {
429
+ return null;
430
+ }
431
+ return Number(group.slice(importId.length + 1));
432
+ };
433
+ let index = 0;
434
+ let isFirstGroup = true;
435
+ while (index < children.length) {
436
+ const firstChunk = chunkOf(children[index]);
437
+ if (firstChunk == null) {
438
+ index++;
439
+ continue;
440
+ }
441
+ const group = [];
442
+ while (index < children.length && chunkOf(children[index]) === firstChunk) {
443
+ group.push(children[index]);
444
+ index++;
445
+ }
446
+ const nextChunk = index === children.length ? chunks.length : chunkOf(children[index]) ?? chunks.length;
447
+ const startChunk = isFirstGroup ? 0 : firstChunk;
448
+ isFirstGroup = false;
449
+ const covered = chunks.slice(startChunk, nextChunk);
450
+ const content = covered.map((chunk, position) => (position === 0 ? '' : chunk.leading) + (position === covered.length - 1 ? chunk.content : chunk.content + chunk.trailing)).join('');
451
+ const isLastGroup = index === children.length;
452
+ const trailing = isLastGroup ? '' : covered[covered.length - 1]?.trailing ?? '';
453
+ const record = {
454
+ group: `${importId}:${firstChunk}`,
455
+ size: group.length,
456
+ content,
457
+ trailing,
458
+ canonicalHash: $canonicalMarkdown(group, transformers).hash,
459
+ lineEnding: lineEndingOf(content + (covered[covered.length - 1]?.trailing ?? ''), '\n'),
460
+ ...(!isLastGroup && !ENDS_WITH_BLANK_LINE.test(trailing) ? {
461
+ next: `${importId}:${nextChunk}`
462
+ } : {})
463
+ };
464
+ group.forEach((node, position) => {
465
+ $setState(node, groupState, position === 0 ? record : {
466
+ group: record.group
467
+ });
468
+ });
469
+ }
470
+ }
471
+
472
+ /**
473
+ * An element that lists `nodes` as its children without adopting them, so
474
+ * Lexical's exporter can read a run of top-level nodes on its own. The
475
+ * exporter only ever asks the root it is given for its children.
476
+ */
477
+ function childrenOf(nodes) {
478
+ return {
479
+ getChildren: () => [...nodes]
480
+ };
481
+ }
482
+
483
+ /** Every node in the runs' subtrees, in document order. */
484
+ function descendantsOf(nodes) {
485
+ const out = [];
486
+ const visit = node => {
487
+ out.push(node);
488
+ if ($isElementNode(node)) {
489
+ node.getChildren().forEach(visit);
490
+ }
491
+ };
492
+ nodes.forEach(visit);
493
+ return out;
494
+ }
495
+
496
+ /**
497
+ * Canonical Markdown per run of nodes, keyed by the run's first node. Lexical
498
+ * keeps a node's object across editor states until that node itself changes,
499
+ * so a run whose every descendant is the same object as last time is
500
+ * unchanged and costs a walk instead of an export.
501
+ */
502
+ const canonicalCache = new WeakMap();
503
+ function $canonicalMarkdown(nodes, transformers) {
504
+ const descendants = descendantsOf(nodes);
505
+ const cached = canonicalCache.get(nodes[0]);
506
+ if (cached != null && cached.transformers === transformers && cached.descendants.length === descendants.length && cached.descendants.every((node, index) => node === descendants[index])) {
507
+ return cached;
508
+ }
509
+ // Inline plugin nodes read as text inside the marks around them, so a mark
510
+ // put on or taken off a node alone changes the canonical form too.
511
+ const {
512
+ placeholders,
513
+ sources
514
+ } = extensionPlaceholders(descendants, descendants.map(node => node.getTextContent()).join(''));
515
+ let markdown = $convertToMarkdownKeepingTimeLinear(withExtensionExport(transformers), childrenOf(placeholders.size === 0 ? nodes : nodes.map(node => markedView(node, null, placeholders))));
516
+ for (const [placeholder, source] of sources) {
517
+ markdown = markdown.split(placeholder).join(source);
518
+ }
519
+ const entry = {
520
+ descendants,
521
+ transformers,
522
+ markdown,
523
+ hash: hashOf(markdown)
524
+ };
525
+ canonicalCache.set(nodes[0], entry);
526
+ return entry;
527
+ }
528
+
529
+ /**
530
+ * Line starts that would turn literal text into a block structure. Each match
531
+ * is the spaces before the marker, where the escape goes: block markers may
532
+ * follow up to three spaces (CommonMark 0.31), and a list item's continuation
533
+ * line adds its own indentation, so any spaces count.
534
+ */
535
+ const LINE_START_SYNTAX = [
536
+ // Block quote or ATX heading marker.
537
+ /^ *(?=>|#{1,6}(?:[ \t]|$))/,
538
+ // Bullet list item.
539
+ /^ *(?=[-+](?:[ \t]|$))/,
540
+ // Setext underline or thematic break made of `=` or `-`.
541
+ /^ *(?=[=-][=\- \t]*$)/,
542
+ // Table delimiter row.
543
+ /^ *(?=[|:][|:\- \t]*-[|:\- \t]*$)/];
544
+ // An ordered list item escapes its delimiter, not its first character.
545
+ const ORDERED_LIST_START = /^( *\d{1,9})(?=[.)](?:[ \t]|$))/;
546
+
547
+ // Inline syntax Lexical's export leaves unescaped: link and image brackets and
548
+ // character references.
549
+ const INLINE_SYNTAX = /[[\]]|&(?=#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};|[A-Za-z][A-Za-z0-9]*;)/g;
550
+ const PRIVATE_USE_RANGES = [[0xe000, 0xf8ff], [0xf0000, 0xffffd], [0x100000, 0x10fffd]];
551
+
552
+ /**
553
+ * A token that appears nowhere in `text`. There are 137,468 private-use code
554
+ * points, so a text shorter than that always misses one of them; a longer
555
+ * text cannot contain every one of the 137,468² ordered pairs.
556
+ */
557
+ export function absentToken(text) {
558
+ const present = new Set(text);
559
+ for (const [first, last] of PRIVATE_USE_RANGES) {
560
+ for (let codePoint = first; codePoint <= last; codePoint++) {
561
+ const candidate = String.fromCodePoint(codePoint);
562
+ if (!present.has(candidate)) {
563
+ return candidate;
564
+ }
565
+ }
566
+ }
567
+ for (const [first, last] of PRIVATE_USE_RANGES) {
568
+ for (let a = first; a <= last; a++) {
569
+ for (let b = first; b <= last; b++) {
570
+ const candidate = String.fromCodePoint(a, b);
571
+ if (!text.includes(candidate)) {
572
+ return candidate;
573
+ }
574
+ }
575
+ }
576
+ }
577
+ throw new Error('No escape token is absent from the text');
578
+ }
579
+
580
+ /**
581
+ * A view of `node` whose text marks every character that needs a backslash
582
+ * with `token` (none when `token` is null), and whose inline plugin nodes read
583
+ * as text holding their placeholders. Views delegate everything else to the
584
+ * node they wrap, so Lexical's exporter reads them like the real tree without
585
+ * changing it.
586
+ */
587
+ function markedView(node, token, placeholders) {
588
+ const placeholder = placeholders.get(node.getKey());
589
+ if (placeholder != null && $isRichTextExtensionNode(node)) {
590
+ return extensionTextView(node, placeholder);
591
+ }
592
+ if (placeholder != null && $isTextNode(node)) {
593
+ const view = Object.create(node);
594
+ view.getTextContent = () => placeholder;
595
+ return view;
596
+ }
597
+ if ($isTextNode(node)) {
598
+ if (node.hasFormat('code') || $isCodeNode(node.getParent())) {
599
+ return node;
600
+ }
601
+ if (token == null) {
602
+ return Object.create(node);
603
+ }
604
+ let text = node.getTextContent().replace(INLINE_SYNTAX, match => token + match);
605
+ // A `!` right before a link would make the link an image.
606
+ if (text.endsWith('!') && $isLinkNode(node.getNextSibling())) {
607
+ text = text.slice(0, -1) + token + '!';
608
+ }
609
+ // A `(` right after an image that ends in `]` would become its
610
+ // destination.
611
+ const before = node.getPreviousSibling();
612
+ if (text.startsWith('(') && before != null && $isImageSource(before) && before.getTextContent().endsWith(']')) {
613
+ text = token + text;
614
+ }
615
+ const previous = node.getPreviousSibling();
616
+ if (previous == null || $isLineBreakNode(previous)) {
617
+ const ordered = ORDERED_LIST_START.exec(text);
618
+ const start = LINE_START_SYNTAX.map(pattern => pattern.exec(text)).find(match => match != null);
619
+ const before = ordered?.[1] ?? start?.[0];
620
+ if (before != null) {
621
+ text = before + token + text.slice(before.length);
622
+ }
623
+ }
624
+ const view = Object.create(node);
625
+ view.getTextContent = () => text;
626
+ return view;
627
+ }
628
+ if ($isElementNode(node) && !$isCodeNode(node)) {
629
+ // Read once: each read walks every child, so reading per child would
630
+ // cost the square of their number — a run of tabs is one node per tab.
631
+ const original = node.getChildren();
632
+ const children = original.map(child => markedView(child, token, placeholders));
633
+ // Each view's siblings are the views beside it, so a text view sees a
634
+ // plugin node's text view as text with formats, not as a gap.
635
+ children.forEach((child, index) => {
636
+ if (child !== original[index]) {
637
+ child.getPreviousSibling = () => children[index - 1] ?? null;
638
+ child.getNextSibling = () => children[index + 1] ?? null;
639
+ }
640
+ });
641
+ const view = Object.create(node);
642
+ view.getChildren = () => children;
643
+ return view;
644
+ }
645
+ return node;
646
+ }
647
+
648
+ // A reference image's label at the end of its source: `[b]` in `![a][b]`.
649
+ const IMAGE_LABEL_END = /\]\[(?:[^\\\]]|\\.)*\]$/;
650
+
651
+ /**
652
+ * An image's source as an export writes it. A shortcut reference image
653
+ * (`![a]`) right before a link writes in its collapsed form (`![a][]`), so
654
+ * the link's text never reads as its label.
655
+ */
656
+ function $imageSourceForExport(node) {
657
+ const source = node.getTextContent();
658
+ const isShortcut = source.endsWith(']') && !IMAGE_LABEL_END.test(source);
659
+ return isShortcut && $isLinkNode(node.getNextSibling()) ? `${source}[]` : source;
660
+ }
661
+
662
+ /**
663
+ * A placeholder for each inline plugin node among `nodes`, absent from
664
+ * `text`, and the source each placeholder stands for.
665
+ */
666
+ function extensionPlaceholders(nodes, text) {
667
+ const placeholders = new Map();
668
+ const sources = new Map();
669
+ const free = absentCharacters(text);
670
+ for (const node of nodes) {
671
+ const source = $isRichTextExtensionNode(node) && node.isInline() ? node.getSource() : $isImageSource(node) ? $imageSourceForExport(node) : null;
672
+ if (source != null) {
673
+ const placeholder = free.next().value;
674
+ if (placeholder == null) {
675
+ break;
676
+ }
677
+ placeholders.set(node.getKey(), placeholder);
678
+ sources.set(placeholder, source);
679
+ }
680
+ }
681
+ return {
682
+ placeholders,
683
+ sources
684
+ };
685
+ }
686
+
687
+ /**
688
+ * The canonical Markdown of a changed group, with literal text escaped so it
689
+ * imports again as the structure the editor showed (spec:AST-062 FR3).
690
+ */
691
+ function $regeneratedMarkdown(nodes, canonical, transformers) {
692
+ // The token is absent from the plain export and from every text the views
693
+ // mark, so each token in the marked export is one of the marks.
694
+ const text = canonical + nodes.map(node => node.getTextContent()).join('');
695
+ const token = absentToken(text);
696
+ // Inline plugin nodes export as placeholders inside the text around them,
697
+ // so the marks around a node wrap it; each placeholder becomes the node's
698
+ // exact source afterwards.
699
+ const {
700
+ placeholders,
701
+ sources
702
+ } = extensionPlaceholders(descendantsOf(nodes), text + token);
703
+ let marked = $convertToMarkdownKeepingTimeLinear(withExtensionExport(transformers), childrenOf(nodes.map(node => markedView(node, token, placeholders)))).split(token).join('\\');
704
+ for (const [placeholder, source] of sources) {
705
+ marked = marked.split(placeholder).join(source);
706
+ }
707
+ return marked;
708
+ }
709
+ const ENDS_WITH_BLANK_LINE = /\n[ \t]*\r?\n[ \t\r]*$/;
710
+
711
+ /**
712
+ * Exports the root per spec:AST-062: unchanged groups as authored, changed and
713
+ * new blocks in canonical form, all in the document's envelope. Reads the tree
714
+ * without changing it, so it runs in a read of the live editor state.
715
+ */
716
+ /** Whether `text` is one thematic break line, as written. */
717
+ function isRuleLine(text) {
718
+ return !text.includes('\n') && THEMATIC_BREAK_LINE.test(text);
719
+ }
720
+
721
+ /**
722
+ * Whether the bytes recorded after `piece` still separate it from `next`.
723
+ * Bytes ending in a blank line separate any two blocks. A single line break
724
+ * only follows or precedes a thematic break (a rule is a chunk of its own),
725
+ * and it belongs to the block it follows:
726
+ *
727
+ * - after a rule as written, it stays whatever comes next, because no line
728
+ * after a rule can join it;
729
+ * - between the same two blocks, both as written, it stays;
730
+ * - after a changed block, it stays only above the same rule as written when
731
+ * that rule cannot underline the block as a heading (`***`, `___`, or
732
+ * spaced dashes, not `---`).
733
+ *
734
+ * Everywhere else — next to a moved, inserted, deleted, or changed block — a
735
+ * blank line separates the two, so no block can join its neighbor.
736
+ */
737
+ function keepsRecordedTrailing(piece, next) {
738
+ // The last block's recorded trailing is empty: it separates nothing.
739
+ if (piece.trailing == null || !piece.trailing.includes('\n')) {
740
+ return false;
741
+ }
742
+ if (ENDS_WITH_BLANK_LINE.test(piece.trailing)) {
743
+ return true;
744
+ }
745
+ if (piece.isAsWritten && isRuleLine(piece.text)) {
746
+ return true;
747
+ }
748
+ if (piece.next == null || piece.next !== next.group || !next.isAsWritten) {
749
+ return false;
750
+ }
751
+ return piece.isAsWritten || isRuleLine(next.text) && !SETEXT_DASHES.test(next.text);
752
+ }
753
+ export function $exportMarkdownKeepingSource(transformers) {
754
+ const transformerList = transformers;
755
+ const root = $getRoot();
756
+ const document = $getState(root, documentState);
757
+ const lineEnding = document?.lineEnding ?? '\n';
758
+ const children = root.getChildren();
759
+ const pieces = [];
760
+ let index = 0;
761
+ while (index < children.length) {
762
+ const first = children[index];
763
+ const record = $getState(first, groupState);
764
+ const group = [first];
765
+ index++;
766
+ if (record?.content != null) {
767
+ while (index < children.length && $getState(children[index], groupState)?.group === record.group && $getState(children[index], groupState)?.content == null) {
768
+ group.push(children[index]);
769
+ index++;
770
+ }
771
+ }
772
+ const canonical = $canonicalMarkdown(group, transformerList);
773
+ // A group imported into another document keeps its content but takes this
774
+ // document's line ending and separators.
775
+ const isOwn = document != null && record?.group.startsWith(`${document.importId}:`) === true;
776
+ if (record?.content != null && group.length === record.size && canonical.hash === record.canonicalHash) {
777
+ pieces.push({
778
+ text: isOwn ? record.content : withLineEnding(record.content, lineEnding),
779
+ trailing: isOwn ? record.trailing ?? null : null,
780
+ isAsWritten: isOwn,
781
+ group: record.group,
782
+ next: record.next ?? null
783
+ });
784
+ continue;
785
+ }
786
+ const regenerated = $regeneratedMarkdown(group, canonical.markdown, transformerList);
787
+ pieces.push({
788
+ text: withLineEnding(regenerated, isOwn && record?.lineEnding != null ? record.lineEnding : lineEnding),
789
+ trailing: isOwn ? record?.trailing ?? null : null,
790
+ isAsWritten: false,
791
+ group: record?.group ?? null,
792
+ next: isOwn ? record?.next ?? null : null
793
+ });
794
+ }
795
+ const separator = lineEnding + lineEnding;
796
+ let output = (document?.byteOrderMark === true ? '\uFEFF' : '') + (document?.leading ?? '');
797
+ pieces.forEach((piece, position) => {
798
+ output += piece.text;
799
+ if (position < pieces.length - 1) {
800
+ output += keepsRecordedTrailing(piece, pieces[position + 1]) ? piece.trailing ?? '' : separator;
801
+ }
802
+ });
803
+ return output + (document?.trailing ?? '');
804
+ }