@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.
- package/LICENSE +34 -0
- package/README.md +376 -0
- package/dist/annotations/anchor-align.d.ts +50 -0
- package/dist/annotations/anchor-align.js +74 -0
- package/dist/annotations/anchor-model.d.ts +81 -0
- package/dist/annotations/anchor-model.js +80 -0
- package/dist/annotations/anchor-resolver.d.ts +92 -0
- package/dist/annotations/anchor-resolver.js +231 -0
- package/dist/annotations/block-structure.d.ts +65 -0
- package/dist/annotations/block-structure.js +113 -0
- package/dist/annotations/types.d.ts +159 -0
- package/dist/annotations/types.js +50 -0
- package/dist/annotations.d.ts +25 -0
- package/dist/annotations.js +27 -0
- package/dist/app/App.d.ts +64 -0
- package/dist/app/App.js +180 -0
- package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
- package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
- package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
- package/dist/app/editor/AnchorScrollPlugin.js +134 -0
- package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
- package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
- package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
- package/dist/app/editor/AnnotationPlugin.js +135 -0
- package/dist/app/editor/AnnotationSurface.d.ts +34 -0
- package/dist/app/editor/AnnotationSurface.js +115 -0
- package/dist/app/editor/AssetContext.d.ts +20 -0
- package/dist/app/editor/AssetContext.js +81 -0
- package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
- package/dist/app/editor/BlockClickPlugin.js +126 -0
- package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
- package/dist/app/editor/CodeBlockPlugin.js +219 -0
- package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
- package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
- package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
- package/dist/app/editor/DragHandlePlugin.js +300 -0
- package/dist/app/editor/Editor.d.ts +84 -0
- package/dist/app/editor/Editor.js +539 -0
- package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
- package/dist/app/editor/FrontmatterPlugin.js +301 -0
- package/dist/app/editor/ImageModal.d.ts +13 -0
- package/dist/app/editor/ImageModal.js +267 -0
- package/dist/app/editor/ImagePlugin.d.ts +3 -0
- package/dist/app/editor/ImagePlugin.js +187 -0
- package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
- package/dist/app/editor/LinkClickPlugin.js +90 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
- package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
- package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
- package/dist/app/editor/SearchPlugin.d.ts +1 -0
- package/dist/app/editor/SearchPlugin.js +379 -0
- package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
- package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
- package/dist/app/editor/SlashMenu.d.ts +11 -0
- package/dist/app/editor/SlashMenu.js +494 -0
- package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
- package/dist/app/editor/SlashMenuPlugin.js +168 -0
- package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
- package/dist/app/editor/TableActionsPlugin.js +398 -0
- package/dist/app/editor/TogglePlugin.d.ts +1 -0
- package/dist/app/editor/TogglePlugin.js +159 -0
- package/dist/app/editor/Toolbar.d.ts +10 -0
- package/dist/app/editor/Toolbar.js +263 -0
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
- package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
- package/dist/app/editor/annotation-marks.d.ts +177 -0
- package/dist/app/editor/annotation-marks.js +750 -0
- package/dist/app/editor/annotationCommands.d.ts +13 -0
- package/dist/app/editor/annotationCommands.js +11 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
- package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
- package/dist/app/editor/c4/edge-clipping.js +139 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
- package/dist/app/editor/c4/layout.d.ts +25 -0
- package/dist/app/editor/c4/layout.js +839 -0
- package/dist/app/editor/c4/parser.d.ts +19 -0
- package/dist/app/editor/c4/parser.js +410 -0
- package/dist/app/editor/c4/render-to-string.d.ts +24 -0
- package/dist/app/editor/c4/render-to-string.js +34 -0
- package/dist/app/editor/c4/renderer.d.ts +64 -0
- package/dist/app/editor/c4/renderer.js +569 -0
- package/dist/app/editor/c4/types.d.ts +203 -0
- package/dist/app/editor/c4/types.js +43 -0
- package/dist/app/editor/correction-yaml.d.ts +10 -0
- package/dist/app/editor/correction-yaml.js +40 -0
- package/dist/app/editor/editorNodes.d.ts +9 -0
- package/dist/app/editor/editorNodes.js +36 -0
- package/dist/app/editor/index.d.ts +1 -0
- package/dist/app/editor/index.js +1 -0
- package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
- package/dist/app/editor/mark-overlap-resolver.js +45 -0
- package/dist/app/editor/nodes/C4Component.d.ts +14 -0
- package/dist/app/editor/nodes/C4Component.js +284 -0
- package/dist/app/editor/nodes/C4Node.d.ts +32 -0
- package/dist/app/editor/nodes/C4Node.js +112 -0
- package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
- package/dist/app/editor/nodes/CalloutNode.js +94 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
- package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
- package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
- package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
- package/dist/app/editor/nodes/CustomListNode.js +54 -0
- package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
- package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
- package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
- package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
- package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
- package/dist/app/editor/nodes/EquationComponent.js +130 -0
- package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
- package/dist/app/editor/nodes/EquationNode.js +179 -0
- package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
- package/dist/app/editor/nodes/FootnoteNode.js +151 -0
- package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
- package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
- package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
- package/dist/app/editor/nodes/HtmlNode.js +121 -0
- package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
- package/dist/app/editor/nodes/ImageComponent.js +228 -0
- package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
- package/dist/app/editor/nodes/ImageNode.js +183 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
- package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
- package/dist/app/editor/nodes/MermaidComponent.js +216 -0
- package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
- package/dist/app/editor/nodes/MermaidNode.js +96 -0
- package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
- package/dist/app/editor/nodes/ToggleNode.js +237 -0
- package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
- package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
- package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
- package/dist/app/editor/nodes/diagram-utils.js +29 -0
- package/dist/app/editor/nodes/index.d.ts +30 -0
- package/dist/app/editor/nodes/index.js +17 -0
- package/dist/app/editor/utils.d.ts +2 -0
- package/dist/app/editor/utils.js +17 -0
- package/dist/app/mapper/index.d.ts +3 -0
- package/dist/app/mapper/index.js +2 -0
- package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
- package/dist/app/mapper/lexicalToMdast.js +1937 -0
- package/dist/app/mapper/mdastToLexical.d.ts +31 -0
- package/dist/app/mapper/mdastToLexical.js +1075 -0
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +8 -0
- package/dist/headless.d.ts +40 -0
- package/dist/headless.js +45 -0
- package/dist/host/context.d.ts +20 -0
- package/dist/host/context.js +24 -0
- package/dist/host/defaults.d.ts +21 -0
- package/dist/host/defaults.js +47 -0
- package/dist/host/messages.d.ts +26 -0
- package/dist/host/messages.js +47 -0
- package/dist/host/types.d.ts +75 -0
- package/dist/host/types.js +13 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +51 -0
- package/dist/markdown/annotate-sentinels.d.ts +42 -0
- package/dist/markdown/annotate-sentinels.js +86 -0
- package/dist/markdown/parse.d.ts +56 -0
- package/dist/markdown/parse.js +516 -0
- package/dist/markdown/stringify.d.ts +7 -0
- package/dist/markdown/stringify.js +439 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
- package/dist/markdown.d.ts +28 -0
- package/dist/markdown.js +44 -0
- package/dist/mathjax-config.d.ts +89 -0
- package/dist/mathjax-config.js +133 -0
- package/dist/nodes.d.ts +34 -0
- package/dist/nodes.js +34 -0
- package/dist/stores/correctionStore.d.ts +15 -0
- package/dist/stores/correctionStore.js +12 -0
- package/dist/stores/editorStore.d.ts +16 -0
- package/dist/stores/editorStore.js +27 -0
- package/dist/styles.css +2477 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.js +91 -0
- package/dist/utils/file-types.d.ts +10 -0
- package/dist/utils/file-types.js +46 -0
- package/docs/annotations.md +134 -0
- package/docs/decisions/README.md +72 -0
- package/docs/decisions/adr-002.md +27 -0
- package/docs/decisions/adr-007.md +27 -0
- package/docs/decisions/adr-010.md +26 -0
- package/docs/decisions/adr-024.md +124 -0
- package/docs/decisions/adr-024b.md +0 -0
- package/docs/decisions/adr-025.md +103 -0
- package/docs/decisions/adr-027.md +66 -0
- package/docs/decisions/adr-057.md +67 -0
- package/docs/decisions/adr-070.md +131 -0
- package/docs/decisions/adr-075.md +410 -0
- package/docs/decisions/adr-076.md +183 -0
- package/docs/decisions/adr-077.md +410 -0
- package/docs/decisions/adr-078.md +243 -0
- package/docs/decisions/adr-079.md +167 -0
- package/docs/decisions/adr-080.md +325 -0
- package/docs/decisions/adr-081.md +108 -0
- package/docs/decisions/adr-082.md +123 -0
- package/docs/decisions/adr-083.md +204 -0
- package/docs/decisions/adr-084.md +179 -0
- package/docs/editor-api.md +106 -0
- package/docs/markdown-pipeline.md +282 -0
- package/docs/provenance.md +106 -0
- package/docs/zusammen-editor-capability-map.md +232 -0
- 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
|
+
}
|