@liminis/editor 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +376 -0
  3. package/dist/annotations/anchor-align.d.ts +50 -0
  4. package/dist/annotations/anchor-align.js +74 -0
  5. package/dist/annotations/anchor-model.d.ts +81 -0
  6. package/dist/annotations/anchor-model.js +80 -0
  7. package/dist/annotations/anchor-resolver.d.ts +92 -0
  8. package/dist/annotations/anchor-resolver.js +231 -0
  9. package/dist/annotations/block-structure.d.ts +65 -0
  10. package/dist/annotations/block-structure.js +113 -0
  11. package/dist/annotations/types.d.ts +159 -0
  12. package/dist/annotations/types.js +50 -0
  13. package/dist/annotations.d.ts +25 -0
  14. package/dist/annotations.js +27 -0
  15. package/dist/app/App.d.ts +64 -0
  16. package/dist/app/App.js +180 -0
  17. package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
  18. package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
  19. package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
  20. package/dist/app/editor/AnchorScrollPlugin.js +134 -0
  21. package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
  22. package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
  23. package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
  24. package/dist/app/editor/AnnotationPlugin.js +135 -0
  25. package/dist/app/editor/AnnotationSurface.d.ts +34 -0
  26. package/dist/app/editor/AnnotationSurface.js +115 -0
  27. package/dist/app/editor/AssetContext.d.ts +20 -0
  28. package/dist/app/editor/AssetContext.js +81 -0
  29. package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
  30. package/dist/app/editor/BlockClickPlugin.js +126 -0
  31. package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
  32. package/dist/app/editor/CodeBlockPlugin.js +219 -0
  33. package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
  34. package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
  35. package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
  36. package/dist/app/editor/DragHandlePlugin.js +300 -0
  37. package/dist/app/editor/Editor.d.ts +84 -0
  38. package/dist/app/editor/Editor.js +539 -0
  39. package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
  40. package/dist/app/editor/FrontmatterPlugin.js +301 -0
  41. package/dist/app/editor/ImageModal.d.ts +13 -0
  42. package/dist/app/editor/ImageModal.js +267 -0
  43. package/dist/app/editor/ImagePlugin.d.ts +3 -0
  44. package/dist/app/editor/ImagePlugin.js +187 -0
  45. package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
  46. package/dist/app/editor/LinkClickPlugin.js +90 -0
  47. package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
  48. package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
  49. package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
  50. package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
  51. package/dist/app/editor/SearchPlugin.d.ts +1 -0
  52. package/dist/app/editor/SearchPlugin.js +379 -0
  53. package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
  54. package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
  55. package/dist/app/editor/SlashMenu.d.ts +11 -0
  56. package/dist/app/editor/SlashMenu.js +494 -0
  57. package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
  58. package/dist/app/editor/SlashMenuPlugin.js +168 -0
  59. package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
  60. package/dist/app/editor/TableActionsPlugin.js +398 -0
  61. package/dist/app/editor/TogglePlugin.d.ts +1 -0
  62. package/dist/app/editor/TogglePlugin.js +159 -0
  63. package/dist/app/editor/Toolbar.d.ts +10 -0
  64. package/dist/app/editor/Toolbar.js +263 -0
  65. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
  66. package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
  67. package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
  68. package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
  69. package/dist/app/editor/annotation-marks.d.ts +177 -0
  70. package/dist/app/editor/annotation-marks.js +750 -0
  71. package/dist/app/editor/annotationCommands.d.ts +13 -0
  72. package/dist/app/editor/annotationCommands.js +11 -0
  73. package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
  74. package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
  75. package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
  76. package/dist/app/editor/c4/edge-clipping.js +139 -0
  77. package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
  78. package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
  79. package/dist/app/editor/c4/layout.d.ts +25 -0
  80. package/dist/app/editor/c4/layout.js +839 -0
  81. package/dist/app/editor/c4/parser.d.ts +19 -0
  82. package/dist/app/editor/c4/parser.js +410 -0
  83. package/dist/app/editor/c4/render-to-string.d.ts +24 -0
  84. package/dist/app/editor/c4/render-to-string.js +34 -0
  85. package/dist/app/editor/c4/renderer.d.ts +64 -0
  86. package/dist/app/editor/c4/renderer.js +569 -0
  87. package/dist/app/editor/c4/types.d.ts +203 -0
  88. package/dist/app/editor/c4/types.js +43 -0
  89. package/dist/app/editor/correction-yaml.d.ts +10 -0
  90. package/dist/app/editor/correction-yaml.js +40 -0
  91. package/dist/app/editor/editorNodes.d.ts +9 -0
  92. package/dist/app/editor/editorNodes.js +36 -0
  93. package/dist/app/editor/index.d.ts +1 -0
  94. package/dist/app/editor/index.js +1 -0
  95. package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
  96. package/dist/app/editor/mark-overlap-resolver.js +45 -0
  97. package/dist/app/editor/nodes/C4Component.d.ts +14 -0
  98. package/dist/app/editor/nodes/C4Component.js +284 -0
  99. package/dist/app/editor/nodes/C4Node.d.ts +32 -0
  100. package/dist/app/editor/nodes/C4Node.js +112 -0
  101. package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
  102. package/dist/app/editor/nodes/CalloutNode.js +94 -0
  103. package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
  104. package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
  105. package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
  106. package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
  107. package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
  108. package/dist/app/editor/nodes/CustomListNode.js +54 -0
  109. package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
  110. package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
  111. package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
  112. package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
  113. package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
  114. package/dist/app/editor/nodes/EquationComponent.js +130 -0
  115. package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
  116. package/dist/app/editor/nodes/EquationNode.js +179 -0
  117. package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
  118. package/dist/app/editor/nodes/FootnoteNode.js +151 -0
  119. package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
  120. package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
  121. package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
  122. package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
  123. package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
  124. package/dist/app/editor/nodes/HtmlNode.js +121 -0
  125. package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
  126. package/dist/app/editor/nodes/ImageComponent.js +228 -0
  127. package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
  128. package/dist/app/editor/nodes/ImageNode.js +183 -0
  129. package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
  130. package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
  131. package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
  132. package/dist/app/editor/nodes/MermaidComponent.js +216 -0
  133. package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
  134. package/dist/app/editor/nodes/MermaidNode.js +96 -0
  135. package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
  136. package/dist/app/editor/nodes/ToggleNode.js +237 -0
  137. package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
  138. package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
  139. package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
  140. package/dist/app/editor/nodes/diagram-utils.js +29 -0
  141. package/dist/app/editor/nodes/index.d.ts +30 -0
  142. package/dist/app/editor/nodes/index.js +17 -0
  143. package/dist/app/editor/utils.d.ts +2 -0
  144. package/dist/app/editor/utils.js +17 -0
  145. package/dist/app/mapper/index.d.ts +3 -0
  146. package/dist/app/mapper/index.js +2 -0
  147. package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
  148. package/dist/app/mapper/lexicalToMdast.js +1937 -0
  149. package/dist/app/mapper/mdastToLexical.d.ts +31 -0
  150. package/dist/app/mapper/mdastToLexical.js +1075 -0
  151. package/dist/contract.d.ts +8 -0
  152. package/dist/contract.js +8 -0
  153. package/dist/headless.d.ts +40 -0
  154. package/dist/headless.js +45 -0
  155. package/dist/host/context.d.ts +20 -0
  156. package/dist/host/context.js +24 -0
  157. package/dist/host/defaults.d.ts +21 -0
  158. package/dist/host/defaults.js +47 -0
  159. package/dist/host/messages.d.ts +26 -0
  160. package/dist/host/messages.js +47 -0
  161. package/dist/host/types.d.ts +75 -0
  162. package/dist/host/types.js +13 -0
  163. package/dist/index.d.ts +35 -0
  164. package/dist/index.js +51 -0
  165. package/dist/markdown/annotate-sentinels.d.ts +42 -0
  166. package/dist/markdown/annotate-sentinels.js +86 -0
  167. package/dist/markdown/parse.d.ts +56 -0
  168. package/dist/markdown/parse.js +516 -0
  169. package/dist/markdown/stringify.d.ts +7 -0
  170. package/dist/markdown/stringify.js +439 -0
  171. package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
  172. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
  173. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
  174. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
  175. package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
  176. package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
  177. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
  178. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
  179. package/dist/markdown.d.ts +28 -0
  180. package/dist/markdown.js +44 -0
  181. package/dist/mathjax-config.d.ts +89 -0
  182. package/dist/mathjax-config.js +133 -0
  183. package/dist/nodes.d.ts +34 -0
  184. package/dist/nodes.js +34 -0
  185. package/dist/stores/correctionStore.d.ts +15 -0
  186. package/dist/stores/correctionStore.js +12 -0
  187. package/dist/stores/editorStore.d.ts +16 -0
  188. package/dist/stores/editorStore.js +27 -0
  189. package/dist/styles.css +2477 -0
  190. package/dist/types.d.ts +296 -0
  191. package/dist/types.js +91 -0
  192. package/dist/utils/file-types.d.ts +10 -0
  193. package/dist/utils/file-types.js +46 -0
  194. package/docs/annotations.md +134 -0
  195. package/docs/decisions/README.md +72 -0
  196. package/docs/decisions/adr-002.md +27 -0
  197. package/docs/decisions/adr-007.md +27 -0
  198. package/docs/decisions/adr-010.md +26 -0
  199. package/docs/decisions/adr-024.md +124 -0
  200. package/docs/decisions/adr-024b.md +0 -0
  201. package/docs/decisions/adr-025.md +103 -0
  202. package/docs/decisions/adr-027.md +66 -0
  203. package/docs/decisions/adr-057.md +67 -0
  204. package/docs/decisions/adr-070.md +131 -0
  205. package/docs/decisions/adr-075.md +410 -0
  206. package/docs/decisions/adr-076.md +183 -0
  207. package/docs/decisions/adr-077.md +410 -0
  208. package/docs/decisions/adr-078.md +243 -0
  209. package/docs/decisions/adr-079.md +167 -0
  210. package/docs/decisions/adr-080.md +325 -0
  211. package/docs/decisions/adr-081.md +108 -0
  212. package/docs/decisions/adr-082.md +123 -0
  213. package/docs/decisions/adr-083.md +204 -0
  214. package/docs/decisions/adr-084.md +179 -0
  215. package/docs/editor-api.md +106 -0
  216. package/docs/markdown-pipeline.md +282 -0
  217. package/docs/provenance.md +106 -0
  218. package/docs/zusammen-editor-capability-map.md +232 -0
  219. package/package.json +175 -0
@@ -0,0 +1,1075 @@
1
+ import { $createParagraphNode, $createTextNode, $createLineBreakNode, $isLineBreakNode, $getRoot, TextNode, } from 'lexical';
2
+ import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text';
3
+ import { $createCodeNode } from '@lexical/code';
4
+ import { $isLinkNode } from '@lexical/link';
5
+ import { $createHorizontalRuleNode, $createImageNode, $isImageNode, $createCalloutNode, $createToggleContainerNode, $createToggleTitleNode, $createToggleContentNode, $createEquationNode, $isEquationNode, $createFootnoteNode, $isFootnoteNode, $createHtmlNode, $isHtmlNode, $createMermaidNode, $createC4Node, $createFrontmatterNode, $createCustomLinkNode, $createCustomListNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $createCustomListItemNode, $createListItemParagraphBreakNode, } from '../editor/nodes/index.js';
6
+ import { parseFormattedAlias } from '../editor/MarkdownShortcutsPlugin.js';
7
+ import { $createTableNode, $createTableRowNode, $createTableCellNode, TableCellHeaderStates } from '@lexical/table';
8
+ import { getFileType } from '../../utils/file-types.js';
9
+ // Set only while a *WithOffsets import is running, so convertText/convertInlineCode
10
+ // can record spans without threading a collector parameter through every one of
11
+ // this file's many recursive conversion functions. Lexical updates run their
12
+ // updater function synchronously and non-reentrantly, so this is safe: it's
13
+ // cleared before the exported functions below return.
14
+ let offsetSpanCollector = null;
15
+ function recordOffsetSpan(position, textNode) {
16
+ if (!offsetSpanCollector)
17
+ return;
18
+ const start = position?.start?.offset;
19
+ const end = position?.end?.offset;
20
+ if (start == null || end == null)
21
+ return;
22
+ // An OffsetSpan is only usable if raw-markdown offsets inside it map to the
23
+ // TextNode's own character offsets one-for-one — the consumer subtracts
24
+ // `span.start` from a raw offset and uses the result as a Lexical text
25
+ // offset directly. mdast's `position` covers the *source* range while
26
+ // `node.value` is the *decoded* text, so any backslash escape or character
27
+ // reference in the span breaks that correspondence:
28
+ //
29
+ // `a \* b` → span width 6, node text "a * b" (5)
30
+ // `a & b` → span width 9, node text "a & b" (5)
31
+ //
32
+ // Past the escape every derived offset is skewed, and an offset near the end
33
+ // lands beyond the node's size. Dropping the span makes placement decline for
34
+ // that text (the anchor stays panel-only) rather than wrap the wrong
35
+ // characters or build a point Lexical rejects — the same "reject rather than
36
+ // mis-place" trade the rest of this pathway makes.
37
+ if (end - start !== textNode.getTextContentSize())
38
+ return;
39
+ offsetSpanCollector.push({ start, end, nodeKey: textNode.getKey() });
40
+ }
41
+ // Convert mdast tree to Lexical editor state
42
+ export function importMarkdownToLexical(editor, root) {
43
+ editor.update(() => {
44
+ importMarkdownToLexicalInEditorState(root);
45
+ });
46
+ }
47
+ /**
48
+ * Same conversion as {@link importMarkdownToLexicalInEditorState}, but also
49
+ * returns the {@link OffsetSpan}s recovered from the mdast tree's own
50
+ * `position.offset` data — the annotation read pathway's offset decoration.
51
+ * Must run inside an already-active `editor.update()` (mirrors
52
+ * `importMarkdownToLexicalInEditorState`'s own calling convention).
53
+ */
54
+ export function importMarkdownToLexicalInEditorStateWithOffsets(root) {
55
+ const spans = [];
56
+ const previousCollector = offsetSpanCollector;
57
+ offsetSpanCollector = spans;
58
+ try {
59
+ importMarkdownToLexicalInEditorState(root);
60
+ }
61
+ finally {
62
+ offsetSpanCollector = previousCollector;
63
+ }
64
+ spans.sort((a, b) => a.start - b.start);
65
+ return spans;
66
+ }
67
+ /** Same as {@link importMarkdownToLexical}, but also returns the recovered {@link OffsetSpan}s. */
68
+ export function importMarkdownToLexicalWithOffsets(editor, root) {
69
+ let spans = [];
70
+ editor.update(() => {
71
+ spans = importMarkdownToLexicalInEditorStateWithOffsets(root);
72
+ });
73
+ return spans;
74
+ }
75
+ export function importMarkdownToLexicalInEditorState(root) {
76
+ const lexicalRoot = $getRoot();
77
+ lexicalRoot.clear();
78
+ // Pre-process: combine details blocks
79
+ const processedChildren = preprocessDetailsBlocks(root.children);
80
+ // Pre-process: extract footnote definitions for bottom rendering
81
+ const { processed: childrenWithoutFootnotes, definitions } = preprocessFootnoteDefinitions(processedChildren);
82
+ for (const child of childrenWithoutFootnotes) {
83
+ // Check if this is a toggle marker
84
+ if (child.type === 'toggle-marker') {
85
+ const nodes = convertToggleMarker(child);
86
+ for (const node of nodes) {
87
+ lexicalRoot.append(node);
88
+ }
89
+ }
90
+ else {
91
+ const nodes = convertBlockNode(child);
92
+ for (const node of nodes) {
93
+ lexicalRoot.append(node);
94
+ }
95
+ }
96
+ }
97
+ // Append footnote definitions at the end with HR separator
98
+ if (definitions.length > 0) {
99
+ lexicalRoot.append($createHorizontalRuleNode());
100
+ for (const def of definitions) {
101
+ const nodes = convertBlockNode(def);
102
+ for (const node of nodes) {
103
+ lexicalRoot.append(node);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ // Pre-process mdast children to combine details blocks and preserve content nodes
109
+ function preprocessDetailsBlocks(children) {
110
+ const result = [];
111
+ let i = 0;
112
+ while (i < children.length) {
113
+ const node = children[i];
114
+ if (node.type === 'html') {
115
+ const html = node.value.trim();
116
+ // Early exit: skip regex if doesn't start with <details
117
+ if (!html.startsWith('<details') && !html.startsWith('</details')) {
118
+ result.push(node);
119
+ i++;
120
+ continue;
121
+ }
122
+ // Check for opening details tag
123
+ const openingMatch = /<details(?:\s+open)?>\s*<summary>([\s\S]*?)<\/summary>/i.exec(html);
124
+ if (openingMatch) {
125
+ const isOpen = html.toLowerCase().includes('<details open');
126
+ const summary = openingMatch[1].trim();
127
+ const contentNodes = [];
128
+ // Collect content nodes until we find the closing tag
129
+ i++;
130
+ while (i < children.length) {
131
+ const innerNode = children[i];
132
+ if (innerNode.type === 'html' && (/<\/details>/i.exec(innerNode.value.trim()))) {
133
+ // Found closing tag
134
+ break;
135
+ }
136
+ // Keep the actual mdast node
137
+ contentNodes.push(innerNode);
138
+ i++;
139
+ }
140
+ // Create a marker that carries the actual mdast nodes
141
+ result.push({
142
+ type: 'toggle-marker',
143
+ isOpen,
144
+ summary,
145
+ contentNodes,
146
+ });
147
+ i++; // Skip the closing tag
148
+ continue;
149
+ }
150
+ // Check for complete details block in a single HTML node
151
+ const completeMatch = /<details(?:\s+open)?>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/i.exec(html);
152
+ if (completeMatch) {
153
+ const isOpen = html.toLowerCase().includes('<details open');
154
+ const summary = completeMatch[1].trim();
155
+ const contentText = completeMatch[2].trim();
156
+ // Create a marker with a paragraph for the text content
157
+ const contentNodes = [];
158
+ if (contentText) {
159
+ contentNodes.push({
160
+ type: 'paragraph',
161
+ children: [{ type: 'text', value: contentText }],
162
+ });
163
+ }
164
+ result.push({
165
+ type: 'toggle-marker',
166
+ isOpen,
167
+ summary,
168
+ contentNodes,
169
+ });
170
+ i++;
171
+ continue;
172
+ }
173
+ }
174
+ result.push(node);
175
+ i++;
176
+ }
177
+ return result;
178
+ }
179
+ // Pre-process mdast children to extract footnote definitions for bottom-of-document rendering
180
+ function preprocessFootnoteDefinitions(children) {
181
+ const definitions = [];
182
+ function extractFromNode(node) {
183
+ if (node.type === 'footnoteDefinition') {
184
+ definitions.push(node);
185
+ return null; // Remove from tree
186
+ }
187
+ // Recursively process nodes with children (blockquote, list, listItem, etc.)
188
+ if ('children' in node && Array.isArray(node.children)) {
189
+ const typedNode = node;
190
+ const filteredChildren = [];
191
+ let changed = false;
192
+ for (const child of typedNode.children) {
193
+ const result = extractFromNode(child);
194
+ if (result !== null) {
195
+ filteredChildren.push(result);
196
+ if (result !== child)
197
+ changed = true;
198
+ }
199
+ else {
200
+ changed = true;
201
+ }
202
+ }
203
+ // Only create a new node if children actually changed
204
+ if (!changed)
205
+ return node;
206
+ return { ...typedNode, children: filteredChildren };
207
+ }
208
+ return node;
209
+ }
210
+ const processed = [];
211
+ for (const child of children) {
212
+ // Handle toggle markers separately - they have their own contentNodes to process
213
+ if (child.type === 'toggle-marker') {
214
+ const marker = child;
215
+ const filteredContentNodes = [];
216
+ for (const contentNode of marker.contentNodes) {
217
+ const result = extractFromNode(contentNode);
218
+ if (result !== null) {
219
+ filteredContentNodes.push(result);
220
+ }
221
+ }
222
+ processed.push({ ...marker, contentNodes: filteredContentNodes });
223
+ }
224
+ else {
225
+ const result = extractFromNode(child);
226
+ if (result !== null) {
227
+ processed.push(result);
228
+ }
229
+ }
230
+ }
231
+ return { processed, definitions };
232
+ }
233
+ function convertBlockNode(node) {
234
+ const nodeType = node.type;
235
+ switch (nodeType) {
236
+ case 'paragraph':
237
+ return convertParagraph(node);
238
+ case 'heading':
239
+ return [convertHeading(node)];
240
+ case 'blockquote':
241
+ return convertBlockquote(node);
242
+ case 'list':
243
+ return [convertList(node)];
244
+ case 'code':
245
+ return [convertCode(node)];
246
+ case 'thematicBreak':
247
+ return [convertThematicBreak()];
248
+ case 'table':
249
+ return [convertTable(node)];
250
+ case 'html':
251
+ return convertHtml(node);
252
+ case 'math':
253
+ // Block math from mdast-util-math: $$...$$
254
+ return [$createEquationNode(node.value, false)];
255
+ case 'inlineMath':
256
+ // Inline math from mdast-util-math: $...$
257
+ // This shouldn't appear at block level, but handle it gracefully
258
+ return [$createEquationNode(node.value, true)];
259
+ case 'yaml': {
260
+ // YAML frontmatter from mdast-util-frontmatter
261
+ const frontmatterNode = $createFrontmatterNode();
262
+ const value = node.value;
263
+ if (value) {
264
+ frontmatterNode.append($createTextNode(value));
265
+ }
266
+ return [frontmatterNode];
267
+ }
268
+ case 'wikiLink': {
269
+ // Wiki-links appearing at block level (shouldn't happen, but handle gracefully)
270
+ // Wrap in a paragraph with a link
271
+ const wikiLink = node;
272
+ const target = wikiLink.value || '';
273
+ const rawDisplayText = wikiLink.data?.alias || target;
274
+ const url = getFileType(target) !== 'unknown' ? target : `${target}.md`;
275
+ const paragraph = $createParagraphNode();
276
+ const link = $createCustomLinkNode(url);
277
+ link.setWikiLinkOrigin(true);
278
+ // Parse format markers in alias (e.g., **bold**, *italic*, ~~strike~~)
279
+ const { text: plainText, formats } = parseFormattedAlias(rawDisplayText);
280
+ const textNode = $createTextNode(plainText);
281
+ for (const format of formats) {
282
+ textNode.toggleFormat(format);
283
+ }
284
+ link.append(textNode);
285
+ paragraph.append(link);
286
+ return [paragraph];
287
+ }
288
+ case 'footnoteDefinition': {
289
+ // Footnote definition: render as indented paragraphs with superscript label.
290
+ // On export, lexicalToMdast detects this pattern (indent=1, starts with FootnoteNode)
291
+ // and reconstructs the footnoteDefinition MDAST node for round-trip preservation.
292
+ const fnDef = node;
293
+ const results = [];
294
+ let labelAdded = false;
295
+ for (const child of fnDef.children) {
296
+ const nodes = convertBlockNode(child);
297
+ for (const n of nodes) {
298
+ // Prepend footnote label to the first paragraph only
299
+ if (!labelAdded && n.getType() === 'paragraph' && 'getFirstChild' in n) {
300
+ const label = $createFootnoteNode(fnDef.identifier);
301
+ const space = $createTextNode(' ');
302
+ const firstChild = n.getFirstChild();
303
+ if (firstChild) {
304
+ firstChild.insertBefore(space);
305
+ space.insertBefore(label);
306
+ }
307
+ else {
308
+ n.append(label);
309
+ n.append(space);
310
+ }
311
+ labelAdded = true;
312
+ }
313
+ if ('setIndent' in n && typeof n.setIndent === 'function') {
314
+ n.setIndent(1);
315
+ }
316
+ }
317
+ results.push(...nodes);
318
+ }
319
+ return results;
320
+ }
321
+ case 'defList': {
322
+ // Definition list: build a dedicated DefinitionListNode so the term/description
323
+ // structure survives round-tripping (see lexicalToMdast's convertDefinitionListNode).
324
+ const defListNode = node;
325
+ const container = $createDefinitionListNode();
326
+ for (const child of defListNode.children) {
327
+ if (child.type === 'defListTerm') {
328
+ const term = $createDefinitionTermNode();
329
+ for (const inlineChild of child.children) {
330
+ for (const n of convertInlineNode(inlineChild)) {
331
+ term.append(n);
332
+ }
333
+ }
334
+ container.append(term);
335
+ }
336
+ else if (child.type === 'defListDescription') {
337
+ const description = $createDefinitionDescriptionNode();
338
+ for (const contentChild of child.children) {
339
+ for (const n of convertBlockNode(contentChild)) {
340
+ description.append(n);
341
+ }
342
+ }
343
+ container.append(description);
344
+ }
345
+ }
346
+ return [container];
347
+ }
348
+ default:
349
+ // For unknown nodes, log a warning and create an empty paragraph
350
+ // Do NOT use String(node) as it produces "[object Object]"
351
+ console.warn('[mdastToLexical] Unknown block node type:', node.type, node);
352
+ const paragraph = $createParagraphNode();
353
+ // Try to extract any text content from the node
354
+ const textContent = node.value || '';
355
+ if (textContent) {
356
+ paragraph.append($createTextNode(textContent));
357
+ }
358
+ return [paragraph];
359
+ }
360
+ }
361
+ function convertParagraph(node) {
362
+ // Check if this is just an image - return ImageNode directly
363
+ if (node.children.length === 1 &&
364
+ node.children[0].type === 'image') {
365
+ const img = node.children[0];
366
+ return [$createImageNode(img.url, img.alt || '', { title: img.title ?? undefined })];
367
+ }
368
+ // Check if paragraph contains any images mixed with text
369
+ const hasImages = node.children.some((child) => child.type === 'image');
370
+ if (!hasImages) {
371
+ // Simple case: no images, just create a paragraph
372
+ const paragraph = $createParagraphNode();
373
+ for (const child of node.children) {
374
+ const nodes = convertInlineNode(child);
375
+ for (const n of nodes) {
376
+ paragraph.append(n);
377
+ }
378
+ }
379
+ return [paragraph];
380
+ }
381
+ // Complex case: mixed text and images
382
+ // Split into multiple blocks: paragraphs for text, ImageNodes for images
383
+ const result = [];
384
+ let currentParagraph = null;
385
+ for (const child of node.children) {
386
+ if (child.type === 'image') {
387
+ // Flush current paragraph if it has content
388
+ if (currentParagraph && currentParagraph.getTextContent().length > 0) {
389
+ result.push(currentParagraph);
390
+ currentParagraph = null;
391
+ }
392
+ // Add the image as its own block
393
+ const img = child;
394
+ result.push($createImageNode(img.url, img.alt || '', { title: img.title ?? undefined }));
395
+ }
396
+ else {
397
+ // Text or other inline content
398
+ if (!currentParagraph) {
399
+ currentParagraph = $createParagraphNode();
400
+ }
401
+ const nodes = convertInlineNode(child);
402
+ for (const n of nodes) {
403
+ currentParagraph.append(n);
404
+ }
405
+ }
406
+ }
407
+ // Flush any remaining paragraph
408
+ if (currentParagraph && currentParagraph.getTextContent().length > 0) {
409
+ result.push(currentParagraph);
410
+ }
411
+ // If nothing was added (shouldn't happen), return empty paragraph
412
+ if (result.length === 0) {
413
+ return [$createParagraphNode()];
414
+ }
415
+ return result;
416
+ }
417
+ function convertHeading(node) {
418
+ const tag = `h${node.depth}`;
419
+ const heading = $createHeadingNode(tag);
420
+ for (const child of node.children) {
421
+ const nodes = convertInlineNode(child);
422
+ for (const n of nodes) {
423
+ heading.append(n);
424
+ }
425
+ }
426
+ return heading;
427
+ }
428
+ function convertBlockquote(node) {
429
+ // Check if this is a callout (admonition)
430
+ if (node.children.length > 0) {
431
+ const firstChild = node.children[0];
432
+ if (firstChild.type === 'paragraph' && firstChild.children.length > 0) {
433
+ const firstText = firstChild.children[0];
434
+ if (firstText.type === 'text') {
435
+ const calloutMatch = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]/i.exec(firstText.value);
436
+ if (calloutMatch) {
437
+ const calloutType = calloutMatch[1].toLowerCase();
438
+ const restOfText = firstText.value.slice(calloutMatch[0].length).trimStart();
439
+ // Create callout node without initial content
440
+ const callout = $createCalloutNode(calloutType);
441
+ // Create a paragraph for the first line's remaining text and rest of first paragraph
442
+ const firstParagraph = $createParagraphNode();
443
+ if (restOfText) {
444
+ firstParagraph.append($createTextNode(restOfText));
445
+ }
446
+ // Add remaining inline content from the first paragraph
447
+ for (let j = 1; j < firstChild.children.length; j++) {
448
+ const inlineNodes = convertInlineNode(firstChild.children[j]);
449
+ for (const n of inlineNodes) {
450
+ firstParagraph.append(n);
451
+ }
452
+ }
453
+ // Only add the paragraph if it has content
454
+ if (firstParagraph.getTextContent() || firstChild.children.length > 1) {
455
+ callout.append(firstParagraph);
456
+ }
457
+ // Convert remaining children (additional paragraphs, lists, etc.)
458
+ for (let i = 1; i < node.children.length; i++) {
459
+ const child = node.children[i];
460
+ if (child.type === 'paragraph') {
461
+ const p = $createParagraphNode();
462
+ for (const inlineChild of child.children) {
463
+ const nodes = convertInlineNode(inlineChild);
464
+ for (const n of nodes) {
465
+ p.append(n);
466
+ }
467
+ }
468
+ callout.append(p);
469
+ }
470
+ }
471
+ // Ensure callout has at least one paragraph
472
+ if (callout.getChildrenSize() === 0) {
473
+ callout.append($createParagraphNode());
474
+ }
475
+ return [callout];
476
+ }
477
+ }
478
+ }
479
+ }
480
+ // Regular blockquote — Lexical's QuoteNode is flat (can't nest quotes),
481
+ // so we return the outer quote with its direct paragraph content,
482
+ // then recursively convert nested blockquotes as separate quote nodes.
483
+ const quote = $createQuoteNode();
484
+ const results = [];
485
+ for (const child of node.children) {
486
+ if (child.type === 'paragraph') {
487
+ // Wrap every paragraph child in a real ParagraphNode (rather than
488
+ // flattening its inline content directly onto the quote) so that
489
+ // consecutive paragraphs stay separated on export. Unlike ListItemNode,
490
+ // QuoteNode.append() doesn't unwrap/merge appended ParagraphNodes, so
491
+ // this survives structurally intact — see #18.
492
+ const p = $createParagraphNode();
493
+ for (const inlineChild of child.children) {
494
+ const nodes = convertInlineNode(inlineChild);
495
+ for (const n of nodes) {
496
+ p.append(n);
497
+ }
498
+ }
499
+ quote.append(p);
500
+ }
501
+ else if (child.type === 'blockquote') {
502
+ // Nested blockquote: convert recursively.
503
+ // Flush the current quote first, then add nested results.
504
+ if (quote.getChildrenSize() > 0 && results.length === 0) {
505
+ results.push(quote);
506
+ }
507
+ const nestedResults = convertBlockquote(child);
508
+ // Indent nested quotes to show depth
509
+ for (const n of nestedResults) {
510
+ if ('setIndent' in n && typeof n.setIndent === 'function') {
511
+ n.setIndent(n.getIndent() + 1);
512
+ }
513
+ }
514
+ results.push(...nestedResults);
515
+ }
516
+ else {
517
+ // Other block content inside blockquote (lists, code, etc.)
518
+ const blockNodes = convertBlockNode(child);
519
+ for (const n of blockNodes) {
520
+ quote.append(n);
521
+ }
522
+ }
523
+ }
524
+ // If we haven't pushed the quote yet (no nested blockquotes), push it now
525
+ if (results.length === 0) {
526
+ results.push(quote);
527
+ }
528
+ else if (quote.getChildrenSize() > 0 && !results.includes(quote)) {
529
+ results.unshift(quote);
530
+ }
531
+ return results;
532
+ }
533
+ function convertList(node) {
534
+ const hasCheckedItems = node.children.some((item) => item.checked !== null);
535
+ const listType = node.ordered ? 'number' : hasCheckedItems ? 'check' : 'bullet';
536
+ const list = $createCustomListNode(listType);
537
+ list.setSpread(node.spread === true);
538
+ for (const item of node.children) {
539
+ const listItem = convertListItem(item, node);
540
+ list.append(listItem);
541
+ }
542
+ return list;
543
+ }
544
+ function convertListItem(node, parentList) {
545
+ // Support checkboxes in both ordered and unordered lists.
546
+ // For unordered lists, we use Lexical's checklist rendering (listType = 'check').
547
+ // For ordered lists, Lexical's stock rendering can't be checklist-typed (it
548
+ // would lose numbering), so CustomListItemNode.createDOM/updateDOM renders
549
+ // the checkbox itself, driven by __taskChecked rather than useChecked/listType.
550
+ const useChecked = parentList.ordered ? undefined : node.checked !== null ? node.checked : undefined;
551
+ const listItem = $createCustomListItemNode(useChecked);
552
+ // mdast's own tri-state `checked` is the true per-item source of truth,
553
+ // independent of useChecked (which is deliberately always undefined for
554
+ // ordered items) and independent of the parent list's listType (which
555
+ // Lexical's stock getChecked() incorrectly gates on) — see
556
+ // CustomListItemNode's docstring for why this must not be derived from
557
+ // Lexical's own checkbox state.
558
+ listItem.setTaskChecked(node.checked ?? null);
559
+ for (let i = 0; i < node.children.length; i++) {
560
+ const child = node.children[i];
561
+ if (child.type === 'paragraph') {
562
+ // @lexical/list's ListItemNode.append() unwraps a directly-appended
563
+ // ParagraphNode, merging its inline content flat onto the item — so a
564
+ // lone paragraph already flattens correctly with no extra work needed.
565
+ // But two consecutive paragraphs with nothing else between them would
566
+ // then merge into one run of text with no separator. Mark that boundary
567
+ // with a single ListItemParagraphBreakNode (a dedicated node type,
568
+ // unambiguous with LineBreakNode by construction — see #902) before
569
+ // flattening this paragraph's content, and detect it again on export to
570
+ // split back into two paragraphs.
571
+ if (node.children[i - 1]?.type === 'paragraph') {
572
+ listItem.append($createListItemParagraphBreakNode());
573
+ }
574
+ for (const inlineChild of child.children) {
575
+ const nodes = convertInlineNode(inlineChild);
576
+ for (const n of nodes) {
577
+ listItem.append(n);
578
+ }
579
+ }
580
+ }
581
+ else if (child.type === 'list') {
582
+ // Nested list
583
+ const nestedList = convertList(child);
584
+ listItem.append(nestedList);
585
+ }
586
+ else {
587
+ // Other block content inside list items (blockquotes, code, tables, etc.)
588
+ const blockNodes = convertBlockNode(child);
589
+ for (const n of blockNodes) {
590
+ listItem.append(n);
591
+ }
592
+ }
593
+ }
594
+ return listItem;
595
+ }
596
+ function convertCode(node) {
597
+ // Check if this is a mermaid diagram
598
+ if (node.lang === 'mermaid') {
599
+ return $createMermaidNode(node.value);
600
+ }
601
+ // Check if this is a C4 architecture diagram
602
+ if (node.lang === 'c4' || (node.lang === 'plantuml' && isC4PlantUML(node.value))) {
603
+ const manualLayout = extractC4LayoutFromMeta(node.meta);
604
+ return $createC4Node(node.value, manualLayout);
605
+ }
606
+ // If no language was specified, use 'plain' to avoid Lexical defaulting to 'javascript'
607
+ const language = node.lang ? node.lang : 'plain';
608
+ const code = $createCodeNode(language);
609
+ code.append($createTextNode(node.value));
610
+ return code;
611
+ }
612
+ /** Detect C4-PlantUML content inside a plantuml code block */
613
+ const C4_MACRO_PATTERN = /\b(?:Person|System|Container|Component|Boundary|Rel|BiRel)(?:_Ext|Db|Queue|_Boundary)?\s*[(_]/;
614
+ function isC4PlantUML(code) {
615
+ return C4_MACRO_PATTERN.test(code);
616
+ }
617
+ /** Extract and validate manual layout data from a C4 code fence's meta string */
618
+ function extractC4LayoutFromMeta(meta) {
619
+ if (!meta?.startsWith('@layout '))
620
+ return undefined;
621
+ try {
622
+ const raw = JSON.parse(meta.slice('@layout '.length));
623
+ if (!raw || typeof raw !== 'object')
624
+ return undefined;
625
+ const positions = raw.positions;
626
+ if (!positions || typeof positions !== 'object')
627
+ return undefined;
628
+ for (const key of Object.keys(positions)) {
629
+ const pos = positions[key];
630
+ if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number' ||
631
+ !Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
632
+ return undefined;
633
+ }
634
+ }
635
+ return raw;
636
+ }
637
+ catch {
638
+ return undefined;
639
+ }
640
+ }
641
+ function convertThematicBreak() {
642
+ return $createHorizontalRuleNode();
643
+ }
644
+ function convertTable(node) {
645
+ const table = $createTableNode();
646
+ for (let i = 0; i < node.children.length; i++) {
647
+ const row = node.children[i];
648
+ const isHeader = i === 0;
649
+ const tableRow = convertTableRow(row, isHeader, node.align);
650
+ table.append(tableRow);
651
+ }
652
+ return table;
653
+ }
654
+ function convertTableRow(node, isHeader, alignments) {
655
+ const row = $createTableRowNode();
656
+ for (let i = 0; i < node.children.length; i++) {
657
+ const cell = node.children[i];
658
+ const align = alignments?.[i] || null;
659
+ const tableCell = convertTableCell(cell, isHeader, align);
660
+ row.append(tableCell);
661
+ }
662
+ return row;
663
+ }
664
+ function convertTableCell(node, isHeader, align) {
665
+ const cell = $createTableCellNode(isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS);
666
+ cell.setFormat(align ?? '');
667
+ const paragraph = $createParagraphNode();
668
+ for (const child of node.children) {
669
+ const nodes = convertInlineNode(child);
670
+ for (const n of nodes) {
671
+ paragraph.append(n);
672
+ }
673
+ }
674
+ cell.append(paragraph);
675
+ return cell;
676
+ }
677
+ /**
678
+ * Convert a block-level mdast `html` node. The raw markup is preserved
679
+ * opaquely via HtmlNode for round-trip fidelity (see issue #909) — it is
680
+ * never sanitized or interpreted, only ever displayed as inert text
681
+ * (HtmlNode never assigns to innerHTML).
682
+ *
683
+ * The one exception is a dimensioned `<img>` tag, which is reconstructed as
684
+ * a real, resizable ImageNode — this is a matched, load-bearing pair with
685
+ * convertImageNode's html-reconstruction on export and must not be broken.
686
+ */
687
+ function convertHtml(node) {
688
+ const html = node.value;
689
+ const trimmed = html.trim();
690
+ // Check for block equation: $$...$$
691
+ const blockEquationMatch = /^\$\$([^$]+)\$\$$/.exec(trimmed);
692
+ if (blockEquationMatch) {
693
+ return [$createEquationNode(blockEquationMatch[1].trim(), false)];
694
+ }
695
+ // Check for inline equation: $...$
696
+ const inlineEquationMatch = /^\$([^$]+)\$$/.exec(trimmed);
697
+ if (inlineEquationMatch) {
698
+ return [$createEquationNode(inlineEquationMatch[1].trim(), true)];
699
+ }
700
+ // Toggle/details blocks are handled by preprocessDetailsBlocks
701
+ // This function only handles remaining HTML
702
+ // Check for a standalone dimensioned <img> tag — reconstructed as a real
703
+ // ImageNode, matching convertImageNode's html-reconstruction on export.
704
+ // Only fires when the <img> is the block's sole content: an <img> nested
705
+ // inside other markup (e.g. a <div> wrapper), or accompanied by a comment
706
+ // or other non-whitespace node, must stay part of the opaque HtmlNode
707
+ // below, not be hoisted out on its own. Checked against childNodes (not
708
+ // just element children) so a sibling <!-- comment --> isn't silently
709
+ // dropped by only counting elements.
710
+ const parser = new DOMParser();
711
+ const doc = parser.parseFromString(trimmed, 'text/html');
712
+ const significantChildren = Array.from(doc.body.childNodes).filter((n) => !(n.nodeType === Node.TEXT_NODE && !n.textContent?.trim()));
713
+ const isStandaloneImg = significantChildren.length === 1 &&
714
+ significantChildren[0].nodeType === Node.ELEMENT_NODE &&
715
+ significantChildren[0].tagName === 'IMG';
716
+ const imgElement = isStandaloneImg ? significantChildren[0] : null;
717
+ if (imgElement) {
718
+ const src = imgElement.getAttribute('src');
719
+ if (src) {
720
+ const imageNode = $createImageNode(src, imgElement.getAttribute('alt') || '', { title: imgElement.getAttribute('title') || undefined });
721
+ const width = imgElement.getAttribute('width');
722
+ const height = imgElement.getAttribute('height');
723
+ if (width) {
724
+ const widthNum = parseInt(width, 10);
725
+ if (!isNaN(widthNum) && widthNum > 0) {
726
+ imageNode.setWidth(widthNum);
727
+ }
728
+ }
729
+ if (height) {
730
+ const heightNum = parseInt(height, 10);
731
+ if (!isNaN(heightNum) && heightNum > 0) {
732
+ imageNode.setHeight(heightNum);
733
+ }
734
+ }
735
+ return [imageNode];
736
+ }
737
+ }
738
+ // Everything else — the untrimmed original value is preserved verbatim,
739
+ // opaquely, for exact byte round-trip regardless of incidental whitespace.
740
+ return [$createHtmlNode(html, false)];
741
+ }
742
+ // Convert a toggle marker (from preprocessDetailsBlocks) to Lexical nodes
743
+ function convertToggleMarker(marker) {
744
+ const container = $createToggleContainerNode(marker.isOpen);
745
+ // Create title node with the summary text
746
+ const title = $createToggleTitleNode();
747
+ const titleParagraph = $createParagraphNode();
748
+ if (marker.summary) {
749
+ titleParagraph.append($createTextNode(marker.summary));
750
+ }
751
+ title.append(titleParagraph);
752
+ container.append(title);
753
+ // Create content node with all the content blocks
754
+ const content = $createToggleContentNode();
755
+ if (marker.contentNodes.length === 0) {
756
+ // Add an empty paragraph if no content
757
+ content.append($createParagraphNode());
758
+ }
759
+ else {
760
+ // Convert each content node to Lexical nodes
761
+ for (const contentNode of marker.contentNodes) {
762
+ const lexicalNodes = convertBlockNode(contentNode);
763
+ for (const node of lexicalNodes) {
764
+ content.append(node);
765
+ }
766
+ }
767
+ }
768
+ container.append(content);
769
+ return [container];
770
+ }
771
+ function convertInlineNode(node) {
772
+ // Defensive: handle null/undefined nodes
773
+ if (!node?.type) {
774
+ console.warn('[mdastToLexical] convertInlineNode received invalid node:', node);
775
+ return [$createTextNode('')];
776
+ }
777
+ const nodeType = node.type;
778
+ switch (nodeType) {
779
+ case 'text':
780
+ return [convertText(node)];
781
+ case 'strong':
782
+ return convertStrong(node);
783
+ case 'emphasis':
784
+ return convertEmphasis(node);
785
+ case 'inlineCode':
786
+ return [convertInlineCode(node)];
787
+ case 'link':
788
+ return [convertLink(node)];
789
+ case 'delete':
790
+ return convertDelete(node);
791
+ case 'break':
792
+ // Soft line break from trailing double-spaces in markdown
793
+ return [$createLineBreakNode()];
794
+ case 'image': {
795
+ const imageNode = node;
796
+ // Images nested inline (e.g. the "badge" pattern: an image inside a link)
797
+ // become a real ImageNode child, preserving the image through the round trip.
798
+ return [$createImageNode(imageNode.url, imageNode.alt || '', { title: imageNode.title ?? undefined })];
799
+ }
800
+ case 'inlineMath':
801
+ // Inline math from mdast-util-math: $...$
802
+ return [$createEquationNode(node.value, true)];
803
+ case 'footnoteReference': {
804
+ // Footnote reference: use FootnoteNode to preserve identity for round-trip
805
+ const fnRef = node;
806
+ return [$createFootnoteNode(fnRef.identifier)];
807
+ }
808
+ case 'wikiLink': {
809
+ // Wiki-links from mdast-util-wiki-link: [[path|alias]]
810
+ const wikiLink = node;
811
+ const target = wikiLink.value;
812
+ // Defensive: handle undefined/null target
813
+ if (!target) {
814
+ console.warn('[mdastToLexical] wikiLink with undefined target:', wikiLink);
815
+ return [$createTextNode('[[]]')];
816
+ }
817
+ const displayText = wikiLink.data?.alias || target;
818
+ // Convert to URL, preserving semantic intent
819
+ // Directory links (trailing slash) are preserved - navigation layer resolves them
820
+ // Example: [[entities/people/]] → stays as entities/people/ (resolved to index.md at navigation time)
821
+ let url;
822
+ if (target.startsWith('#')) {
823
+ // Anchor-only: #anchor → #anchor
824
+ url = target;
825
+ }
826
+ else if (target.endsWith('/')) {
827
+ // Directory link: preserve trailing slash for semantic clarity
828
+ // Navigation layer will resolve to index.md or README.md
829
+ url = target;
830
+ }
831
+ else if (target.includes('#')) {
832
+ // Path with anchor: page#anchor → page.md#anchor (or dir/#anchor preserved)
833
+ const [path, anchor] = target.split('#', 2);
834
+ if (path.endsWith('/')) {
835
+ // Directory with anchor - preserve for navigation layer
836
+ url = target;
837
+ }
838
+ else {
839
+ const pathWithExt = getFileType(path) !== 'unknown' ? path : `${path}.md`;
840
+ url = `${pathWithExt}#${anchor}`;
841
+ }
842
+ }
843
+ else {
844
+ // Simple path: page → page.md (unless it already has a recognized extension)
845
+ url = getFileType(target) !== 'unknown' ? target : `${target}.md`;
846
+ }
847
+ const link = $createCustomLinkNode(url);
848
+ // Marks this link as genuine author-written wiki-link syntax, so export
849
+ // (convertLinkNode) always emits it back as a wiki-link even when a host
850
+ // has disabled promotion of ordinary links (liminis#951).
851
+ link.setWikiLinkOrigin(true);
852
+ // Preserve empty-alias state for round-trip
853
+ if (wikiLink.data?._emptyAlias) {
854
+ link.setWikiAliasState('empty');
855
+ }
856
+ // Parse format markers in alias (e.g., **bold**, *italic*, ~~strike~~)
857
+ const { text: plainText, formats } = parseFormattedAlias(displayText);
858
+ const textNode = $createTextNode(plainText);
859
+ for (const format of formats) {
860
+ textNode.toggleFormat(format);
861
+ }
862
+ link.append(textNode);
863
+ return [link];
864
+ }
865
+ case 'html': {
866
+ // Check for inline equation: $...$
867
+ const html = node.value;
868
+ const inlineEquationMatch = /^\$([^$]+)\$$/.exec(html);
869
+ if (inlineEquationMatch) {
870
+ return [$createEquationNode(inlineEquationMatch[1].trim(), true)];
871
+ }
872
+ // Check for block equation: $$...$$ (shouldn't be inline, but handle gracefully)
873
+ const blockEquationMatch = /^\$\$([^$]+)\$\$$/.exec(html);
874
+ if (blockEquationMatch) {
875
+ return [$createEquationNode(blockEquationMatch[1].trim(), false)];
876
+ }
877
+ // Other inline HTML is preserved opaquely as live markup, not escaped text
878
+ return [$createHtmlNode(html, true)];
879
+ }
880
+ default:
881
+ return [$createTextNode('')];
882
+ }
883
+ }
884
+ function convertText(node) {
885
+ const textNode = $createTextNode(node.value);
886
+ recordOffsetSpan(node.position, textNode);
887
+ if (node.data?._forceEscape === true) {
888
+ setForceEscape(textNode);
889
+ }
890
+ return textNode;
891
+ }
892
+ /**
893
+ * Apply a text format to all text children of a LinkNode.
894
+ * This allows formats like bold/italic to propagate through wiki links.
895
+ */
896
+ function applyFormatToLinkChildren(link, format, marker) {
897
+ for (const child of link.getChildren()) {
898
+ if (child instanceof TextNode) {
899
+ if (!child.hasFormat(format)) {
900
+ child.toggleFormat(format);
901
+ }
902
+ // Set marker style for round-trip fidelity
903
+ if (marker && (format === 'bold' || format === 'italic')) {
904
+ const kind = format === 'bold' ? 'strong' : 'emphasis';
905
+ setMarkdownMarker(child, kind, marker);
906
+ }
907
+ }
908
+ }
909
+ }
910
+ function convertStrong(node) {
911
+ const nodes = [];
912
+ const marker = node.data?._strongMarker;
913
+ for (const child of node.children) {
914
+ const converted = convertInlineNode(child);
915
+ for (const n of converted) {
916
+ if (n instanceof TextNode) {
917
+ if (!n.hasFormat('bold')) {
918
+ n.toggleFormat('bold');
919
+ }
920
+ setMarkdownMarker(n, 'strong', marker);
921
+ nodes.push(n);
922
+ }
923
+ else if ($isLinkNode(n)) {
924
+ // Apply bold formatting to wiki link's text children
925
+ applyFormatToLinkChildren(n, 'bold', marker);
926
+ nodes.push(n);
927
+ }
928
+ else if ($isEquationNode(n) || $isFootnoteNode(n)) {
929
+ if (!n.hasFormat('bold')) {
930
+ n.toggleFormat('bold');
931
+ }
932
+ if (marker === '_' || marker === '*') {
933
+ n.setStrongMarker(marker);
934
+ }
935
+ nodes.push(n);
936
+ }
937
+ else if ($isImageNode(n) || $isHtmlNode(n) || $isLineBreakNode(n)) {
938
+ // These have no bold/italic representation — pass through unformatted
939
+ nodes.push(n);
940
+ }
941
+ }
942
+ }
943
+ return nodes;
944
+ }
945
+ function convertEmphasis(node) {
946
+ const nodes = [];
947
+ const marker = node.data?._emphasisMarker;
948
+ for (const child of node.children) {
949
+ const converted = convertInlineNode(child);
950
+ for (const n of converted) {
951
+ if (n instanceof TextNode) {
952
+ if (!n.hasFormat('italic')) {
953
+ n.toggleFormat('italic');
954
+ }
955
+ setMarkdownMarker(n, 'emphasis', marker);
956
+ nodes.push(n);
957
+ }
958
+ else if ($isLinkNode(n)) {
959
+ // Apply italic formatting to wiki link's text children
960
+ applyFormatToLinkChildren(n, 'italic', marker);
961
+ nodes.push(n);
962
+ }
963
+ else if ($isEquationNode(n) || $isFootnoteNode(n)) {
964
+ if (!n.hasFormat('italic')) {
965
+ n.toggleFormat('italic');
966
+ }
967
+ if (marker === '_' || marker === '*') {
968
+ n.setEmphasisMarker(marker);
969
+ }
970
+ nodes.push(n);
971
+ }
972
+ else if ($isImageNode(n) || $isHtmlNode(n) || $isLineBreakNode(n)) {
973
+ // These have no bold/italic representation — pass through unformatted
974
+ nodes.push(n);
975
+ }
976
+ }
977
+ }
978
+ return nodes;
979
+ }
980
+ function setMarkdownMarker(node, kind, marker) {
981
+ if (marker !== '_' && marker !== '*') {
982
+ return;
983
+ }
984
+ const prop = kind === 'emphasis' ? '--md-emphasis-marker' : '--md-strong-marker';
985
+ const style = node.getStyle() || '';
986
+ const pattern = new RegExp(`${prop}\\s*:\\s*[_*]\\s*;?`);
987
+ const entry = `${prop}:${marker};`;
988
+ const nextStyle = pattern.test(style)
989
+ ? style.replace(pattern, entry)
990
+ : style.length > 0 && !style.trim().endsWith(';')
991
+ ? `${style};${entry}`
992
+ : `${style}${entry}`;
993
+ node.setStyle(nextStyle);
994
+ }
995
+ // Marks a single-character TextNode as carrying a source-level backslash
996
+ // escape that must survive stringification, mirroring the
997
+ // `--md-emphasis-marker`/`--md-strong-marker` style-hint precedent above
998
+ // (see `setMarkdownMarker`) rather than embedding anything in the editable
999
+ // text content itself (#17).
1000
+ function setForceEscape(node) {
1001
+ const prop = '--md-force-escape';
1002
+ const style = node.getStyle() || '';
1003
+ const entry = `${prop}:1;`;
1004
+ if (new RegExp(`${prop}\\s*:\\s*1\\s*;?`).test(style)) {
1005
+ return;
1006
+ }
1007
+ const nextStyle = style.length > 0 && !style.trim().endsWith(';') ? `${style};${entry}` : `${style}${entry}`;
1008
+ node.setStyle(nextStyle);
1009
+ }
1010
+ function convertInlineCode(node) {
1011
+ const textNode = $createTextNode(node.value);
1012
+ textNode.setFormat('code');
1013
+ recordOffsetSpan(inlineCodeContentPosition(node), textNode);
1014
+ return textNode;
1015
+ }
1016
+ /**
1017
+ * mdast's own `position` for an `inlineCode` node spans the whole code span
1018
+ * *including* its backtick fence (e.g. `` `doThing()` ``), but `node.value`
1019
+ * (and the `TextNode` built from it) never includes the backticks — so the
1020
+ * raw offsets need to be narrowed inward by the fence length before they're
1021
+ * usable as an offset->node span. Fences are symmetric (same backtick count
1022
+ * on both sides, CommonMark's own rule), so the fence length is recovered
1023
+ * from the gap between the position's full width and the content's length.
1024
+ */
1025
+ function inlineCodeContentPosition(node) {
1026
+ const start = node.position?.start?.offset;
1027
+ const end = node.position?.end?.offset;
1028
+ if (start == null || end == null)
1029
+ return node.position;
1030
+ const fenceLength = Math.floor((end - start - node.value.length) / 2);
1031
+ if (fenceLength <= 0)
1032
+ return node.position;
1033
+ return {
1034
+ start: { ...node.position.start, offset: start + fenceLength },
1035
+ end: { ...node.position.end, offset: end - fenceLength },
1036
+ };
1037
+ }
1038
+ function convertLink(node) {
1039
+ const link = $createCustomLinkNode(node.url, { title: node.title ?? undefined });
1040
+ for (const child of node.children) {
1041
+ const nodes = convertInlineNode(child);
1042
+ for (const n of nodes) {
1043
+ link.append(n);
1044
+ }
1045
+ }
1046
+ return link;
1047
+ }
1048
+ function convertDelete(node) {
1049
+ const nodes = [];
1050
+ for (const child of node.children) {
1051
+ const converted = convertInlineNode(child);
1052
+ for (const n of converted) {
1053
+ if (n instanceof TextNode) {
1054
+ n.setFormat('strikethrough');
1055
+ nodes.push(n);
1056
+ }
1057
+ else if ($isLinkNode(n)) {
1058
+ // Apply strikethrough formatting to wiki link's text children
1059
+ applyFormatToLinkChildren(n, 'strikethrough');
1060
+ nodes.push(n);
1061
+ }
1062
+ else if ($isEquationNode(n) || $isFootnoteNode(n)) {
1063
+ if (!n.hasFormat('strikethrough')) {
1064
+ n.toggleFormat('strikethrough');
1065
+ }
1066
+ nodes.push(n);
1067
+ }
1068
+ else if ($isImageNode(n) || $isHtmlNode(n) || $isLineBreakNode(n)) {
1069
+ // These have no strikethrough representation — pass through unformatted
1070
+ nodes.push(n);
1071
+ }
1072
+ }
1073
+ }
1074
+ return nodes;
1075
+ }