@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,1937 @@
1
+ import { $getRoot, $isTextNode, $isElementNode, $isParagraphNode, $isLineBreakNode, } from 'lexical';
2
+ import { $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
3
+ import { $isListNode, $isListItemNode } from '@lexical/list';
4
+ import { $isCodeNode } from '@lexical/code';
5
+ import { $isLinkNode } from '@lexical/link';
6
+ import { $isMarkNode } from '@lexical/mark';
7
+ import { $isTableNode, $isTableRowNode, $isTableCellNode } from '@lexical/table';
8
+ import { $isHorizontalRuleNode, $isImageNode, $isCalloutNode, $isToggleContainerNode, $isToggleTitleNode, $isToggleContentNode, $isEquationNode, $isMermaidNode, $isC4Node, $isFrontmatterNode, $isFootnoteNode, $isCustomListNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, $isCustomListItemNode, $isHtmlNode, $isListItemParagraphBreakNode, } from '../editor/nodes/index.js';
9
+ import { SENTINEL_CLOSE_END, SENTINEL_CLOSE_START, SENTINEL_OPEN_END, SENTINEL_OPEN_START, stripAnnotateSentinels, } from '../../markdown/annotate-sentinels.js';
10
+ // Convert Lexical editor state to mdast tree
11
+ export function exportLexicalToMdast(editor, options = {}) {
12
+ let root = { type: 'root', children: [] };
13
+ editor.getEditorState().read(() => {
14
+ root = exportLexicalToMdastInEditorState(options);
15
+ });
16
+ return root;
17
+ }
18
+ /**
19
+ * Same conversion as {@link exportLexicalToMdast}, but callable from within an
20
+ * already-active `editor.update()`/`editor.getEditorState().read()` — mirrors
21
+ * `mdastToLexical.ts`'s `importMarkdownToLexicalInEditorState` convention.
22
+ * Used by the annotated-serialize harvest ({@link setAnnotateTarget}) so it can
23
+ * run inside the same read as the plain export it's paired with, with no
24
+ * nested `.read()` call.
25
+ */
26
+ export function exportLexicalToMdastInEditorState(options = {}) {
27
+ // Read once per export pass into the module-level variable `convertLinkNode`
28
+ // consults — mirrors the `annotateTargetId` precedent below (safe under the
29
+ // same synchronous, non-reentrant discipline: this function is the sole
30
+ // entry point every caller funnels through, and it returns before any other
31
+ // export could start).
32
+ wikiLinkPromotionMode = options.wikiLinkPromotion ?? 'promote';
33
+ // Refresh the sentinel-boundary maps for this export pass — populated only
34
+ // when annotate mode is on (see setAnnotateTarget); left null otherwise, so
35
+ // the disk-write path (never sets a target) takes on zero overhead and zero
36
+ // behavioral change.
37
+ if (annotateTargetId) {
38
+ collectSentinelPlacements(annotateTargetId);
39
+ }
40
+ else {
41
+ sentinelBefore = null;
42
+ sentinelAfter = null;
43
+ hoistedBefore = null;
44
+ hoistedAfter = null;
45
+ }
46
+ const lexicalRoot = $getRoot();
47
+ const allChildren = lexicalRoot.getChildren();
48
+ // Detect the footnote definitions section appended at the end by mdastToLexical.
49
+ // Pattern: [HR, indented paragraph with FootnoteNode label, ...]
50
+ // These need to be exported as footnoteDefinition MDAST nodes, not plain paragraphs.
51
+ const { bodyChildren, footnoteChildren } = splitFootnoteSection(allChildren);
52
+ const children = [];
53
+ for (const child of bodyChildren) {
54
+ const nodes = convertLexicalNode(child);
55
+ children.push(...nodes);
56
+ }
57
+ // Group footnote paragraphs into definitions and convert to MDAST nodes
58
+ for (const fnDef of groupFootnoteChildren(footnoteChildren)) {
59
+ children.push(fnDef);
60
+ }
61
+ return { type: 'root', children };
62
+ }
63
+ // ============================================================================
64
+ // Mark transparency
65
+ // ============================================================================
66
+ /**
67
+ * `node.getChildren()`, with any `MarkNode` child (an annotation's live anchor)
68
+ * replaced in place by its own effective children, recursively. A `MarkNode` is
69
+ * a live in-editor wrapper with no markdown representation of its own
70
+ * (ADR-077) — every call site that gathers a block's inline content must see
71
+ * straight through it, so serialization is unaffected by whether any text
72
+ * currently carries an annotation.
73
+ */
74
+ function effectiveChildren(node) {
75
+ const result = [];
76
+ for (const child of node.getChildren()) {
77
+ if ($isMarkNode(child)) {
78
+ result.push(...effectiveChildren(child));
79
+ }
80
+ else {
81
+ result.push(child);
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ // ============================================================================
87
+ // Annotated-serialize mode
88
+ //
89
+ // A throwaway variant of the export used only by the annotation capture
90
+ // primitive (`annotation-marks.ts`'s `locateLiveMarkdownRange`): while a
91
+ // target annotation id is set via `setAnnotateTarget`, that id's live
92
+ // MarkNode(s) — already transparent to markdown — additionally get their
93
+ // content bracketed with a pair of Unicode Private-Use-Area sentinel tokens
94
+ // carrying the id (see `markdown/annotate-sentinels.ts`). Serializing with this
95
+ // mode on is byte-for-byte identical to a normal export *except* for those
96
+ // inserted tokens, so a caller can find the id's sentinel tokens in the
97
+ // resulting string and know that: the first open token's own position is this
98
+ // mark's real start offset in the *plain* export of the same state, and the
99
+ // (sentinel-stripped) text between the outermost tokens is the exact raw
100
+ // markdown slice the mark covers — including whatever markdown syntax the
101
+ // mark's boundary happens to fall on, which is exactly what a search for the
102
+ // mark's *rendered* text could not reliably locate. Never enabled on the
103
+ // disk-write path.
104
+ //
105
+ // That "identical except for the tokens" property is load-bearing and is *not*
106
+ // self-evident from the code: it holds only because no conversion decision
107
+ // anywhere reads a text value the tokens have been spliced into. Two did, and
108
+ // silently perturbed unrelated output until `annotated-serialize-corpus.test.ts`
109
+ // pinned the property corpus-wide (Liminis #970); both now go through
110
+ // `stripAnnotateSentinels` first. Any new decision made from a text value
111
+ // carries the same obligation.
112
+ //
113
+ // Where a token *lands* is not simply "the boundary text leaf". A token spliced
114
+ // into a leaf that sits inside an inline construct would put the recovered range
115
+ // inside that construct's syntax — a mark over `it [rests](https://example.com)`
116
+ // recovered as `it [rests`, a slice ending mid-link that a host then wrote back
117
+ // as the annotation's stored target (#970, defect 2). So a boundary abutting an
118
+ // inline construct whose whole content the mark covers is *hoisted* outside it
119
+ // and emitted as its own mdast text node beside the construct. See
120
+ // `collectSentinelPlacements` and `hoistTargetAt` for the exact rule, and why
121
+ // partial coverage deliberately does not hoist.
122
+ // ============================================================================
123
+ let annotateTargetId = null;
124
+ // Tokens spliced *into* a boundary text leaf's own value.
125
+ let sentinelBefore = null;
126
+ let sentinelAfter = null;
127
+ // Tokens emitted as their own mdast text node *outside* a wholly-covered inline
128
+ // construct — keyed on the node that begins (resp. ends) that construct. See
129
+ // `collectSentinelPlacements`.
130
+ let hoistedBefore = null;
131
+ let hoistedAfter = null;
132
+ // Set once per export pass by exportLexicalToMdastInEditorState (see its own
133
+ // comment); read only by convertLinkNode's promotion check below.
134
+ let wikiLinkPromotionMode = 'promote';
135
+ /**
136
+ * Enables (or, passed `null`, disables) annotated-serialize mode for `id`. See module doc above.
137
+ *
138
+ * These three are module-level globals shared by every `Editor` instance in the
139
+ * process, not per-editor state. That is safe only because the one caller
140
+ * (`annotation-marks.ts`'s `exportAnnotatedMarkdown`) does set → export →
141
+ * `finally`-reset entirely synchronously, so two captures can never interleave
142
+ * in a single-threaded runtime — but nothing about the signature enforces it,
143
+ * and multiple `<Editor>`s can legitimately coexist (multi-pane). If a future
144
+ * change ever made any part of the annotate-mode export path async, or reused
145
+ * this from a second call site, two captures would silently corrupt each
146
+ * other's sentinel state and produce a wrong anchor range with no signal that
147
+ * anything went wrong (review finding, @handarbeit-pruefer).
148
+ *
149
+ * So the invariant is asserted rather than assumed: enabling while already
150
+ * enabled throws. A crash naming the problem beats a silently wrong anchor.
151
+ */
152
+ export function setAnnotateTarget(id) {
153
+ if (id !== null && annotateTargetId !== null) {
154
+ throw new Error(`setAnnotateTarget("${id}") while annotate mode is already active for "${annotateTargetId}" — ` +
155
+ 'annotated serialization uses module-level sentinel state and is not re-entrant. ' +
156
+ 'The export path must stay synchronous, with exactly one caller at a time.');
157
+ }
158
+ annotateTargetId = id;
159
+ }
160
+ export function markOpenToken(id) {
161
+ return `${SENTINEL_OPEN_START}${id}${SENTINEL_OPEN_END}`;
162
+ }
163
+ export function markCloseToken(id) {
164
+ return `${SENTINEL_CLOSE_START}${id}${SENTINEL_CLOSE_END}`;
165
+ }
166
+ /** Every live `MarkNode` in the tree carrying `id`, document order. */
167
+ function collectMarksWithId(id) {
168
+ const result = [];
169
+ const visit = (node) => {
170
+ if ($isMarkNode(node) && node.hasID(id)) {
171
+ result.push(node);
172
+ }
173
+ if ($isElementNode(node)) {
174
+ for (const child of node.getChildren())
175
+ visit(child);
176
+ }
177
+ };
178
+ visit($getRoot());
179
+ return result;
180
+ }
181
+ /** First/last text-bearing leaf among `nodes`, recursing into element children (e.g. a link's own text) — never into another mark (handled by the caller's own flattening). */
182
+ function firstTextLeaf(nodes) {
183
+ for (const node of nodes) {
184
+ if ($isTextNode(node))
185
+ return node;
186
+ if ($isElementNode(node)) {
187
+ const leaf = firstTextLeaf(effectiveChildren(node));
188
+ if (leaf)
189
+ return leaf;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+ function lastTextLeaf(nodes) {
195
+ for (let i = nodes.length - 1; i >= 0; i--) {
196
+ const node = nodes[i];
197
+ if ($isTextNode(node))
198
+ return node;
199
+ if ($isElementNode(node)) {
200
+ const leaf = lastTextLeaf(effectiveChildren(node));
201
+ if (leaf)
202
+ return leaf;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+ /**
208
+ * The mark-flattened phrasing run a mark's own children sit inside, plus where
209
+ * in it they sit — i.e. the list `convertInlinePhrasingList` will actually see,
210
+ * and the mark's index range within it.
211
+ *
212
+ * Null when the mark has no children, or when its (mark-transparent) parent is
213
+ * one whose inline content does *not* go through the shared phrasing path —
214
+ * see {@link canCarryHoistedTokens}.
215
+ */
216
+ function markPhrasingContext(mark) {
217
+ let parent = mark.getParent();
218
+ while (parent && $isMarkNode(parent))
219
+ parent = parent.getParent();
220
+ if (!parent || !$isElementNode(parent) || !canCarryHoistedTokens(parent))
221
+ return null;
222
+ const kids = effectiveChildren(mark);
223
+ if (kids.length === 0)
224
+ return null;
225
+ const flat = effectiveChildren(parent);
226
+ const first = flat.findIndex((node) => node.is(kids[0]));
227
+ const last = flat.findIndex((node) => node.is(kids[kids.length - 1]));
228
+ if (first === -1 || last === -1 || last < first)
229
+ return null;
230
+ return { flat, first, last };
231
+ }
232
+ /**
233
+ * An inline node that emits markdown syntax of its own around (or instead of)
234
+ * text — a link, wiki link, image, inline equation, footnote reference or
235
+ * inline HTML. When one of these is a mark's own child it is *wholly* inside
236
+ * that mark by construction, so the mark's boundary token belongs outside the
237
+ * whole construct rather than inside its text.
238
+ *
239
+ * Line breaks are excluded deliberately: they carry no syntax a boundary can
240
+ * fall inside, and today's leaf walk skips straight past them.
241
+ */
242
+ function isHoistableConstruct(node) {
243
+ return $isLinkNode(node) || $isImageNode(node) || $isEquationNode(node) || $isFootnoteNode(node) || $isHtmlNode(node);
244
+ }
245
+ /**
246
+ * Inclusive bounds of the maximal run of consecutive siblings in `flat` that
247
+ * will be emitted inside one shared delimiter pair with `flat[index]` — the
248
+ * same runs `convertInlinePhrasingList` merges (inline code, and
249
+ * strong/emphasis/delete, including a run whose members' formats differ but
250
+ * share a common bit — see `convertInlineUnit`). Null when `flat[index]`
251
+ * carries no such delimiters.
252
+ */
253
+ function mergeableRunBounds(flat, index) {
254
+ const child = flat[index];
255
+ if (!$isTextNode(child))
256
+ return null;
257
+ // Pure inline code only. A code-formatted node that also carries a
258
+ // bold/italic/strikethrough bit is emitted inside that wrapper (#973), so its
259
+ // delimiter run is the *formatted* run computed below, not the code run —
260
+ // getting this precedence wrong misplaces annotate-mode sentinel hoisting.
261
+ if (child.hasFormat('code') && !getMergeableFormat(child)) {
262
+ const isCode = (node) => $isTextNode(node) && node.hasFormat('code') && !getMergeableFormat(node);
263
+ let start = index;
264
+ let end = index;
265
+ while (start > 0 && isCode(flat[start - 1]))
266
+ start--;
267
+ while (end + 1 < flat.length && isCode(flat[end + 1]))
268
+ end++;
269
+ return { start, end };
270
+ }
271
+ const format = getMergeableFormat(child);
272
+ if (!format)
273
+ return null;
274
+ // Replays `convertInlineUnit`'s own shrinking-intersection scan, left to
275
+ // right from the start of `flat`, and returns the bounds of whichever
276
+ // partition segment contains `index`.
277
+ //
278
+ // A symmetric/bidirectional scan centered on `index` (the original version
279
+ // of this code) is only equivalent to that forward partition for a
280
+ // *uniform* run — one where every member's format is exactly equal, so the
281
+ // running intersection never actually shrinks and boundary order can't
282
+ // matter. Once a run's members can differ-but-overlap (the bold/bold+italic
283
+ // /bold split produced by a nested `**bold _and italic_**`), the partition
284
+ // becomes direction-sensitive: which run a node belongs to depends on where
285
+ // that run *started* scanning forward, which a scan centered on `index`
286
+ // cannot recover just by looking at `index`'s own neighbors. A prior version
287
+ // of this function got this wrong for an asymmetric transition like
288
+ // italic(2)/bold+italic(3)/bold(1): querying the last (bold) node walked
289
+ // left into the middle node and returned a run of the last two nodes, while
290
+ // `convertInlineUnit`'s actual forward-computed unit for that same middle
291
+ // node is the *first* two — a fictitious run that happened to still satisfy
292
+ // `hoistTargetAt`'s "wholly inside the mark" gate, hoisting an annotation
293
+ // sentinel out past content the mark never covered (PR #16 review).
294
+ let i = 0;
295
+ while (i <= index) {
296
+ const startFormat = getMergeableFormat(flat[i]);
297
+ if (startFormat === null || startFormat === 0) {
298
+ i++;
299
+ continue;
300
+ }
301
+ let common = startFormat;
302
+ let j = i + 1;
303
+ while (j < flat.length) {
304
+ const nextFormat = getMergeableFormat(flat[j]);
305
+ if (nextFormat === null || (nextFormat & common) === 0)
306
+ break;
307
+ common &= nextFormat;
308
+ j++;
309
+ }
310
+ if (index < j)
311
+ return { start: i, end: j - 1 };
312
+ i = j;
313
+ }
314
+ return { start: index, end: index };
315
+ }
316
+ /**
317
+ * Whether `node` is the entirety of `parent`'s mark-flattened content — i.e.
318
+ * everything `parent` will emit comes from inside `node`.
319
+ */
320
+ function coversAllOf(node, parent) {
321
+ const outer = effectiveChildren(parent);
322
+ const inner = $isMarkNode(node) ? effectiveChildren(node) : [node];
323
+ return outer.length === inner.length && outer.every((child, index) => child.is(inner[index]));
324
+ }
325
+ /**
326
+ * Walking *outward* from `mark`: the outermost inline construct whose whole
327
+ * text the mark covers, or null if the mark sits inside no such construct.
328
+ *
329
+ * This is the case where the mark is *inside* the construct rather than around
330
+ * it — a selection of exactly a link's own text, which is what every ordinary
331
+ * DOM capture over a link produces, and what a markdown-domain anchor over a
332
+ * whole link resolves to (both endpoints snap onto the link's text node). The
333
+ * mark covers the link's rendered content in full, so its raw-markdown range
334
+ * is the whole construct: `[project docs](…)`, not `project docs` (FR-011).
335
+ */
336
+ function hoistTargetOutward(mark) {
337
+ let node = mark;
338
+ let target = null;
339
+ for (let parent = node.getParent(); parent; parent = node.getParent()) {
340
+ if ($isMarkNode(parent)) {
341
+ node = parent;
342
+ continue;
343
+ }
344
+ if (!isHoistableConstruct(parent) || !coversAllOf(node, parent))
345
+ break;
346
+ target = parent;
347
+ node = parent;
348
+ }
349
+ // The token is emitted as a sibling of `target`, so `target`'s own parent has
350
+ // to be one whose inline run can carry it — and that parent has to route
351
+ // `target` itself through that run rather than to the block dispatcher.
352
+ return target && canCarryHoistedTokens(target.getParent()) && hoistedTokenReachesOutput(target) ? target : null;
353
+ }
354
+ /**
355
+ * Whether a hoisted token emitted among `parent`'s inline children would
356
+ * actually reach the output.
357
+ *
358
+ * A fenced code block's content is concatenated raw text (`convertCodeNode`),
359
+ * and a link's own body has only text runs routed through the phrasing list —
360
+ * its wiki-link form has nothing but the alias slot to write into. A token
361
+ * emitted into either would simply be dropped, so those boundaries keep the
362
+ * leaf splice.
363
+ */
364
+ function canCarryHoistedTokens(parent) {
365
+ let node = parent;
366
+ while (node && $isMarkNode(node))
367
+ node = node.getParent();
368
+ return !!node && $isElementNode(node) && !$isCodeNode(node) && !$isLinkNode(node);
369
+ }
370
+ /**
371
+ * The same question asked of the *construct* rather than its container, because
372
+ * one container routes its children unevenly.
373
+ *
374
+ * `convertListItemNode` sends only text runs, line breaks and links through the
375
+ * inline phrasing path; every other child goes to the block dispatcher, which
376
+ * has nowhere to put a phrasing token. Hoisting a boundary onto one of those
377
+ * would drop the token silently, and `locateLiveMarkdownRange` would then fail
378
+ * to find the mark at all.
379
+ *
380
+ * No document reaches that state today, which is why this is a guard rather
381
+ * than a fix: an image, inline equation, footnote reference or inline HTML
382
+ * inside a list item is *itself* block-promoted by that same dispatcher, so it
383
+ * does not survive a round trip inline (`- The value $x^2$ matters` exports as
384
+ * three blocks) and any range over it is already rejected by
385
+ * `locateLiveMarkdownRange`'s slice check. Repairing that unrelated,
386
+ * pre-existing round-trip defect must not silently regress annotation ranges as
387
+ * its side effect.
388
+ */
389
+ function hoistedTokenReachesOutput(node) {
390
+ let parent = node.getParent();
391
+ while (parent && $isMarkNode(parent))
392
+ parent = parent.getParent();
393
+ return $isListItemNode(parent) ? $isLinkNode(node) : true;
394
+ }
395
+ /**
396
+ * The node a mark's boundary token should be emitted outside of, or null to
397
+ * keep today's splice into the boundary text leaf.
398
+ *
399
+ * The rule is structural: **hoist past every inline construct whose whole
400
+ * rendered content the mark covers and whose emitted syntax abuts this
401
+ * boundary.** Two shapes, checked in that order:
402
+ *
403
+ * 1. the mark sits *inside* one or more constructs and covers each one's
404
+ * content entirely — {@link hoistTargetOutward}; the outermost wins, so
405
+ * `[**rests**](url)` closes after the link rather than after the `**`;
406
+ * 2. the boundary child is itself a construct (a link the mark contains) or a
407
+ * formatted text leaf whose maximal same-format run lies entirely inside the
408
+ * mark — hoist past that construct's shared delimiters.
409
+ *
410
+ * Otherwise the mark covers only *part* of the construct, its delimiter is
411
+ * shared with text the mark doesn't cover, and the token stays where it is.
412
+ *
413
+ * That last case is deliberate, and is where this deviates from the spec's
414
+ * FR-014, which would widen a partial-coverage mark out to the construct's
415
+ * boundaries too. Widening there changes what the annotation *means*: a mark
416
+ * over `big` inside `**big world**` would recover as `**big world**`, so
417
+ * re-placing that anchor would highlight `big world` — a different span than
418
+ * the user selected, which FR-017's existing corpus
419
+ * (`annotation-marks.test.ts`'s `FORMATTING_CORPUS`) pins against. Whole
420
+ * coverage has no such problem: re-placing `[project docs](…)` snaps back
421
+ * inside the syntax and highlights `project docs` again. FR-014's first clause
422
+ * holds either way — a boundary always sits at a text-leaf edge, never
423
+ * strictly inside a delimiter run.
424
+ */
425
+ function hoistTargetAt(mark, edge) {
426
+ const outward = hoistTargetOutward(mark);
427
+ if (outward)
428
+ return outward;
429
+ const context = markPhrasingContext(mark);
430
+ if (!context)
431
+ return null;
432
+ const { flat, first, last } = context;
433
+ // Skip line breaks the same way the leaf walk does, so a mark that opens or
434
+ // closes on one still looks past it to the real boundary content.
435
+ let index = edge === 'start' ? first : last;
436
+ const step = edge === 'start' ? 1 : -1;
437
+ while (index >= first && index <= last && $isLineBreakNode(flat[index]))
438
+ index += step;
439
+ if (index < first || index > last)
440
+ return null;
441
+ if (isHoistableConstruct(flat[index]))
442
+ return hoistedTokenReachesOutput(flat[index]) ? flat[index] : null;
443
+ const run = mergeableRunBounds(flat, index);
444
+ if (run && run.start >= first && run.end <= last)
445
+ return flat[edge === 'start' ? run.start : run.end];
446
+ return null;
447
+ }
448
+ /**
449
+ * Populates the four sentinel-placement maps for every live mark carrying `id`.
450
+ *
451
+ * Its own first rendered-text leaf gets an open-token prefix and its own last a
452
+ * close-token suffix — *unless* that boundary abuts an inline construct the
453
+ * mark wholly covers, in which case the token is hoisted out to the construct's
454
+ * own boundary instead (see {@link hoistTargetAt}). Without the hoist, a mark
455
+ * over `it [rests](https://example.com)` put its close token on the link's
456
+ * *text* leaf, so the recovered range read `it [rests` — a slice ending inside
457
+ * link syntax, which a host's refresh pass then wrote back as the annotation's
458
+ * stored target (Liminis #970, defect 2).
459
+ *
460
+ * A multi-block/multi-mark annotation (several sibling MarkNodes sharing `id`)
461
+ * gets one such pair per mark instance — deliberately, so the caller can
462
+ * recover a per-mark boundary even when the marks aren't textually adjacent.
463
+ */
464
+ function collectSentinelPlacements(id) {
465
+ const before = new Map();
466
+ const after = new Map();
467
+ const hoistBefore = new Map();
468
+ const hoistAfter = new Map();
469
+ for (const mark of collectMarksWithId(id)) {
470
+ const kids = effectiveChildren(mark);
471
+ const openAt = hoistTargetAt(mark, 'start');
472
+ if (openAt) {
473
+ hoistBefore.set(openAt, id);
474
+ }
475
+ else {
476
+ const first = firstTextLeaf(kids);
477
+ if (first)
478
+ before.set(first, id);
479
+ }
480
+ const closeAt = hoistTargetAt(mark, 'end');
481
+ if (closeAt) {
482
+ hoistAfter.set(closeAt, id);
483
+ }
484
+ else {
485
+ const last = lastTextLeaf(kids);
486
+ if (last)
487
+ after.set(last, id);
488
+ }
489
+ }
490
+ sentinelBefore = before;
491
+ sentinelAfter = after;
492
+ hoistedBefore = hoistBefore;
493
+ hoistedAfter = hoistAfter;
494
+ }
495
+ /**
496
+ * The mdast text nodes carrying any hoisted tokens recorded for
497
+ * `kids[start..end)` at `edge` — emitted as siblings immediately before (resp.
498
+ * after) that slice's own converted output, which is what puts them outside the
499
+ * construct's syntax. Empty whenever annotate mode is off.
500
+ */
501
+ function hoistedTokenNodes(kids, start, end, edge) {
502
+ const map = edge === 'before' ? hoistedBefore : hoistedAfter;
503
+ if (!map || map.size === 0)
504
+ return [];
505
+ const result = [];
506
+ for (let i = start; i < end; i++) {
507
+ const id = map.get(kids[i]);
508
+ if (id)
509
+ result.push({ type: 'text', value: edge === 'before' ? markOpenToken(id) : markCloseToken(id) });
510
+ }
511
+ return result;
512
+ }
513
+ /** {@link hoistedTokenNodes} for a single node. */
514
+ function hoistedTokenNodesFor(node, edge) {
515
+ return hoistedTokenNodes([node], 0, 1, edge);
516
+ }
517
+ /** `node`'s own rendered text content, with an annotate-mode sentinel spliced in if this exact node instance is a live mark's boundary leaf. A no-op (returns `node.getTextContent()` verbatim) whenever annotate mode is off. */
518
+ function sentinelAugmentedText(node) {
519
+ let text = node.getTextContent();
520
+ const beforeId = sentinelBefore?.get(node);
521
+ if (beforeId)
522
+ text = markOpenToken(beforeId) + text;
523
+ const afterId = sentinelAfter?.get(node);
524
+ if (afterId)
525
+ text = text + markCloseToken(afterId);
526
+ return text;
527
+ }
528
+ // Detect the trailing footnote definitions section:
529
+ // An HR followed by indented paragraphs — each definition starts with a FootnoteNode label,
530
+ // and may be followed by continuation paragraphs (indent=1, no FootnoteNode) for multi-paragraph definitions.
531
+ function splitFootnoteSection(allChildren) {
532
+ // Walk backwards to find the start of the footnote section.
533
+ // Accept any indent=1 paragraph — both labeled (starts with FootnoteNode) and
534
+ // continuation paragraphs (no FootnoteNode, part of a multi-paragraph definition).
535
+ let footnoteStart = allChildren.length;
536
+ let hasLabeledParagraph = false;
537
+ for (let i = allChildren.length - 1; i >= 0; i--) {
538
+ const child = allChildren[i];
539
+ if ($isParagraphNode(child) && child.getIndent() === 1) {
540
+ // Through the mark-transparent view, so an annotation wrapping the label
541
+ // itself doesn't hide this paragraph from footnote-section detection.
542
+ const firstChild = effectiveChildren(child)[0];
543
+ if (firstChild && $isFootnoteNode(firstChild)) {
544
+ hasLabeledParagraph = true;
545
+ }
546
+ footnoteStart = i;
547
+ continue;
548
+ }
549
+ break;
550
+ }
551
+ // Must have at least one paragraph that starts with a FootnoteNode label
552
+ if (!hasLabeledParagraph) {
553
+ return { bodyChildren: allChildren, footnoteChildren: [] };
554
+ }
555
+ // Check if the node just before the footnote paragraphs is an HR separator
556
+ if (footnoteStart < allChildren.length && footnoteStart > 0) {
557
+ const maybeHR = allChildren[footnoteStart - 1];
558
+ if ($isHorizontalRuleNode(maybeHR)) {
559
+ return {
560
+ bodyChildren: allChildren.slice(0, footnoteStart - 1),
561
+ footnoteChildren: allChildren.slice(footnoteStart),
562
+ };
563
+ }
564
+ }
565
+ // No footnote section found
566
+ return { bodyChildren: allChildren, footnoteChildren: [] };
567
+ }
568
+ // Group consecutive footnote paragraphs into footnoteDefinition MDAST nodes.
569
+ // Each labeled paragraph (starts with FootnoteNode) begins a new definition.
570
+ // Subsequent unlabeled indent=1 paragraphs are continuation paragraphs of the same definition.
571
+ function groupFootnoteChildren(footnoteChildren) {
572
+ const results = [];
573
+ let currentIdentifier = null;
574
+ let currentParagraphs = [];
575
+ function flushDefinition() {
576
+ if (currentIdentifier && currentParagraphs.length > 0) {
577
+ results.push({
578
+ type: 'footnoteDefinition',
579
+ identifier: currentIdentifier,
580
+ label: currentIdentifier,
581
+ children: currentParagraphs,
582
+ });
583
+ }
584
+ currentIdentifier = null;
585
+ currentParagraphs = [];
586
+ }
587
+ for (const node of footnoteChildren) {
588
+ if (!$isParagraphNode(node))
589
+ continue;
590
+ // Through the mark-transparent view, so an annotation wrapping the label
591
+ // itself doesn't hide this paragraph's own definition identity.
592
+ const firstChild = effectiveChildren(node)[0];
593
+ if (firstChild && $isFootnoteNode(firstChild)) {
594
+ // New definition — flush any previous one
595
+ flushDefinition();
596
+ currentIdentifier = firstChild.getFootnoteId();
597
+ // Convert inline content, skipping the FootnoteNode label and its trailing space
598
+ const contentChildren = convertFootnoteInlineChildren(firstChild);
599
+ currentParagraphs.push({
600
+ type: 'paragraph',
601
+ children: contentChildren.length > 0 ? contentChildren : [{ type: 'text', value: '' }],
602
+ });
603
+ }
604
+ else if (currentIdentifier) {
605
+ // Continuation paragraph for the current definition
606
+ const contentChildren = convertInlineChildren(node);
607
+ currentParagraphs.push({
608
+ type: 'paragraph',
609
+ children: contentChildren.length > 0 ? contentChildren : [{ type: 'text', value: '' }],
610
+ });
611
+ }
612
+ }
613
+ flushDefinition();
614
+ return results;
615
+ }
616
+ // Removes one leading space character from `value`, skipping past any
617
+ // complete leading sentinel token(s) first so annotate mode's boundary
618
+ // markers keep their position (Liminis #970's invariant: a decision made
619
+ // from a text value must be made against its sentinel-stripped form, and any
620
+ // mutation must not perturb where the tokens land).
621
+ function stripLeadingSpaceKeepingSentinels(value) {
622
+ let i = 0;
623
+ while (i < value.length) {
624
+ if (value[i] === SENTINEL_OPEN_START) {
625
+ const endIdx = value.indexOf(SENTINEL_OPEN_END, i + 1);
626
+ if (endIdx === -1)
627
+ break;
628
+ i = endIdx + 1;
629
+ continue;
630
+ }
631
+ if (value[i] === SENTINEL_CLOSE_START) {
632
+ const endIdx = value.indexOf(SENTINEL_CLOSE_END, i + 1);
633
+ if (endIdx === -1)
634
+ break;
635
+ i = endIdx + 1;
636
+ continue;
637
+ }
638
+ break;
639
+ }
640
+ if (value[i] === ' ') {
641
+ return value.slice(0, i) + value.slice(i + 1);
642
+ }
643
+ return value;
644
+ }
645
+ // Convert inline children of a footnote definition's first paragraph,
646
+ // skipping the leading FootnoteNode label and its trailing space separator.
647
+ function convertFootnoteInlineChildren(labelNode) {
648
+ const contentChildren = [];
649
+ // An annotation wrapping the label itself puts a MarkNode between it and its
650
+ // real block parent — step past any such wrapper(s) so the mark-transparent
651
+ // sibling walk below sees the label's true siblings, not just its mark-mates.
652
+ let parent = labelNode.getParent();
653
+ while (parent && $isMarkNode(parent))
654
+ parent = parent.getParent();
655
+ if (!parent)
656
+ return contentChildren;
657
+ // Walk the label's siblings through the mark-transparent view so an
658
+ // annotation over part of the footnote's own content doesn't hide it.
659
+ const { nodes: siblings, marked } = effectiveChildrenWithMarkMembership(parent);
660
+ const labelIndex = siblings.findIndex((n) => n.is(labelNode));
661
+ const rest = labelIndex === -1 ? [] : siblings.slice(labelIndex + 1);
662
+ let restIndex = 0;
663
+ let child = rest[restIndex] ?? null;
664
+ // TextNodes are buffered and flushed through the shared run-merging path, for
665
+ // the same reason convertLinkNode and convertListItemNode do it: a mark that
666
+ // splits a formatted run inside the footnote's body would otherwise emit one
667
+ // delimiter pair per resulting sibling (`**big**** word**`).
668
+ let textRun = [];
669
+ const flushTextRun = () => {
670
+ if (textRun.length > 0) {
671
+ contentChildren.push(...convertInlinePhrasingList(textRun, marked));
672
+ textRun = [];
673
+ }
674
+ };
675
+ // Use the same inline conversion logic as convertInlineChildren
676
+ while (child) {
677
+ if ($isTextNode(child)) {
678
+ textRun.push(child);
679
+ restIndex++;
680
+ child = rest[restIndex] ?? null;
681
+ continue;
682
+ }
683
+ flushTextRun();
684
+ // A footnote definition's own inline children bypass
685
+ // convertInlinePhrasingList for everything but text runs, so any token
686
+ // hoisted outside one of these constructs has to be emitted here too.
687
+ contentChildren.push(...hoistedTokenNodesFor(child, 'before'));
688
+ if ($isLineBreakNode(child)) {
689
+ contentChildren.push({ type: 'break' });
690
+ }
691
+ else if ($isLinkNode(child)) {
692
+ contentChildren.push(convertLinkNode(child));
693
+ }
694
+ else if ($isImageNode(child)) {
695
+ const image = {
696
+ type: 'image',
697
+ url: child.getSrc(),
698
+ alt: child.getAlt(),
699
+ title: child.getTitle(),
700
+ };
701
+ contentChildren.push(image);
702
+ }
703
+ else if ($isEquationNode(child)) {
704
+ contentChildren.push({ type: 'inlineMath', value: child.getEquation() });
705
+ }
706
+ else if ($isFootnoteNode(child)) {
707
+ contentChildren.push({
708
+ type: 'footnoteReference',
709
+ identifier: child.getFootnoteId(),
710
+ label: child.getFootnoteId(),
711
+ });
712
+ }
713
+ contentChildren.push(...hoistedTokenNodesFor(child, 'after'));
714
+ restIndex++;
715
+ child = rest[restIndex] ?? null;
716
+ }
717
+ flushTextRun();
718
+ // The label is followed on import by a synthetic single-space separator
719
+ // TextNode. If the definition body starts with plain text, Lexical's
720
+ // reconciliation merges that separator into the body's own TextNode
721
+ // (' ' + 'The body.' -> ' The body.') rather than leaving it as a
722
+ // distinct sibling, so it can't be skipped above by identity. Strip
723
+ // exactly one leading space from the first text child here instead, which
724
+ // handles both the merged and unmerged cases identically. The decision is
725
+ // made against the sentinel-stripped form (annotate mode may have spliced
726
+ // boundary tokens into this same node) and the removal keeps those tokens
727
+ // in place — see `stripLeadingSpaceKeepingSentinels`.
728
+ const first = contentChildren[0];
729
+ if (first?.type === 'text' && stripAnnotateSentinels(first.value).startsWith(' ')) {
730
+ const value = stripLeadingSpaceKeepingSentinels(first.value);
731
+ // Keep the node even if stripping the space left no real text — it may
732
+ // still carry sentinel tokens that must not be dropped (Liminis #970).
733
+ // Only an entirely empty string means there's nothing left to emit.
734
+ if (value.length > 0) {
735
+ contentChildren[0] = { ...first, value };
736
+ }
737
+ else {
738
+ contentChildren.shift();
739
+ }
740
+ }
741
+ return contentChildren;
742
+ }
743
+ function convertLexicalNode(node) {
744
+ if ($isParagraphNode(node)) {
745
+ return [convertParagraphNode(node)];
746
+ }
747
+ if ($isHeadingNode(node)) {
748
+ return [convertHeadingNode(node)];
749
+ }
750
+ if ($isQuoteNode(node)) {
751
+ return [convertQuoteNode(node)];
752
+ }
753
+ if ($isListNode(node)) {
754
+ return [convertListNode(node)];
755
+ }
756
+ // Check FrontmatterNode BEFORE CodeNode since FrontmatterNode extends CodeNode
757
+ if ($isFrontmatterNode(node)) {
758
+ return [convertFrontmatterNode(node)];
759
+ }
760
+ if ($isCodeNode(node)) {
761
+ return [convertCodeNode(node)];
762
+ }
763
+ if ($isHorizontalRuleNode(node)) {
764
+ return [convertHorizontalRuleNode()];
765
+ }
766
+ if ($isTableNode(node)) {
767
+ return [convertTableNode(node)];
768
+ }
769
+ if ($isImageNode(node)) {
770
+ return [convertImageNode(node)];
771
+ }
772
+ if ($isCalloutNode(node)) {
773
+ return [convertCalloutNode(node)];
774
+ }
775
+ if ($isToggleContainerNode(node)) {
776
+ return convertToggleContainerNode(node);
777
+ }
778
+ if ($isEquationNode(node)) {
779
+ return [convertEquationNode(node)];
780
+ }
781
+ if ($isMermaidNode(node)) {
782
+ return [convertMermaidNode(node)];
783
+ }
784
+ if ($isC4Node(node)) {
785
+ return [convertC4Node(node)];
786
+ }
787
+ if ($isDefinitionListNode(node)) {
788
+ return [convertDefinitionListNode(node)];
789
+ }
790
+ if ($isHtmlNode(node)) {
791
+ return [{ type: 'html', value: node.getHtml() }];
792
+ }
793
+ // Fallback: create paragraph
794
+ const paragraph = {
795
+ type: 'paragraph',
796
+ children: [{ type: 'text', value: '' }],
797
+ };
798
+ return [paragraph];
799
+ }
800
+ function convertParagraphNode(node) {
801
+ const children = convertInlineChildren(node);
802
+ return {
803
+ type: 'paragraph',
804
+ children: children.length > 0 ? children : [{ type: 'text', value: '' }],
805
+ };
806
+ }
807
+ function convertHeadingNode(node) {
808
+ const tag = node.getTag();
809
+ const depth = parseInt(tag.charAt(1), 10);
810
+ const children = convertInlineChildren(node);
811
+ return {
812
+ type: 'heading',
813
+ depth,
814
+ children: children.length > 0 ? children : [{ type: 'text', value: '' }],
815
+ };
816
+ }
817
+ /**
818
+ * True for children that must be dispatched as their own block, rather than
819
+ * folded into the surrounding inline run. Deliberately excludes
820
+ * Image/Equation/Footnote/Html — those stay inline-eligible and are handled by
821
+ * `convertInlinePhrasingList` (see `isInlineDelimitedNode`-adjacent dispatch).
822
+ */
823
+ function isQuoteBlockChild(node) {
824
+ return ($isParagraphNode(node) ||
825
+ $isHeadingNode(node) ||
826
+ $isListNode(node) ||
827
+ $isFrontmatterNode(node) ||
828
+ $isCodeNode(node) ||
829
+ $isHorizontalRuleNode(node) ||
830
+ $isTableNode(node) ||
831
+ $isCalloutNode(node) ||
832
+ $isToggleContainerNode(node) ||
833
+ $isMermaidNode(node) ||
834
+ $isC4Node(node) ||
835
+ $isDefinitionListNode(node) ||
836
+ $isQuoteNode(node));
837
+ }
838
+ /**
839
+ * QuoteNode is reachable via two structurally different shapes: markdown
840
+ * import (`convertBlockquote` in mdastToLexical.ts gives it real ParagraphNode
841
+ * — and other block-node — children, one per mdast paragraph, so consecutive
842
+ * paragraphs survive as separate blocks) and live typing (Lexical's own
843
+ * `registerRichText` never wraps typed content in a ParagraphNode, so a
844
+ * live-typed quote's children are flat TextNode/LineBreakNode runs). This
845
+ * buffer-and-flush loop handles both: block-type children (paragraphs and
846
+ * other block content) are dispatched individually through the shared block
847
+ * dispatcher, while everything else is buffered and flushed through the same
848
+ * `convertInlinePhrasingList` call `convertInlineChildren` uses elsewhere, so
849
+ * Image/Equation/Footnote/Html/mark-transparency handling stays exactly as it
850
+ * is for a flat, non-block quote. See #18.
851
+ */
852
+ function convertQuoteNode(node) {
853
+ const { nodes, marked } = effectiveChildrenWithMarkMembership(node);
854
+ const children = [];
855
+ let buffer = [];
856
+ const flushBuffer = () => {
857
+ if (buffer.length > 0) {
858
+ const converted = convertInlinePhrasingList(buffer, marked);
859
+ const paragraph = {
860
+ type: 'paragraph',
861
+ children: converted.length > 0 ? converted : [{ type: 'text', value: '' }],
862
+ };
863
+ children.push(paragraph);
864
+ buffer = [];
865
+ }
866
+ };
867
+ for (const child of nodes) {
868
+ if (isQuoteBlockChild(child)) {
869
+ flushBuffer();
870
+ children.push(...convertLexicalNode(child));
871
+ }
872
+ else {
873
+ buffer.push(child);
874
+ }
875
+ }
876
+ flushBuffer();
877
+ if (children.length === 0) {
878
+ children.push({ type: 'paragraph', children: [{ type: 'text', value: '' }] });
879
+ }
880
+ return {
881
+ type: 'blockquote',
882
+ children: children,
883
+ };
884
+ }
885
+ function convertListNode(node) {
886
+ const listType = node.getListType();
887
+ const ordered = listType === 'number';
888
+ const spread = $isCustomListNode(node) ? node.getSpread() : false;
889
+ const children = [];
890
+ for (const child of node.getChildren()) {
891
+ if ($isListItemNode(child)) {
892
+ children.push(convertListItemNode(child, ordered, spread));
893
+ }
894
+ }
895
+ return {
896
+ type: 'list',
897
+ ordered,
898
+ spread,
899
+ children,
900
+ };
901
+ }
902
+ function convertListItemNode(node, _ordered, spread) {
903
+ // Mirrors the flush-on-block-boundary dispatch used at the document root
904
+ // (convertLexicalNode): inline children (text/line breaks/links) accumulate
905
+ // into a buffer, which is flushed into a paragraph whenever a nested list or
906
+ // any other block-type child (ParagraphNode, CodeNode, TableNode, QuoteNode,
907
+ // etc.) is encountered. This preserves block content nested inside list
908
+ // items instead of silently dropping it.
909
+ const children = [];
910
+ let inlineChildren = [];
911
+ // Buffers raw TextNodes rather than converting each as it's seen, so a run
912
+ // an annotation mark split is merged on flush by the same logic
913
+ // convertInlineChildren uses — needed here for the same reason.
914
+ let textRun = [];
915
+ const { nodes: listItemChildren, marked } = effectiveChildrenWithMarkMembership(node);
916
+ const flushTextRun = () => {
917
+ if (textRun.length > 0) {
918
+ inlineChildren.push(...convertInlinePhrasingList(textRun, marked));
919
+ textRun = [];
920
+ }
921
+ };
922
+ const flushInline = () => {
923
+ flushTextRun();
924
+ if (inlineChildren.length > 0) {
925
+ children.push({ type: 'paragraph', children: [...inlineChildren] });
926
+ inlineChildren = [];
927
+ }
928
+ };
929
+ for (const child of listItemChildren) {
930
+ if ($isListNode(child)) {
931
+ flushInline();
932
+ children.push(convertListNode(child));
933
+ }
934
+ else if ($isTextNode(child)) {
935
+ textRun.push(child);
936
+ }
937
+ else if ($isListItemParagraphBreakNode(child)) {
938
+ // Marks a paragraph boundary inserted by convertListItem for consecutive
939
+ // mdast paragraphs (see its comment) — flush the current paragraph and
940
+ // start a new one. A dedicated node type, unambiguous with LineBreakNode
941
+ // by construction, so it never collides with a genuine hard break —
942
+ // see #902.
943
+ flushInline();
944
+ }
945
+ else if ($isLineBreakNode(child)) {
946
+ flushTextRun();
947
+ inlineChildren.push({ type: 'break' });
948
+ }
949
+ else if ($isLinkNode(child)) {
950
+ flushTextRun();
951
+ // A list item's own inline children bypass convertInlinePhrasingList for
952
+ // everything but text runs, so any token hoisted outside this link has to
953
+ // be emitted here too.
954
+ inlineChildren.push(...hoistedTokenNodesFor(child, 'before'), convertLinkNode(child), ...hoistedTokenNodesFor(child, 'after'));
955
+ }
956
+ else {
957
+ // Any other block-type child (ParagraphNode, CodeNode, TableNode,
958
+ // QuoteNode, etc.) — flush accumulated inline text first, then delegate
959
+ // to the same block dispatcher used at the document root.
960
+ flushInline();
961
+ children.push(...convertLexicalNode(child));
962
+ }
963
+ }
964
+ flushInline();
965
+ // Preserve explicit task markers in text to keep round-trip stable.
966
+ // If a list item's first paragraph already starts with [ ] or [x], keep it
967
+ // and avoid setting `checked` to prevent duplicate markers on stringify.
968
+ //
969
+ // The test runs against the sentinel-*free* text: in annotate mode the
970
+ // paragraph's first value is prefixed with an open token, so matching the raw
971
+ // value made this decision flip to false and the item got a `checked` flag on
972
+ // top of the literal marker it already carried — `1. [ ] [ ] Run the setup
973
+ // script`, i.e. annotate mode changing output outside its own tokens, which
974
+ // is exactly what `locateLiveMarkdownRange`'s offset math forbids (#970).
975
+ let hasExplicitMarker = false;
976
+ const firstChild = children[0];
977
+ if (firstChild?.type === 'paragraph') {
978
+ const firstPhrasing = firstChild.children[0];
979
+ if (firstPhrasing?.type === 'text' && /^\[( |x|X)\]\s+/.exec(stripAnnotateSentinels(firstPhrasing.value))) {
980
+ hasExplicitMarker = true;
981
+ }
982
+ }
983
+ // Stock ListItemNode.getChecked() derives its answer from the parent
984
+ // list's listType ('check' ? Boolean(__checked) : undefined), so it can't
985
+ // distinguish a plain item from an unchecked task when the list is mixed
986
+ // (see CustomListItemNode's docstring). Read the mapper-owned tri-state
987
+ // field instead, which was set directly from mdast on import.
988
+ const checked = $isCustomListItemNode(node) ? node.getTaskChecked() : (node.getChecked?.() ?? null);
989
+ const checkedForOutput = hasExplicitMarker ? null : checked;
990
+ return {
991
+ type: 'listItem',
992
+ // Baseline spread mirrors the containing list's spread (itself carried
993
+ // through the round trip via CustomListNode, preserving the source
994
+ // document's loose/tight intent). stringify.ts's computeListSpread may
995
+ // still upgrade this to true (never downgrade to false) when an item's
996
+ // own child shape requires blank-line separation to parse back
997
+ // unambiguously, regardless of the source document's original spacing.
998
+ spread,
999
+ checked: checkedForOutput,
1000
+ children: children.length > 0 ? children : [{ type: 'paragraph', children: [{ type: 'text', value: '' }] }],
1001
+ };
1002
+ }
1003
+ // Mark-transparent and annotate-mode-aware equivalent of
1004
+ // `node.getTextContent()`: a fenced code block's own text (concatenating
1005
+ // CodeHighlightNode/TabNode/LineBreakNode children) plus, for the
1006
+ // annotate-mode target's boundary leaf, the same sentinel splice every other
1007
+ // block type gets via `sentinelAugmentedText`. Without this, an annotation
1008
+ // wrapping part of a code block's content would never be locatable via
1009
+ // `locateLiveMarkdownRange` — capture would always come back null exactly as
1010
+ // if there were no live mark at all.
1011
+ function convertCodeNode(node) {
1012
+ // Mark-transparent equivalent of getTextContent(), which handles both
1013
+ // TextNode and CodeHighlightNode children.
1014
+ let value = '';
1015
+ for (const child of effectiveChildren(node)) {
1016
+ value += $isTextNode(child) ? sentinelAugmentedText(child) : child.getTextContent();
1017
+ }
1018
+ const rawLang = node.getLanguage?.();
1019
+ const lang = rawLang && rawLang !== 'plain' ? rawLang : undefined;
1020
+ return {
1021
+ type: 'code',
1022
+ lang,
1023
+ value,
1024
+ };
1025
+ }
1026
+ function convertHorizontalRuleNode() {
1027
+ return { type: 'thematicBreak' };
1028
+ }
1029
+ function mapFormatTypeToAlign(formatType) {
1030
+ switch (formatType) {
1031
+ case 'left':
1032
+ case 'right':
1033
+ case 'center':
1034
+ return formatType;
1035
+ default:
1036
+ return null;
1037
+ }
1038
+ }
1039
+ function convertTableNode(node) {
1040
+ const rows = [];
1041
+ const align = [];
1042
+ let isFirstRow = true;
1043
+ for (const child of node.getChildren()) {
1044
+ if ($isTableRowNode(child)) {
1045
+ const row = convertTableRowNode(child);
1046
+ rows.push(row);
1047
+ // Get alignment from first row
1048
+ if (isFirstRow) {
1049
+ for (const cell of child.getChildren()) {
1050
+ if ($isTableCellNode(cell)) {
1051
+ align.push(mapFormatTypeToAlign(cell.getFormatType()));
1052
+ }
1053
+ }
1054
+ isFirstRow = false;
1055
+ }
1056
+ }
1057
+ }
1058
+ return {
1059
+ type: 'table',
1060
+ align,
1061
+ children: rows,
1062
+ };
1063
+ }
1064
+ function convertTableRowNode(node) {
1065
+ const cells = [];
1066
+ for (const child of node.getChildren()) {
1067
+ if ($isTableCellNode(child)) {
1068
+ cells.push(convertTableCellNode(child));
1069
+ }
1070
+ }
1071
+ return {
1072
+ type: 'tableRow',
1073
+ children: cells,
1074
+ };
1075
+ }
1076
+ function convertTableCellNode(node) {
1077
+ const children = [];
1078
+ for (const child of node.getChildren()) {
1079
+ if ($isParagraphNode(child)) {
1080
+ children.push(...convertInlineChildren(child));
1081
+ }
1082
+ }
1083
+ return {
1084
+ type: 'tableCell',
1085
+ children: children.length > 0 ? children : [{ type: 'text', value: '' }],
1086
+ };
1087
+ }
1088
+ function convertImageNode(node) {
1089
+ const width = node.getWidth();
1090
+ const height = node.getHeight();
1091
+ // If image has custom dimensions, output as HTML img tag
1092
+ if (width || height) {
1093
+ let html = `<img src="${node.getSrc()}" alt="${node.getAlt()}"`;
1094
+ if (width) {
1095
+ html += ` width="${width}"`;
1096
+ }
1097
+ if (height) {
1098
+ html += ` height="${height}"`;
1099
+ }
1100
+ if (node.getTitle()) {
1101
+ html += ` title="${node.getTitle()}"`;
1102
+ }
1103
+ html += '>';
1104
+ return {
1105
+ type: 'html',
1106
+ value: html,
1107
+ };
1108
+ }
1109
+ // Standard markdown image syntax
1110
+ const image = {
1111
+ type: 'image',
1112
+ url: node.getSrc(),
1113
+ alt: node.getAlt(),
1114
+ title: node.getTitle(),
1115
+ };
1116
+ return {
1117
+ type: 'paragraph',
1118
+ children: [image],
1119
+ };
1120
+ }
1121
+ function convertEquationNode(node) {
1122
+ const equation = node.getEquation();
1123
+ const isInline = node.isInline();
1124
+ if (isInline) {
1125
+ // Inline equation: use inlineMath mdast node
1126
+ return {
1127
+ type: 'paragraph',
1128
+ children: [{ type: 'inlineMath', value: equation }],
1129
+ };
1130
+ }
1131
+ // Block equation: use math mdast node
1132
+ return {
1133
+ type: 'math',
1134
+ value: equation,
1135
+ };
1136
+ }
1137
+ function convertMermaidNode(node) {
1138
+ // Export as a fenced code block with lang="mermaid"
1139
+ return {
1140
+ type: 'code',
1141
+ lang: 'mermaid',
1142
+ value: node.getCode(),
1143
+ };
1144
+ }
1145
+ function convertC4Node(node) {
1146
+ // Export as a fenced code block with lang="c4"
1147
+ // Persist manual layout positions in the code fence meta field
1148
+ const manualLayout = node.getManualLayout();
1149
+ const meta = manualLayout && Object.keys(manualLayout.positions).length > 0
1150
+ ? '@layout ' + JSON.stringify(manualLayout)
1151
+ : undefined;
1152
+ return {
1153
+ type: 'code',
1154
+ lang: 'c4',
1155
+ meta: meta ?? null,
1156
+ value: node.getCode(),
1157
+ };
1158
+ }
1159
+ function convertFrontmatterNode(node) {
1160
+ // Export as yaml mdast node (will be serialized as ---\ncontent\n--- by mdast-util-frontmatter)
1161
+ return {
1162
+ type: 'yaml',
1163
+ value: node.getTextContent(),
1164
+ };
1165
+ }
1166
+ function convertCalloutNode(node) {
1167
+ const calloutType = node.getCalloutType().toUpperCase();
1168
+ const children = [];
1169
+ // Iterate through callout's children (paragraphs)
1170
+ let isFirst = true;
1171
+ for (const child of node.getChildren()) {
1172
+ if ($isParagraphNode(child)) {
1173
+ const inlineChildren = convertInlineChildren(child);
1174
+ if (isFirst) {
1175
+ // First paragraph gets the [!TYPE] prefix
1176
+ const prefix = { type: 'text', value: `[!${calloutType}]` };
1177
+ const contentChildren = [prefix];
1178
+ if (inlineChildren.length > 0) {
1179
+ // Add newline before content if there is content
1180
+ contentChildren.push({ type: 'text', value: '\n' });
1181
+ contentChildren.push(...inlineChildren);
1182
+ }
1183
+ children.push({
1184
+ type: 'paragraph',
1185
+ children: contentChildren,
1186
+ });
1187
+ isFirst = false;
1188
+ }
1189
+ else {
1190
+ // Subsequent paragraphs are added as-is
1191
+ children.push({
1192
+ type: 'paragraph',
1193
+ children: inlineChildren.length > 0 ? inlineChildren : [{ type: 'text', value: '' }],
1194
+ });
1195
+ }
1196
+ }
1197
+ }
1198
+ // Ensure at least one paragraph with the type marker
1199
+ if (children.length === 0) {
1200
+ children.push({
1201
+ type: 'paragraph',
1202
+ children: [{ type: 'text', value: `[!${calloutType}]` }],
1203
+ });
1204
+ }
1205
+ return {
1206
+ type: 'blockquote',
1207
+ children,
1208
+ };
1209
+ }
1210
+ function convertToggleContainerNode(node) {
1211
+ const result = [];
1212
+ let summaryText = '';
1213
+ const contentNodes = [];
1214
+ // Iterate through children to find title and content
1215
+ for (const child of node.getChildren()) {
1216
+ if ($isToggleTitleNode(child)) {
1217
+ // Extract text from title
1218
+ summaryText = child.getTextContent();
1219
+ }
1220
+ else if ($isToggleContentNode(child)) {
1221
+ // Convert content children to mdast
1222
+ const contentNode = child;
1223
+ for (const contentChild of contentNode.getChildren()) {
1224
+ const converted = convertLexicalNode(contentChild);
1225
+ contentNodes.push(...converted);
1226
+ }
1227
+ }
1228
+ }
1229
+ // Build the opening HTML tag
1230
+ const isOpen = node.getOpen();
1231
+ result.push({
1232
+ type: 'html',
1233
+ value: `<details${isOpen ? ' open' : ''}>\n<summary>${summaryText}</summary>`,
1234
+ });
1235
+ // Add all the content nodes (they will be serialized as markdown)
1236
+ result.push(...contentNodes);
1237
+ // Add the closing tag
1238
+ result.push({
1239
+ type: 'html',
1240
+ value: '\n</details>',
1241
+ });
1242
+ return result;
1243
+ }
1244
+ // Reconstructs a defList/defListTerm/defListDescription mdast node from a
1245
+ // DefinitionListNode. These node types aren't part of @types/mdast's core
1246
+ // Content union (mirroring the footnoteDefinition precedent above), so the
1247
+ // result is cast with `as unknown as Content` at the return site.
1248
+ function convertDefinitionListNode(node) {
1249
+ const children = [];
1250
+ for (const child of node.getChildren()) {
1251
+ if ($isDefinitionTermNode(child)) {
1252
+ const termChildren = convertInlineChildren(child);
1253
+ children.push({
1254
+ type: 'defListTerm',
1255
+ children: termChildren.length > 0 ? termChildren : [{ type: 'text', value: '' }],
1256
+ });
1257
+ }
1258
+ else if ($isDefinitionDescriptionNode(child)) {
1259
+ const descriptionChildren = [];
1260
+ for (const descriptionChild of child.getChildren()) {
1261
+ descriptionChildren.push(...convertLexicalNode(descriptionChild));
1262
+ }
1263
+ children.push({
1264
+ type: 'defListDescription',
1265
+ spread: false,
1266
+ children: descriptionChildren.length > 0
1267
+ ? descriptionChildren
1268
+ : [{ type: 'paragraph', children: [{ type: 'text', value: '' }] }],
1269
+ });
1270
+ }
1271
+ }
1272
+ return {
1273
+ type: 'defList',
1274
+ children,
1275
+ };
1276
+ }
1277
+ // The bold/italic/strikethrough bits of Lexical's TextFormat bitmask.
1278
+ // (bit 8 = underline, 16 = code, and higher bits aren't representable in markdown here.)
1279
+ const MERGEABLE_FORMAT_MASK = 1 | 2 | 4;
1280
+ // Returns the bold/italic/strikethrough bits a child can contribute to a shared
1281
+ // strong/emphasis/delete wrapper, or null if the child can't participate in one
1282
+ // (a node type with no format bitmask, like images/breaks).
1283
+ //
1284
+ // Code formatting is deliberately *not* a disqualifier (#973): a code-formatted
1285
+ // TextNode that also carries bold/italic/strikethrough belongs inside that
1286
+ // wrapper, as an `inlineCode` node. A code-formatted node carrying no other bit
1287
+ // yields 0, which still fails the `if (format)` guard at every call site, so
1288
+ // pure-inline-code behaviour is unchanged.
1289
+ function getMergeableFormat(child) {
1290
+ if ($isTextNode(child)) {
1291
+ return child.getFormat() & MERGEABLE_FORMAT_MASK;
1292
+ }
1293
+ if ($isEquationNode(child) || $isFootnoteNode(child)) {
1294
+ return child.getFormat() & MERGEABLE_FORMAT_MASK;
1295
+ }
1296
+ return null;
1297
+ }
1298
+ // Converts a single inline child using the same per-node rules as before this
1299
+ // issue's fix — no merging, no format wrapper applied here. Used both for
1300
+ // children that carry no format and for children whose format is already
1301
+ // handled by convertTextNode (a lone formatted TextNode).
1302
+ function convertSingleInlineChild(child) {
1303
+ if ($isTextNode(child)) {
1304
+ return convertTextNode(child);
1305
+ }
1306
+ else if ($isLineBreakNode(child)) {
1307
+ // Soft line break (from trailing double-spaces in markdown)
1308
+ return [{ type: 'break' }];
1309
+ }
1310
+ else if ($isLinkNode(child)) {
1311
+ return [convertLinkNode(child)];
1312
+ }
1313
+ else if ($isImageNode(child)) {
1314
+ // Handle ImageNode that ended up inside a paragraph (from markdown shortcut)
1315
+ // Convert to inline mdast image
1316
+ const image = {
1317
+ type: 'image',
1318
+ url: child.getSrc(),
1319
+ alt: child.getAlt(),
1320
+ title: child.getTitle(),
1321
+ };
1322
+ return [image];
1323
+ }
1324
+ else if ($isEquationNode(child)) {
1325
+ // Handle EquationNode that ended up inside a paragraph (from markdown shortcut)
1326
+ // Convert to inlineMath mdast node
1327
+ return [{ type: 'inlineMath', value: child.getEquation() }];
1328
+ }
1329
+ else if ($isFootnoteNode(child)) {
1330
+ // Footnote reference: convert back to footnoteReference mdast node
1331
+ return [{
1332
+ type: 'footnoteReference',
1333
+ identifier: child.getFootnoteId(),
1334
+ label: child.getFootnoteId(),
1335
+ }];
1336
+ }
1337
+ else if ($isHtmlNode(child)) {
1338
+ // Inline HTML preserved opaquely: convert back to a phrasing html mdast node
1339
+ return [{ type: 'html', value: child.getHtml() }];
1340
+ }
1341
+ return [];
1342
+ }
1343
+ // Flattens a list of leaf-level inline nodes into mdast content with no
1344
+ // format wrapping — the base case once every bold/italic/strikethrough bit
1345
+ // a run carries has been consumed by an enclosing wrapper.
1346
+ //
1347
+ // Consecutive code-formatted members accumulate and flush as a single
1348
+ // `inlineCode` node (#973), mirroring convertCodeRun: a mark splitting a
1349
+ // code span leaves adjacent code-format siblings, and mdast-util-to-markdown
1350
+ // never merges adjacent inlineCode nodes back together.
1351
+ function convertLeavesRaw(nodes) {
1352
+ const content = [];
1353
+ let codeBuffer = '';
1354
+ const flushCode = () => {
1355
+ if (codeBuffer === '')
1356
+ return;
1357
+ content.push({ type: 'inlineCode', value: codeBuffer });
1358
+ codeBuffer = '';
1359
+ };
1360
+ for (const node of nodes) {
1361
+ if ($isTextNode(node)) {
1362
+ const text = sentinelAugmentedText(node);
1363
+ if (text === '') {
1364
+ continue;
1365
+ }
1366
+ if (node.hasFormat('code')) {
1367
+ codeBuffer += text;
1368
+ continue;
1369
+ }
1370
+ flushCode();
1371
+ // An escaped character imported inside emphasis/strong (e.g.
1372
+ // `*text \_x\_*`) takes this merged-run export path even on a pure
1373
+ // no-edit round trip, so the force-escape hint must be read here too,
1374
+ // not just in convertTextNode's single-node path (#17). Gated by
1375
+ // isForceEscapableContent so a stale hint on a since-edited node can't
1376
+ // wrap unrelated typed content in a spurious backslash.
1377
+ const forceEscape = getForceEscapeFlag(node.getStyle() || '') && isForceEscapableContent(node.getTextContent());
1378
+ content.push({ type: 'text', value: text, data: forceEscape ? { _forceEscape: true } : undefined });
1379
+ }
1380
+ else if ($isEquationNode(node)) {
1381
+ flushCode();
1382
+ content.push({ type: 'inlineMath', value: node.getEquation() });
1383
+ }
1384
+ else if ($isFootnoteNode(node)) {
1385
+ flushCode();
1386
+ content.push({
1387
+ type: 'footnoteReference',
1388
+ identifier: node.getFootnoteId(),
1389
+ label: node.getFootnoteId(),
1390
+ });
1391
+ }
1392
+ }
1393
+ flushCode();
1394
+ return content;
1395
+ }
1396
+ // Scans `nodes` for the strong/emphasis marker style hints (`--md-strong-marker`
1397
+ // / `--md-emphasis-marker`) carried on text nodes, or the equivalent getters on
1398
+ // equation/footnote nodes — the first non-empty hint for each wins.
1399
+ //
1400
+ // Marker hints are read regardless of a text node's own code formatting, so a
1401
+ // run whose only styled member is code-formatted still keeps its `_`/`*`
1402
+ // rather than normalizing. Empty text nodes are skipped: Lexical creates
1403
+ // empty format-carrying TextNodes as caret placeholders when a format is
1404
+ // toggled before anything is typed, and one of those carrying a stale marker
1405
+ // style must not decide the whole run's marker.
1406
+ function resolveMarkers(nodes) {
1407
+ let strongMarker = null;
1408
+ let emphasisMarker = null;
1409
+ for (const node of nodes) {
1410
+ if ($isTextNode(node)) {
1411
+ const text = sentinelAugmentedText(node);
1412
+ if (text === '')
1413
+ continue;
1414
+ const style = node.getStyle() || '';
1415
+ strongMarker = strongMarker ?? getMarkdownMarker(style, '--md-strong-marker');
1416
+ emphasisMarker = emphasisMarker ?? getMarkdownMarker(style, '--md-emphasis-marker');
1417
+ }
1418
+ else if ($isEquationNode(node) || $isFootnoteNode(node)) {
1419
+ strongMarker = strongMarker ?? node.getStrongMarker();
1420
+ emphasisMarker = emphasisMarker ?? node.getEmphasisMarker();
1421
+ }
1422
+ }
1423
+ return { strongMarker, emphasisMarker };
1424
+ }
1425
+ // Builds nested strong/emphasis/delete wrappers from a run of siblings whose
1426
+ // bold/italic/strikethrough bits may *differ* but overlap — the shape
1427
+ // produced when importing e.g. `**bold and _nested italic_ inside**`, where
1428
+ // the enclosing bold survives as a shared bit across three siblings while
1429
+ // only the middle one also carries italic (see `convertInlineUnit`, which
1430
+ // selects the run this is called on).
1431
+ //
1432
+ // Recursively: group the run into maximal sub-runs whose formats share a
1433
+ // common non-zero bit (the running AND across the sub-run never drops to
1434
+ // zero), wrap each sub-run in that common format, and recurse on each
1435
+ // member's *residual* bits (its own format minus the common bits) to build
1436
+ // the nested content — bottoming out once a member's residual format is 0,
1437
+ // at which point it's flattened via `convertLeavesRaw`. For a uniform run
1438
+ // (every member's format literally equal, the classic case), this collapses
1439
+ // to a single group spanning the whole run with zero residual everywhere,
1440
+ // reproducing the old single-wrapper behavior exactly.
1441
+ function buildFormattedContent(members) {
1442
+ const result = [];
1443
+ let i = 0;
1444
+ while (i < members.length) {
1445
+ if (members[i].format === 0) {
1446
+ let j = i + 1;
1447
+ while (j < members.length && members[j].format === 0)
1448
+ j++;
1449
+ result.push(...convertLeavesRaw(members.slice(i, j).map((m) => m.node)));
1450
+ i = j;
1451
+ continue;
1452
+ }
1453
+ let common = members[i].format;
1454
+ let j = i + 1;
1455
+ while (j < members.length && (members[j].format & common) !== 0) {
1456
+ common &= members[j].format;
1457
+ j++;
1458
+ }
1459
+ const group = members.slice(i, j);
1460
+ const residual = group.map((m) => ({ node: m.node, format: m.format & ~common }));
1461
+ const inner = buildFormattedContent(residual);
1462
+ const { strongMarker, emphasisMarker } = resolveMarkers(group.map((m) => m.node));
1463
+ result.push(wrapWithFormat(inner, common, strongMarker, emphasisMarker));
1464
+ i = j;
1465
+ }
1466
+ return result;
1467
+ }
1468
+ // Nests content in strong/emphasis/delete wrappers matching convertTextNode's
1469
+ // order: bold innermost, then italic, then strikethrough outermost.
1470
+ function wrapWithFormat(content, format, strongMarker, emphasisMarker) {
1471
+ let result = content;
1472
+ if (format & 1) {
1473
+ // Bold
1474
+ result = [{
1475
+ type: 'strong',
1476
+ children: result,
1477
+ data: strongMarker ? { _strongMarker: strongMarker } : undefined,
1478
+ }];
1479
+ }
1480
+ if (format & 2) {
1481
+ // Italic
1482
+ result = [{
1483
+ type: 'emphasis',
1484
+ children: result,
1485
+ data: emphasisMarker ? { _emphasisMarker: emphasisMarker } : undefined,
1486
+ }];
1487
+ }
1488
+ if (format & 4) {
1489
+ // Strikethrough
1490
+ result = [{ type: 'delete', children: result }];
1491
+ }
1492
+ return result[0];
1493
+ }
1494
+ /**
1495
+ * `effectiveChildren`, additionally reporting which of the returned nodes came
1496
+ * out from inside a `MarkNode`.
1497
+ *
1498
+ * Flattening a mark away can split what was a single same-format `TextNode`
1499
+ * into several adjacent same-format siblings. Serialized per-node, that run
1500
+ * would emit one marker pair each (`**a****b**` instead of `**ab**`), so merely
1501
+ * annotating text would change the markdown — exactly what mark transparency
1502
+ * forbids. Merging the run fixes it, but merging unconditionally would also
1503
+ * change output for documents with no marks at all, which must stay
1504
+ * byte-identical. Knowing which nodes were mark members lets the merge fire
1505
+ * only on runs a mark actually touched.
1506
+ */
1507
+ function effectiveChildrenWithMarkMembership(node) {
1508
+ const nodes = [];
1509
+ const marked = new Set();
1510
+ const walk = (parent, insideMark) => {
1511
+ for (const child of parent.getChildren()) {
1512
+ if ($isMarkNode(child)) {
1513
+ walk(child, true);
1514
+ }
1515
+ else {
1516
+ nodes.push(child);
1517
+ if (insideMark)
1518
+ marked.add(child);
1519
+ }
1520
+ }
1521
+ };
1522
+ walk(node, false);
1523
+ return { nodes, marked };
1524
+ }
1525
+ function convertInlineChildren(node) {
1526
+ const { nodes, marked } = effectiveChildrenWithMarkMembership(node);
1527
+ return convertInlinePhrasingList(nodes, marked);
1528
+ }
1529
+ /**
1530
+ * Converts a flat list of already-mark-flattened inline Lexical nodes into
1531
+ * mdast phrasing content, merging maximal runs of same-format siblings that a
1532
+ * mark split apart. Shared by every call site that gathers a run of inline
1533
+ * content — paragraphs/headings/etc. (`convertInlineChildren`) and list-item
1534
+ * inline runs (`convertListItemNode`) — so both stay mark-transparent.
1535
+ *
1536
+ * Conversion proceeds in *units*: a merged run, or a single child. Any
1537
+ * annotate-mode token hoisted out of a construct within a unit is emitted as
1538
+ * its own sibling text node immediately before/after that unit's output, which
1539
+ * is precisely what puts it outside the unit's delimiters (see
1540
+ * `collectSentinelPlacements`). Off annotate mode the token lists are always
1541
+ * empty and this is the same conversion as before.
1542
+ */
1543
+ function convertInlinePhrasingList(kids, marked) {
1544
+ const children = [];
1545
+ let i = 0;
1546
+ while (i < kids.length) {
1547
+ const { content, next } = convertInlineUnit(kids, marked, i);
1548
+ children.push(...hoistedTokenNodes(kids, i, next, 'before'));
1549
+ children.push(...content);
1550
+ children.push(...hoistedTokenNodes(kids, i, next, 'after'));
1551
+ i = next;
1552
+ }
1553
+ return children;
1554
+ }
1555
+ /**
1556
+ * One emission unit of {@link convertInlinePhrasingList}, starting at `i`:
1557
+ * its converted content and the index the next unit starts at.
1558
+ */
1559
+ function convertInlineUnit(kids, marked, i) {
1560
+ const child = kids[i];
1561
+ // A mark splitting an inline-code span leaves adjacent code-format text
1562
+ // siblings, and mdast-util-to-markdown never merges adjacent inlineCode
1563
+ // nodes back together (each gets its own backtick pair) — so the run has to
1564
+ // be concatenated before conversion.
1565
+ //
1566
+ // Only *pure* inline code takes this path: a code-formatted node that also
1567
+ // carries a bold/italic/strikethrough bit must stay available to the
1568
+ // formatted-run path below, which nests it inside its wrapper (#973).
1569
+ if ($isTextNode(child) && child.hasFormat('code') && !getMergeableFormat(child)) {
1570
+ let j = i + 1;
1571
+ let runTouchesMark = marked.has(child);
1572
+ while (j < kids.length &&
1573
+ $isTextNode(kids[j]) &&
1574
+ kids[j].hasFormat('code') &&
1575
+ !getMergeableFormat(kids[j])) {
1576
+ if (marked.has(kids[j]))
1577
+ runTouchesMark = true;
1578
+ j++;
1579
+ }
1580
+ if (runTouchesMark && j > i + 1) {
1581
+ return { content: convertCodeRun(kids.slice(i, j)), next: j };
1582
+ }
1583
+ }
1584
+ const format = getMergeableFormat(child);
1585
+ if (format) {
1586
+ // Find the maximal run of consecutive siblings whose formats keep a
1587
+ // non-empty running intersection (`common`) — a strict generalization of
1588
+ // "sharing this exact format": a uniform run (every member's format
1589
+ // literally equal) is the special case where the intersection never
1590
+ // actually shrinks, but a run like bold/bold+italic/bold (produced by
1591
+ // `**bold and _nested italic_**`) also qualifies, with `common` settling
1592
+ // on the shared bold bit. `uniform` tracks whether the run was exact-match
1593
+ // throughout, so the merge condition below can keep today's narrow gates
1594
+ // for that case while unconditionally merging the newly-reachable
1595
+ // non-uniform (genuinely nested) case — see `buildFormattedContent`,
1596
+ // which recovers the nested wrapper structure from each member's residual
1597
+ // (non-common) bits.
1598
+ const isCodeMember = (node) => $isTextNode(node) && node.hasFormat('code');
1599
+ let j = i + 1;
1600
+ let common = format;
1601
+ let uniform = true;
1602
+ let hasNonTextMember = !$isTextNode(child);
1603
+ let hasCodeMember = isCodeMember(child);
1604
+ let runTouchesMark = marked.has(child);
1605
+ while (j < kids.length) {
1606
+ const nextFormat = getMergeableFormat(kids[j]);
1607
+ if (nextFormat === null || (nextFormat & common) === 0)
1608
+ break;
1609
+ if (nextFormat !== format)
1610
+ uniform = false;
1611
+ common &= nextFormat;
1612
+ if (!$isTextNode(kids[j])) {
1613
+ hasNonTextMember = true;
1614
+ }
1615
+ if (isCodeMember(kids[j]))
1616
+ hasCodeMember = true;
1617
+ if (marked.has(kids[j]))
1618
+ runTouchesMark = true;
1619
+ j++;
1620
+ }
1621
+ // The `hasCodeMember` disjunct is Liminis-specific and has no counterpart in
1622
+ // Zusammen's reference fix, whose gate merges any multi-member run
1623
+ // unconditionally. Liminis narrowed the gate to `runTouchesMark` (#970/#977)
1624
+ // so mark-free documents keep byte-identical output — but that narrowing
1625
+ // would send a mark-free `**`code` more text**` down the per-node path,
1626
+ // emitting two adjacent `strong` nodes that mdast-util-to-markdown will not
1627
+ // join (`**`code`**** more text**`). Do not "simplify" this away.
1628
+ //
1629
+ // Safe under FR-009: a multi-member uniform run containing a code member
1630
+ // could not form before this issue, because getMergeableFormat returned
1631
+ // null for code nodes and terminated the scan.
1632
+ //
1633
+ // `!uniform` merges unconditionally (#16): the differing-but-overlapping
1634
+ // run shape it covers was never reachable before this fix (the old scan
1635
+ // always stopped at the first unequal sibling), so no existing fixture can
1636
+ // depend on the old (broken) un-merged behavior for it.
1637
+ if (hasNonTextMember || (hasCodeMember && j > i + 1) || (runTouchesMark && j > i + 1) || !uniform) {
1638
+ const members = kids.slice(i, j).map((node) => ({ node, format: getMergeableFormat(node) ?? 0 }));
1639
+ return { content: buildFormattedContent(members), next: j };
1640
+ }
1641
+ }
1642
+ return { content: convertSingleInlineChild(child), next: i + 1 };
1643
+ }
1644
+ /** Concatenates a run of inline-code TextNodes into a single mdast inlineCode node. */
1645
+ function convertCodeRun(runChildren) {
1646
+ let text = '';
1647
+ for (const child of runChildren) {
1648
+ text += sentinelAugmentedText(child);
1649
+ }
1650
+ if (text === '')
1651
+ return [];
1652
+ return [{ type: 'inlineCode', value: text }];
1653
+ }
1654
+ function convertTextNode(node) {
1655
+ const text = sentinelAugmentedText(node);
1656
+ const format = node.getFormat();
1657
+ const style = node.getStyle() || '';
1658
+ const emphasisMarker = getMarkdownMarker(style, '--md-emphasis-marker');
1659
+ const strongMarker = getMarkdownMarker(style, '--md-strong-marker');
1660
+ // Gated by isForceEscapableContent so a stale hint on a since-edited node
1661
+ // can't wrap unrelated typed content in a spurious backslash (#17).
1662
+ const forceEscape = getForceEscapeFlag(style) && isForceEscapableContent(node.getTextContent());
1663
+ if (text === '') {
1664
+ return [];
1665
+ }
1666
+ // The base node is chosen first, then wrapped — a code-formatted node that
1667
+ // also carries bold/italic/strikethrough keeps that wrapper (#973), where
1668
+ // previously a trailing `format & 16` short-circuit discarded it.
1669
+ // `_forceEscape` only applies to the plain-text base (#17): code content is
1670
+ // emitted verbatim and never escaped, so the hint is meaningless there.
1671
+ let result = format & 16
1672
+ ? { type: 'inlineCode', value: text }
1673
+ : { type: 'text', value: text, data: forceEscape ? { _forceEscape: true } : undefined };
1674
+ // Apply formatting
1675
+ if (format & 1) {
1676
+ // Bold
1677
+ result = {
1678
+ type: 'strong',
1679
+ children: [result],
1680
+ data: strongMarker ? { _strongMarker: strongMarker } : undefined,
1681
+ };
1682
+ }
1683
+ if (format & 2) {
1684
+ // Italic
1685
+ result = {
1686
+ type: 'emphasis',
1687
+ children: [result],
1688
+ data: emphasisMarker ? { _emphasisMarker: emphasisMarker } : undefined,
1689
+ };
1690
+ }
1691
+ if (format & 4) {
1692
+ // Strikethrough
1693
+ result = { type: 'delete', children: [result] };
1694
+ }
1695
+ return [result];
1696
+ }
1697
+ // Check if a URL looks like a wiki-link (relative .md path, directory, or anchor, no protocol)
1698
+ function isWikiLinkUrl(url) {
1699
+ // Has protocol = not a wiki-link
1700
+ if (url.includes('://') || url.startsWith('mailto:')) {
1701
+ return false;
1702
+ }
1703
+ // Anchor-only link = wiki-link
1704
+ if (url.startsWith('#')) {
1705
+ return true;
1706
+ }
1707
+ // Directory link (trailing slash) = wiki-link
1708
+ // e.g., "entities/teams/" resolves to entities/teams/index.md at navigation time
1709
+ if (url.endsWith('/')) {
1710
+ return true;
1711
+ }
1712
+ // Contains .md (possibly with anchor after) = likely a wiki-link
1713
+ if (url.includes('.md')) {
1714
+ return true;
1715
+ }
1716
+ return false;
1717
+ }
1718
+ /**
1719
+ * Format an alias string with markdown format markers based on text format bitmask.
1720
+ *
1721
+ * Format bitmask values:
1722
+ * - 1: bold
1723
+ * - 2: italic
1724
+ * - 4: strikethrough
1725
+ *
1726
+ * Order: strikethrough wraps bold wraps italic wraps text
1727
+ * e.g., format=7 (all) produces ~~**_text_**~~
1728
+ */
1729
+ export function formatAliasWithMarkers(text, format) {
1730
+ if (format === 0 || !text) {
1731
+ return text;
1732
+ }
1733
+ let result = text;
1734
+ // Apply italic first (innermost)
1735
+ if (format & 2) {
1736
+ result = `*${result}*`;
1737
+ }
1738
+ // Then bold
1739
+ if (format & 1) {
1740
+ result = `**${result}**`;
1741
+ }
1742
+ // Then strikethrough (outermost)
1743
+ if (format & 4) {
1744
+ result = `~~${result}~~`;
1745
+ }
1746
+ return result;
1747
+ }
1748
+ function convertLinkNode(node) {
1749
+ const linkNode = node;
1750
+ const url = linkNode.getURL();
1751
+ const children = [];
1752
+ // Mark-transparent: this loop silently drops any child it doesn't recognize,
1753
+ // so an unflattened MarkNode over the link's text would delete that text from
1754
+ // the export entirely (`[note](...)` becoming `[](...)`).
1755
+ //
1756
+ // TextNodes are buffered rather than converted one at a time, then flushed
1757
+ // through the shared run-merging path: flattening a mark away can split one
1758
+ // formatted TextNode into several same-format siblings, and converting those
1759
+ // independently emits a delimiter pair each — `[**abc def**]` becoming
1760
+ // `[**abc**** def**]` once an annotation covers only "abc". Same reason
1761
+ // convertListItemNode buffers.
1762
+ const { nodes: linkChildren, marked } = effectiveChildrenWithMarkMembership(node);
1763
+ let textRun = [];
1764
+ const flushTextRun = () => {
1765
+ if (textRun.length > 0) {
1766
+ children.push(...convertInlinePhrasingList(textRun, marked));
1767
+ textRun = [];
1768
+ }
1769
+ };
1770
+ for (const child of linkChildren) {
1771
+ if ($isTextNode(child)) {
1772
+ textRun.push(child);
1773
+ continue;
1774
+ }
1775
+ flushTextRun();
1776
+ if ($isImageNode(child)) {
1777
+ // Image nested inside a link (the "badge" pattern), e.g. a CI status badge
1778
+ children.push({
1779
+ type: 'image',
1780
+ url: child.getSrc(),
1781
+ alt: child.getAlt(),
1782
+ title: child.getTitle(),
1783
+ });
1784
+ }
1785
+ else if ($isLineBreakNode(child)) {
1786
+ children.push({ type: 'break' });
1787
+ }
1788
+ else if ($isEquationNode(child)) {
1789
+ children.push({ type: 'inlineMath', value: child.getEquation() });
1790
+ }
1791
+ else if ($isFootnoteNode(child)) {
1792
+ children.push({
1793
+ type: 'footnoteReference',
1794
+ identifier: child.getFootnoteId(),
1795
+ label: child.getFootnoteId(),
1796
+ });
1797
+ }
1798
+ else if ($isHtmlNode(child)) {
1799
+ children.push({ type: 'html', value: child.getHtml() });
1800
+ }
1801
+ }
1802
+ flushTextRun();
1803
+ // Check if this should be a wiki-link. A title is a strong signal that the
1804
+ // author deliberately used standard markdown link syntax — wiki-link syntax
1805
+ // has no title slot, so promoting a titled link would silently drop it.
1806
+ // `wikiLinkPromotionMode` (liminis#951) additionally lets a host opt out of
1807
+ // promoting an *ordinary* link whose URL merely looks wiki-link-shaped. It
1808
+ // never suppresses a link that was genuine wiki-link syntax on import
1809
+ // (`getWikiLinkOrigin()`) — an opted-out host must not corrupt a document's
1810
+ // existing `[[...]]` links, only stop creating new ones.
1811
+ const isGenuineWikiLink = linkNode.getWikiLinkOrigin?.() ?? false;
1812
+ if ((wikiLinkPromotionMode === 'promote' || isGenuineWikiLink) && isWikiLinkUrl(url) && !linkNode.getTitle()) {
1813
+ // Convert URL back to wiki-link target
1814
+ let target;
1815
+ if (url.startsWith('#')) {
1816
+ // Anchor-only: #anchor → #anchor
1817
+ target = url;
1818
+ }
1819
+ else if (url.includes('.md#')) {
1820
+ // Path with anchor: page.md#anchor → page#anchor
1821
+ target = url.replace('.md#', '#');
1822
+ }
1823
+ else if (url.endsWith('.md')) {
1824
+ // Simple path: page.md → page
1825
+ target = url.slice(0, -3);
1826
+ }
1827
+ else {
1828
+ // Fallback: use URL as-is
1829
+ target = url;
1830
+ }
1831
+ // Extract alias and formatting from children
1832
+ // Use full text content from all children to avoid data loss with mixed-format aliases
1833
+ const displayText = node.getTextContent();
1834
+ // Annotate mode (`setAnnotateTarget`) brackets a live mark's own text with
1835
+ // sentinel tokens so `locateLiveMarkdownRange` can recover the mark's real
1836
+ // raw-markdown range. `getTextContent()` above flattens through MarkNode
1837
+ // but never calls `sentinelAugmentedText`, so an annotation anchored inside
1838
+ // a wiki-link's alias emitted no tokens at all — capture then found none
1839
+ // and silently declined (review finding, @handarbeit-pruefer). Note this
1840
+ // reaches further than hand-written `[[…|…]]`: a plain `[text](page.md)`
1841
+ // link is promoted to wiki-link syntax by the branch above, so it was
1842
+ // affected too.
1843
+ //
1844
+ // The alias is the only slot in wiki-link syntax that can carry the
1845
+ // tokens, so they go there — but every *decision* below (alias-or-not,
1846
+ // representative format) still reads the sentinel-free `displayText`. That
1847
+ // matters: annotate mode must not turn a `[[page]]` into a `[[page|…]]`,
1848
+ // because `locateLiveMarkdownRange`'s offset math requires everything
1849
+ // outside the bracketed span to stay byte-identical to a plain export.
1850
+ //
1851
+ // Concatenating the flattened children must reproduce `displayText`
1852
+ // exactly before the augmented form is trusted; otherwise we emit the
1853
+ // plain text, the same "decline rather than mis-place" trade the rest of
1854
+ // this pathway makes.
1855
+ //
1856
+ // Known gap: a wiki-link with no alias (`[[the quick brown fox]]`) has
1857
+ // nowhere to put the tokens without inventing an alias, which would be
1858
+ // exactly the structural change ruled out above. An annotation anchored
1859
+ // there still declines to capture.
1860
+ let aliasText = displayText;
1861
+ if (linkChildren.map((child) => child.getTextContent()).join('') === displayText) {
1862
+ const augmented = linkChildren
1863
+ .map((child) => ($isTextNode(child) ? sentinelAugmentedText(child) : child.getTextContent()))
1864
+ .join('');
1865
+ if (augmented !== displayText)
1866
+ aliasText = augmented;
1867
+ }
1868
+ let textFormat = 0;
1869
+ // Use the format of the first TextNode child as the representative format.
1870
+ // Reuses the mark-flattened `linkChildren` from the top of this function
1871
+ // (`effectiveChildrenWithMarkMembership`'s `nodes` is exactly
1872
+ // `effectiveChildren`), so an annotation over the link's text doesn't hide
1873
+ // the representative TextNode behind a MarkNode wrapper.
1874
+ if (linkChildren.length > 0 && $isTextNode(linkChildren[0])) {
1875
+ textFormat = linkChildren[0].getFormat();
1876
+ }
1877
+ const hasAlias = displayText && displayText !== target;
1878
+ const aliasState = node.getWikiAliasState?.() ?? null;
1879
+ const data = {};
1880
+ if (hasAlias) {
1881
+ // Format the alias with markers if the text has formatting
1882
+ data.alias = formatAliasWithMarkers(aliasText, textFormat);
1883
+ }
1884
+ else if (textFormat !== 0 && displayText) {
1885
+ // No alias (displayText === target) but text is formatted
1886
+ // We need to create an alias to preserve the formatting
1887
+ data.alias = formatAliasWithMarkers(aliasText, textFormat);
1888
+ }
1889
+ else if (aliasState === 'empty') {
1890
+ data._emptyAlias = true;
1891
+ }
1892
+ else {
1893
+ data._noAlias = true;
1894
+ }
1895
+ return {
1896
+ type: 'wikiLink',
1897
+ value: target,
1898
+ data,
1899
+ };
1900
+ }
1901
+ return {
1902
+ type: 'link',
1903
+ url,
1904
+ title: linkNode.getTitle(),
1905
+ children: children.length > 0 ? children : [{ type: 'text', value: '' }],
1906
+ };
1907
+ }
1908
+ function getMarkdownMarker(style, prop) {
1909
+ const match = new RegExp(`${prop}\\s*:\\s*([_*])`).exec(style);
1910
+ return match ? match[1] : null;
1911
+ }
1912
+ // Reads the `--md-force-escape` style hint set by `setForceEscape` in
1913
+ // `mdastToLexical.ts` (#17) — marks a single-character TextNode whose source
1914
+ // backslash escape must be restored verbatim at stringify time.
1915
+ function getForceEscapeFlag(style) {
1916
+ return /--md-force-escape\s*:\s*1/.test(style);
1917
+ }
1918
+ // The same seven-character set `splitTextNodeEscapes` (`parse.ts`) tags at
1919
+ // import time. Mirrored here (rather than imported) because parse-time and
1920
+ // export-time concerns are otherwise kept independent in this codebase.
1921
+ const FORCE_ESCAPE_CHARS = new Set(['*', '_', '`', '[', ']', '#', '\\']);
1922
+ // Guards against a stale `--md-force-escape` style hint corrupting freshly
1923
+ // typed content (#17). The style hint is set on a TextNode that, at import
1924
+ // time, is guaranteed to hold exactly one force-escaped character — but
1925
+ // Lexical's own reconciliation can extend that same TextNode's text (and
1926
+ // keep its style) when the user types adjacent to it during live editing,
1927
+ // which would otherwise carry the hint onto arbitrary new characters and
1928
+ // wrap them in a spurious, meaning-changing backslash at stringify time
1929
+ // (e.g. typing "a" next to an escaped "_" must never emit "\a"). Requiring
1930
+ // every character of the node's live text content to itself be one of the
1931
+ // seven force-escapable characters keeps the hint's effect scoped to
1932
+ // content it can actually still describe; anything else safely falls back
1933
+ // to ordinary (unescaped) text, at worst re-losing the original escape
1934
+ // rather than corrupting adjacent content.
1935
+ function isForceEscapableContent(text) {
1936
+ return text.length > 0 && [...text].every((ch) => FORCE_ESCAPE_CHARS.has(ch));
1937
+ }