@liminis/editor 0.4.1 → 0.6.0
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/README.md +66 -1
- package/dist/app/editor/LinkClickPlugin.js +5 -1
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
- package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
- package/dist/app/editor/editorNodes.js +3 -1
- package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
- package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
- package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
- package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
- package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
- package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
- package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
- package/dist/app/editor/nodes/TransclusionNode.js +141 -0
- package/dist/app/editor/nodes/index.d.ts +4 -0
- package/dist/app/editor/nodes/index.js +2 -0
- package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
- package/dist/app/editor/nodes/transclusion-loading.js +29 -0
- package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
- package/dist/app/editor/nodes/transclusion-render.js +186 -0
- package/dist/app/mapper/lexicalToMdast.js +116 -22
- package/dist/app/mapper/mdastToLexical.js +76 -4
- package/dist/host/defaults.js +1 -0
- package/dist/host/messages.d.ts +7 -1
- package/dist/host/messages.js +3 -3
- package/dist/host/types.d.ts +14 -1
- package/dist/markdown/parse.js +535 -1
- package/dist/markdown/stringify.js +35 -10
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
- package/dist/styles.css +48 -0
- package/dist/types.d.ts +1 -0
- package/docs/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +323 -6
- package/package.json +5 -3
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, host-resolver-driven resolution and rendering for block transclusion
|
|
3
|
+
* (`![[file#^id]]`, #119). Split out from `TransclusionComponent.tsx` so the
|
|
4
|
+
* cycle/depth guard and the mdast->JSX mini renderer are unit-testable
|
|
5
|
+
* without mounting Lexical or React.
|
|
6
|
+
*
|
|
7
|
+
* Not a second `LexicalComposer`: transclusion is render-only (bidirectional
|
|
8
|
+
* editing of transcluded content is explicitly out of scope), so resolved
|
|
9
|
+
* block content is parsed once with the existing `parseMarkdown` and walked
|
|
10
|
+
* into plain React elements by a small dedicated renderer here, rather than
|
|
11
|
+
* mounting a second editable surface.
|
|
12
|
+
*/
|
|
13
|
+
import { createElement, Fragment } from 'react';
|
|
14
|
+
import { parseMarkdown } from '../../../markdown/parse.js';
|
|
15
|
+
/**
|
|
16
|
+
* Nested transclusion depth bound (FR-011). A Plan-stage numeric choice, not
|
|
17
|
+
* derived from anything structural — deep enough that legitimate nesting
|
|
18
|
+
* (a summary block quoting a handful of sub-tasks, one level each) never
|
|
19
|
+
* hits it, shallow enough that a missed-cycle edge case still terminates
|
|
20
|
+
* fast. Checked *before* the resolver call at each level, so a document
|
|
21
|
+
* that would exceed it never spends host I/O on content that gets discarded.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_TRANSCLUSION_DEPTH = 8;
|
|
24
|
+
function blockKey(file, blockId) {
|
|
25
|
+
return `${file}#^${blockId}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a `file#^blockId` reference to rendered content, guarding against
|
|
29
|
+
* cycles and unbounded nesting.
|
|
30
|
+
*
|
|
31
|
+
* `visitedPath` is the chain of `file#^blockId` keys already open on *this*
|
|
32
|
+
* branch of the resolution tree (ancestors, not a single global "already
|
|
33
|
+
* transcluded anywhere" set) — the same block transcluded from two unrelated
|
|
34
|
+
* sites in the same document must not falsely trip the cycle guard for the
|
|
35
|
+
* second site.
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveAndRenderTransclusion(file, blockId, resolver, visitedPath = []) {
|
|
38
|
+
const key = blockKey(file, blockId);
|
|
39
|
+
if (visitedPath.includes(key)) {
|
|
40
|
+
return { kind: 'circular' };
|
|
41
|
+
}
|
|
42
|
+
if (visitedPath.length >= MAX_TRANSCLUSION_DEPTH) {
|
|
43
|
+
return { kind: 'depth-exceeded' };
|
|
44
|
+
}
|
|
45
|
+
if (!resolver) {
|
|
46
|
+
return { kind: 'unresolved' };
|
|
47
|
+
}
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await resolver(file, blockId);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// FR-009: a resolver that throws is treated the same as one that
|
|
54
|
+
// couldn't resolve — never let a host's rejected promise escape as an
|
|
55
|
+
// unhandled error into the editor.
|
|
56
|
+
raw = null;
|
|
57
|
+
}
|
|
58
|
+
if (raw === null || raw === undefined || typeof raw !== 'string') {
|
|
59
|
+
// The `typeof` guard is load-bearing, not defensive padding: a resolver
|
|
60
|
+
// that violates its own `Promise<string | null>` contract (returns a
|
|
61
|
+
// number/object/etc.) must still degrade to "unresolved" rather than
|
|
62
|
+
// reaching `parseMarkdown` with a non-string and throwing.
|
|
63
|
+
return { kind: 'unresolved' };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const { root } = parseMarkdown(raw);
|
|
67
|
+
const nextVisitedPath = [...visitedPath, key];
|
|
68
|
+
const content = await renderNodes(root.children, resolver, nextVisitedPath);
|
|
69
|
+
return { kind: 'resolved', content: createElement(Fragment, null, ...content) };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Never let a parse/render failure on resolved content escape as an
|
|
73
|
+
// unhandled error into the editor — same "never throw" invariant as the
|
|
74
|
+
// resolver-rejection guard above.
|
|
75
|
+
return { kind: 'unresolved' };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Render a {@link TransclusionRenderState} to a React node, for both the
|
|
79
|
+
* top-level `TransclusionComponent` and a nested `wikiEmbed` inside
|
|
80
|
+
* resolved content — the two share the same visual vocabulary. */
|
|
81
|
+
export function renderTransclusionState(state) {
|
|
82
|
+
switch (state.kind) {
|
|
83
|
+
case 'resolved':
|
|
84
|
+
return createElement('span', { className: 'editor-transclusion-content' }, state.content);
|
|
85
|
+
case 'unresolved':
|
|
86
|
+
return createElement('span', { className: 'editor-transclusion-unresolved', title: 'This block could not be found.' }, 'Unresolved block reference');
|
|
87
|
+
case 'circular':
|
|
88
|
+
return createElement('span', { className: 'editor-transclusion-circular', title: 'This block transcludes itself.' }, 'Circular transclusion');
|
|
89
|
+
case 'depth-exceeded':
|
|
90
|
+
return createElement('span', { className: 'editor-transclusion-depth-exceeded', title: 'Transclusion nested too deeply.' }, 'Transclusion nested too deeply');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function renderNodes(nodes, resolver, visitedPath) {
|
|
94
|
+
const rendered = await Promise.all(nodes.map((node, index) => renderNode(node, resolver, visitedPath, index)));
|
|
95
|
+
return rendered;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Renders the subset of mdast node types the spec's own example (a checkbox
|
|
99
|
+
* action item, plain prose, a nested transclusion) needs, plus common inline
|
|
100
|
+
* formatting. Anything else falls back to plain extracted text — a
|
|
101
|
+
* deliberate v1 scope limit (see the Plan's "lightweight renderer" risk
|
|
102
|
+
* note), not a gap to silently paper over: this must never crash on
|
|
103
|
+
* arbitrary block content (FR-015 lets *any* `^id`-carrying block be a
|
|
104
|
+
* target, not just the checkbox shape the host emits today).
|
|
105
|
+
*/
|
|
106
|
+
async function renderNode(node, resolver, visitedPath, key) {
|
|
107
|
+
const n = node;
|
|
108
|
+
if (!n || typeof n.type !== 'string') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
switch (n.type) {
|
|
112
|
+
case 'root':
|
|
113
|
+
case 'paragraph': {
|
|
114
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
115
|
+
return createElement('span', { key, className: 'editor-transclusion-paragraph' }, ...children);
|
|
116
|
+
}
|
|
117
|
+
case 'text':
|
|
118
|
+
return n.value ?? '';
|
|
119
|
+
case 'strong': {
|
|
120
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
121
|
+
return createElement('strong', { key }, ...children);
|
|
122
|
+
}
|
|
123
|
+
case 'emphasis': {
|
|
124
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
125
|
+
return createElement('em', { key }, ...children);
|
|
126
|
+
}
|
|
127
|
+
case 'delete': {
|
|
128
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
129
|
+
return createElement('del', { key }, ...children);
|
|
130
|
+
}
|
|
131
|
+
case 'inlineCode':
|
|
132
|
+
return createElement('code', { key }, n.value ?? '');
|
|
133
|
+
case 'break':
|
|
134
|
+
return createElement('br', { key });
|
|
135
|
+
case 'heading': {
|
|
136
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
137
|
+
return createElement('strong', { key, className: 'editor-transclusion-heading' }, ...children);
|
|
138
|
+
}
|
|
139
|
+
case 'list': {
|
|
140
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
141
|
+
return createElement('span', { key, className: 'editor-transclusion-list' }, ...children);
|
|
142
|
+
}
|
|
143
|
+
case 'listItem': {
|
|
144
|
+
const children = await renderNodes(n.children ?? [], resolver, visitedPath);
|
|
145
|
+
const checked = n.checked;
|
|
146
|
+
if (checked === true || checked === false) {
|
|
147
|
+
return createElement('span', { key, className: 'editor-transclusion-list-item' }, createElement('input', { type: 'checkbox', checked, readOnly: true, disabled: true }), ' ', ...children);
|
|
148
|
+
}
|
|
149
|
+
return createElement('span', { key, className: 'editor-transclusion-list-item' }, '• ', ...children);
|
|
150
|
+
}
|
|
151
|
+
case 'wikiEmbed': {
|
|
152
|
+
const target = n.value ?? '';
|
|
153
|
+
const data = n.data;
|
|
154
|
+
const blockId = data?.blockId;
|
|
155
|
+
if (!blockId) {
|
|
156
|
+
// Never produced by parseMarkdown (FR-013 keeps a blockId-less node
|
|
157
|
+
// typed `wikiLink`), but a hand-built mdast tree from a resolver
|
|
158
|
+
// could still lack one — degrade to plain text rather than crash.
|
|
159
|
+
return extractPlainText(n);
|
|
160
|
+
}
|
|
161
|
+
const nested = await resolveAndRenderTransclusion(target, blockId, resolver, visitedPath);
|
|
162
|
+
return createElement('span', { key }, renderTransclusionState(nested));
|
|
163
|
+
}
|
|
164
|
+
case 'wikiLink': {
|
|
165
|
+
// A link-only reference inside transcluded content: render its label
|
|
166
|
+
// as inert text. Live navigation from inside a transclusion is out of
|
|
167
|
+
// scope (this is a read-only render, not a second editable surface).
|
|
168
|
+
const data = n.data;
|
|
169
|
+
return data?.alias || n.value || '';
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
return extractPlainText(n);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Generic fallback: recursively join every `.value` string found, so an
|
|
176
|
+
* unrecognized node type still shows *something* rather than nothing. */
|
|
177
|
+
function extractPlainText(node) {
|
|
178
|
+
const n = node;
|
|
179
|
+
if (!n)
|
|
180
|
+
return '';
|
|
181
|
+
if (typeof n.value === 'string')
|
|
182
|
+
return n.value;
|
|
183
|
+
if (Array.isArray(n.children))
|
|
184
|
+
return n.children.map(extractPlainText).join('');
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
@@ -5,7 +5,7 @@ import { $isCodeNode } from '@lexical/code';
|
|
|
5
5
|
import { $isLinkNode } from '@lexical/link';
|
|
6
6
|
import { $isMarkNode } from '@lexical/mark';
|
|
7
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';
|
|
8
|
+
import { $isHorizontalRuleNode, $isImageNode, $isCalloutNode, $isToggleContainerNode, $isToggleTitleNode, $isToggleContentNode, $isEquationNode, $isMermaidNode, $isC4Node, $isFrontmatterNode, $isFootnoteNode, $isBlockAnchorNode, $isCustomListNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, $isCustomListItemNode, $isHtmlNode, $isListItemParagraphBreakNode, $isTransclusionNode, } from '../editor/nodes/index.js';
|
|
9
9
|
import { SENTINEL_CLOSE_END, SENTINEL_CLOSE_START, SENTINEL_OPEN_END, SENTINEL_OPEN_START, stripAnnotateSentinels, } from '../../markdown/annotate-sentinels.js';
|
|
10
10
|
// Convert Lexical editor state to mdast tree
|
|
11
11
|
export function exportLexicalToMdast(editor, options = {}) {
|
|
@@ -231,16 +231,23 @@ function markPhrasingContext(mark) {
|
|
|
231
231
|
}
|
|
232
232
|
/**
|
|
233
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
|
|
235
|
-
* inline HTML. When one of these is a mark's
|
|
236
|
-
* that mark by construction, so the mark's
|
|
237
|
-
* whole construct rather than inside its
|
|
234
|
+
* text — a link, wiki link, image, inline equation, footnote reference,
|
|
235
|
+
* inline HTML, or transclusion embed (#119). When one of these is a mark's
|
|
236
|
+
* own child it is *wholly* inside that mark by construction, so the mark's
|
|
237
|
+
* boundary token belongs outside the whole construct rather than inside its
|
|
238
|
+
* text.
|
|
238
239
|
*
|
|
239
240
|
* Line breaks are excluded deliberately: they carry no syntax a boundary can
|
|
240
241
|
* fall inside, and today's leaf walk skips straight past them.
|
|
241
242
|
*/
|
|
242
243
|
function isHoistableConstruct(node) {
|
|
243
|
-
return $isLinkNode(node) ||
|
|
244
|
+
return ($isLinkNode(node) ||
|
|
245
|
+
$isImageNode(node) ||
|
|
246
|
+
$isEquationNode(node) ||
|
|
247
|
+
$isFootnoteNode(node) ||
|
|
248
|
+
$isBlockAnchorNode(node) ||
|
|
249
|
+
$isHtmlNode(node) ||
|
|
250
|
+
$isTransclusionNode(node));
|
|
244
251
|
}
|
|
245
252
|
/**
|
|
246
253
|
* Inclusive bounds of the maximal run of consecutive siblings in `flat` that
|
|
@@ -371,26 +378,32 @@ function canCarryHoistedTokens(parent) {
|
|
|
371
378
|
* The same question asked of the *construct* rather than its container, because
|
|
372
379
|
* one container routes its children unevenly.
|
|
373
380
|
*
|
|
374
|
-
* `convertListItemNode` sends only text runs, line breaks
|
|
375
|
-
* inline phrasing path; every other child goes to the
|
|
376
|
-
* has nowhere to put a phrasing token. Hoisting a
|
|
377
|
-
* would drop the token silently, and
|
|
378
|
-
* to find the mark at all.
|
|
381
|
+
* `convertListItemNode` sends only text runs, line breaks, links and block
|
|
382
|
+
* anchors through the inline phrasing path; every other child goes to the
|
|
383
|
+
* block dispatcher, which has nowhere to put a phrasing token. Hoisting a
|
|
384
|
+
* boundary onto one of those would drop the token silently, and
|
|
385
|
+
* `locateLiveMarkdownRange` would then fail to find the mark at all.
|
|
379
386
|
*
|
|
380
|
-
* No document reaches that state today, which is why
|
|
381
|
-
* than a fix: an image, inline equation, footnote
|
|
382
|
-
* inside a list item is *itself* block-promoted by
|
|
383
|
-
* does not survive a round trip inline (`- The
|
|
384
|
-
* three blocks) and any range over it is
|
|
385
|
-
* `locateLiveMarkdownRange`'s slice check. Repairing that
|
|
386
|
-
* pre-existing round-trip defect must not silently regress
|
|
387
|
-
* its side effect.
|
|
387
|
+
* No document reaches that state today for the remaining types, which is why
|
|
388
|
+
* this is a guard rather than a fix: an image, inline equation, footnote
|
|
389
|
+
* reference or inline HTML inside a list item is *itself* block-promoted by
|
|
390
|
+
* that same dispatcher, so it does not survive a round trip inline (`- The
|
|
391
|
+
* value $x^2$ matters` exports as three blocks) and any range over it is
|
|
392
|
+
* already rejected by `locateLiveMarkdownRange`'s slice check. Repairing that
|
|
393
|
+
* unrelated, pre-existing round-trip defect must not silently regress
|
|
394
|
+
* annotation ranges as its side effect.
|
|
395
|
+
*
|
|
396
|
+
* Block anchors are the one exception (#122): `- [ ] ... ^ULID` is the
|
|
397
|
+
* primary real-world shape this issue targets, so `convertListItemNode` keeps
|
|
398
|
+
* a `BlockAnchorNode` inline rather than letting it fall to the block
|
|
399
|
+
* dispatcher — see the `$isBlockAnchorNode` branch there — and a hoisted
|
|
400
|
+
* boundary onto one genuinely does reach the output.
|
|
388
401
|
*/
|
|
389
402
|
function hoistedTokenReachesOutput(node) {
|
|
390
403
|
let parent = node.getParent();
|
|
391
404
|
while (parent && $isMarkNode(parent))
|
|
392
405
|
parent = parent.getParent();
|
|
393
|
-
return $isListItemNode(parent) ? $isLinkNode(node) : true;
|
|
406
|
+
return $isListItemNode(parent) ? ($isLinkNode(node) || $isBlockAnchorNode(node)) : true;
|
|
394
407
|
}
|
|
395
408
|
/**
|
|
396
409
|
* The node a mark's boundary token should be emitted outside of, or null to
|
|
@@ -710,6 +723,9 @@ function convertFootnoteInlineChildren(labelNode) {
|
|
|
710
723
|
label: child.getFootnoteId(),
|
|
711
724
|
});
|
|
712
725
|
}
|
|
726
|
+
else if ($isBlockAnchorNode(child)) {
|
|
727
|
+
contentChildren.push({ type: 'blockAnchor', id: child.getId() });
|
|
728
|
+
}
|
|
713
729
|
contentChildren.push(...hoistedTokenNodesFor(child, 'after'));
|
|
714
730
|
restIndex++;
|
|
715
731
|
child = rest[restIndex] ?? null;
|
|
@@ -790,6 +806,14 @@ function convertLexicalNode(node) {
|
|
|
790
806
|
if ($isHtmlNode(node)) {
|
|
791
807
|
return [{ type: 'html', value: node.getHtml() }];
|
|
792
808
|
}
|
|
809
|
+
if ($isTransclusionNode(node)) {
|
|
810
|
+
// TransclusionNode is inline (#119) and is always constructed as a
|
|
811
|
+
// paragraph's child by mdastToLexical.ts; reached here only if it
|
|
812
|
+
// somehow ends up as a direct block-level child (e.g. a paste-driven
|
|
813
|
+
// DOM import) — wrap it in a paragraph rather than losing it, mirroring
|
|
814
|
+
// convertEquationNode's inline-equation branch above.
|
|
815
|
+
return [{ type: 'paragraph', children: [convertTransclusionNode(node)] }];
|
|
816
|
+
}
|
|
793
817
|
// Fallback: create paragraph
|
|
794
818
|
const paragraph = {
|
|
795
819
|
type: 'paragraph',
|
|
@@ -953,6 +977,30 @@ function convertListItemNode(node, _ordered, spread) {
|
|
|
953
977
|
// be emitted here too.
|
|
954
978
|
inlineChildren.push(...hoistedTokenNodesFor(child, 'before'), convertLinkNode(child), ...hoistedTokenNodesFor(child, 'after'));
|
|
955
979
|
}
|
|
980
|
+
else if ($isBlockAnchorNode(child)) {
|
|
981
|
+
// A block anchor sitting directly under a ListItemNode — the primary
|
|
982
|
+
// real-world shape this issue targets (`- [ ] ... ^ULID`, #122) — must
|
|
983
|
+
// stay inline rather than fall to the block dispatcher below: unlike
|
|
984
|
+
// image/equation/footnote/html (a documented, pre-existing gap this
|
|
985
|
+
// issue does not touch — see `hoistedTokenReachesOutput`'s docstring),
|
|
986
|
+
// a dropped anchor here would break the checkbox-action-item pattern
|
|
987
|
+
// the spec calls out as the primary usage.
|
|
988
|
+
//
|
|
989
|
+
// Unlike the $isLinkNode branch above, a link's format lives on its
|
|
990
|
+
// text children (applied on import via applyFormatToLinkChildren), but
|
|
991
|
+
// a BlockAnchorNode carries its own format bits directly (like
|
|
992
|
+
// FootnoteNode/EquationNode) — so they must be wrapped here explicitly,
|
|
993
|
+
// the same way the general inline path does via getMergeableFormat +
|
|
994
|
+
// buildFormattedContent, or a bold/italic badge would silently lose its
|
|
995
|
+
// markers on export.
|
|
996
|
+
flushTextRun();
|
|
997
|
+
const anchorMdast = { type: 'blockAnchor', id: child.getId() };
|
|
998
|
+
const format = getMergeableFormat(child) ?? 0;
|
|
999
|
+
const anchorContent = format
|
|
1000
|
+
? wrapWithFormat([anchorMdast], format, child.getStrongMarker(), child.getEmphasisMarker())
|
|
1001
|
+
: anchorMdast;
|
|
1002
|
+
inlineChildren.push(...hoistedTokenNodesFor(child, 'before'), anchorContent, ...hoistedTokenNodesFor(child, 'after'));
|
|
1003
|
+
}
|
|
956
1004
|
else {
|
|
957
1005
|
// Any other block-type child (ParagraphNode, CodeNode, TableNode,
|
|
958
1006
|
// QuoteNode, etc.) — flush accumulated inline text first, then delegate
|
|
@@ -1290,7 +1338,7 @@ function getMergeableFormat(child) {
|
|
|
1290
1338
|
if ($isTextNode(child)) {
|
|
1291
1339
|
return child.getFormat() & MERGEABLE_FORMAT_MASK;
|
|
1292
1340
|
}
|
|
1293
|
-
if ($isEquationNode(child) || $isFootnoteNode(child)) {
|
|
1341
|
+
if ($isEquationNode(child) || $isFootnoteNode(child) || $isBlockAnchorNode(child)) {
|
|
1294
1342
|
return child.getFormat() & MERGEABLE_FORMAT_MASK;
|
|
1295
1343
|
}
|
|
1296
1344
|
return null;
|
|
@@ -1334,12 +1382,44 @@ function convertSingleInlineChild(child) {
|
|
|
1334
1382
|
label: child.getFootnoteId(),
|
|
1335
1383
|
}];
|
|
1336
1384
|
}
|
|
1385
|
+
else if ($isBlockAnchorNode(child)) {
|
|
1386
|
+
// Block anchor badge (#122): convert back to a blockAnchor mdast node
|
|
1387
|
+
return [{ type: 'blockAnchor', id: child.getId() }];
|
|
1388
|
+
}
|
|
1337
1389
|
else if ($isHtmlNode(child)) {
|
|
1338
1390
|
// Inline HTML preserved opaquely: convert back to a phrasing html mdast node
|
|
1339
1391
|
return [{ type: 'html', value: child.getHtml() }];
|
|
1340
1392
|
}
|
|
1393
|
+
else if ($isTransclusionNode(child)) {
|
|
1394
|
+
// Transclusion embed (#119): convert back to a wikiEmbed mdast node
|
|
1395
|
+
return [convertTransclusionNode(child)];
|
|
1396
|
+
}
|
|
1341
1397
|
return [];
|
|
1342
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Convert a `TransclusionNode` back to a `wikiEmbed` mdast node.
|
|
1401
|
+
*
|
|
1402
|
+
* `alias`/`_emptyAlias` are carried purely for byte-identical round-trip
|
|
1403
|
+
* (FR-003) — read directly off the node's own explicit fields rather than
|
|
1404
|
+
* inferred from any rendered text, since an embed never displays its alias
|
|
1405
|
+
* (see `mdastToLexical.ts`'s `$createTransclusionNodeFromMdast` for the
|
|
1406
|
+
* matching import-side reasoning).
|
|
1407
|
+
*/
|
|
1408
|
+
function convertTransclusionNode(node) {
|
|
1409
|
+
const alias = node.getAlias();
|
|
1410
|
+
const data = { blockId: node.getBlockId() };
|
|
1411
|
+
if (alias) {
|
|
1412
|
+
data.alias = alias;
|
|
1413
|
+
}
|
|
1414
|
+
else if (node.getEmptyAlias()) {
|
|
1415
|
+
data._emptyAlias = true;
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
type: 'wikiEmbed',
|
|
1419
|
+
value: node.getFile(),
|
|
1420
|
+
data,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1343
1423
|
// Flattens a list of leaf-level inline nodes into mdast content with no
|
|
1344
1424
|
// format wrapping — the base case once every bold/italic/strikethrough bit
|
|
1345
1425
|
// a run carries has been consumed by an enclosing wrapper.
|
|
@@ -1389,6 +1469,10 @@ function convertLeavesRaw(nodes) {
|
|
|
1389
1469
|
label: node.getFootnoteId(),
|
|
1390
1470
|
});
|
|
1391
1471
|
}
|
|
1472
|
+
else if ($isBlockAnchorNode(node)) {
|
|
1473
|
+
flushCode();
|
|
1474
|
+
content.push({ type: 'blockAnchor', id: node.getId() });
|
|
1475
|
+
}
|
|
1392
1476
|
}
|
|
1393
1477
|
flushCode();
|
|
1394
1478
|
return content;
|
|
@@ -1415,7 +1499,7 @@ function resolveMarkers(nodes) {
|
|
|
1415
1499
|
strongMarker = strongMarker ?? getMarkdownMarker(style, '--md-strong-marker');
|
|
1416
1500
|
emphasisMarker = emphasisMarker ?? getMarkdownMarker(style, '--md-emphasis-marker');
|
|
1417
1501
|
}
|
|
1418
|
-
else if ($isEquationNode(node) || $isFootnoteNode(node)) {
|
|
1502
|
+
else if ($isEquationNode(node) || $isFootnoteNode(node) || $isBlockAnchorNode(node)) {
|
|
1419
1503
|
strongMarker = strongMarker ?? node.getStrongMarker();
|
|
1420
1504
|
emphasisMarker = emphasisMarker ?? node.getEmphasisMarker();
|
|
1421
1505
|
}
|
|
@@ -1795,6 +1879,9 @@ function convertLinkNode(node) {
|
|
|
1795
1879
|
label: child.getFootnoteId(),
|
|
1796
1880
|
});
|
|
1797
1881
|
}
|
|
1882
|
+
else if ($isBlockAnchorNode(child)) {
|
|
1883
|
+
children.push({ type: 'blockAnchor', id: child.getId() });
|
|
1884
|
+
}
|
|
1798
1885
|
else if ($isHtmlNode(child)) {
|
|
1799
1886
|
children.push({ type: 'html', value: child.getHtml() });
|
|
1800
1887
|
}
|
|
@@ -1892,6 +1979,13 @@ function convertLinkNode(node) {
|
|
|
1892
1979
|
else {
|
|
1893
1980
|
data._noAlias = true;
|
|
1894
1981
|
}
|
|
1982
|
+
// Block-scoped link (#119): carried as a field separate from `target`
|
|
1983
|
+
// (never folded into the value/URL string) — see CustomLinkNode's own
|
|
1984
|
+
// doc comment for why.
|
|
1985
|
+
const blockId = linkNode.getBlockId?.();
|
|
1986
|
+
if (blockId) {
|
|
1987
|
+
data.blockId = blockId;
|
|
1988
|
+
}
|
|
1895
1989
|
return {
|
|
1896
1990
|
type: 'wikiLink',
|
|
1897
1991
|
value: target,
|
|
@@ -2,7 +2,7 @@ import { $createParagraphNode, $createTextNode, $createLineBreakNode, $isLineBre
|
|
|
2
2
|
import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text';
|
|
3
3
|
import { $createCodeNode } from '@lexical/code';
|
|
4
4
|
import { $isLinkNode } from '@lexical/link';
|
|
5
|
-
import { $createHorizontalRuleNode, $createImageNode, $isImageNode, $createCalloutNode, $createToggleContainerNode, $createToggleTitleNode, $createToggleContentNode, $createEquationNode, $isEquationNode, $createFootnoteNode, $isFootnoteNode, $createHtmlNode, $isHtmlNode, $createMermaidNode, $createC4Node, $createFrontmatterNode, $createCustomLinkNode, $createCustomListNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $createCustomListItemNode, $createListItemParagraphBreakNode, } from '../editor/nodes/index.js';
|
|
5
|
+
import { $createHorizontalRuleNode, $createImageNode, $isImageNode, $createCalloutNode, $createToggleContainerNode, $createToggleTitleNode, $createToggleContentNode, $createEquationNode, $isEquationNode, $createFootnoteNode, $isFootnoteNode, $createBlockAnchorNode, $isBlockAnchorNode, $createHtmlNode, $isHtmlNode, $createMermaidNode, $createC4Node, $createFrontmatterNode, $createCustomLinkNode, $createCustomListNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $createCustomListItemNode, $createListItemParagraphBreakNode, $createTransclusionNode, } from '../editor/nodes/index.js';
|
|
6
6
|
import { parseFormattedAlias } from '../editor/MarkdownShortcutsPlugin.js';
|
|
7
7
|
import { $createTableNode, $createTableRowNode, $createTableCellNode, TableCellHeaderStates } from '@lexical/table';
|
|
8
8
|
import { getFileType } from '../../utils/file-types.js';
|
|
@@ -275,6 +275,9 @@ function convertBlockNode(node) {
|
|
|
275
275
|
const paragraph = $createParagraphNode();
|
|
276
276
|
const link = $createCustomLinkNode(url);
|
|
277
277
|
link.setWikiLinkOrigin(true);
|
|
278
|
+
if (wikiLink.data?.blockId) {
|
|
279
|
+
link.setBlockId(wikiLink.data.blockId);
|
|
280
|
+
}
|
|
278
281
|
// Parse format markers in alias (e.g., **bold**, *italic*, ~~strike~~)
|
|
279
282
|
const { text: plainText, formats } = parseFormattedAlias(rawDisplayText);
|
|
280
283
|
const textNode = $createTextNode(plainText);
|
|
@@ -285,6 +288,27 @@ function convertBlockNode(node) {
|
|
|
285
288
|
paragraph.append(link);
|
|
286
289
|
return [paragraph];
|
|
287
290
|
}
|
|
291
|
+
case 'wikiEmbed': {
|
|
292
|
+
// Transclusion/embed appearing at block level (shouldn't happen — a
|
|
293
|
+
// `wikiEmbed` is phrasing content, mirroring `wikiLink`/`image` — but
|
|
294
|
+
// handle gracefully; wrap in a paragraph, matching the wikiLink case above).
|
|
295
|
+
const wikiEmbed = node;
|
|
296
|
+
const paragraph = $createParagraphNode();
|
|
297
|
+
const target = wikiEmbed.value || '';
|
|
298
|
+
const blockId = wikiEmbed.data?.blockId;
|
|
299
|
+
if (target && blockId) {
|
|
300
|
+
paragraph.append($createTransclusionNodeFromMdast(target, blockId, wikiEmbed.data));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
// Malformed (missing target or blockId): degrade to inert text
|
|
304
|
+
// rather than an empty paragraph, which would silently drop the
|
|
305
|
+
// original content — same reasoning as the inline `wikiEmbed` case.
|
|
306
|
+
console.warn('[mdastToLexical] block-level wikiEmbed missing target or blockId:', wikiEmbed);
|
|
307
|
+
const fragment = blockId ? `${target}#^${blockId}` : target;
|
|
308
|
+
paragraph.append($createTextNode(`![[${fragment}]]`));
|
|
309
|
+
}
|
|
310
|
+
return [paragraph];
|
|
311
|
+
}
|
|
288
312
|
case 'footnoteDefinition': {
|
|
289
313
|
// Footnote definition: render as indented paragraphs with superscript label.
|
|
290
314
|
// On export, lexicalToMdast detects this pattern (indent=1, starts with FootnoteNode)
|
|
@@ -768,6 +792,22 @@ function convertToggleMarker(marker) {
|
|
|
768
792
|
container.append(content);
|
|
769
793
|
return [container];
|
|
770
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* Build a `TransclusionNode` from a `wikiEmbed` mdast node's `value`/`data`,
|
|
797
|
+
* shared by the block-level (defensive) and inline `wikiEmbed` cases below.
|
|
798
|
+
*
|
|
799
|
+
* `alias`/`_emptyAlias` are carried purely for byte-identical round-trip
|
|
800
|
+
* (#119, FR-003) — an embed never *displays* its alias text (it renders the
|
|
801
|
+
* resolved block's live content instead), so unlike `wikiLink` there is no
|
|
802
|
+
* rendered text to infer "was there an alias" from on export; the flag has
|
|
803
|
+
* to be stored explicitly on the node.
|
|
804
|
+
*/
|
|
805
|
+
function $createTransclusionNodeFromMdast(target, blockId, data) {
|
|
806
|
+
const rawAlias = data?.alias;
|
|
807
|
+
const emptyAlias = data?._emptyAlias === true;
|
|
808
|
+
const hasAlias = typeof rawAlias === 'string' && rawAlias.length > 0 && rawAlias !== target;
|
|
809
|
+
return $createTransclusionNode(target, blockId, hasAlias ? rawAlias : null, emptyAlias);
|
|
810
|
+
}
|
|
771
811
|
function convertInlineNode(node) {
|
|
772
812
|
// Defensive: handle null/undefined nodes
|
|
773
813
|
if (!node?.type) {
|
|
@@ -805,6 +845,12 @@ function convertInlineNode(node) {
|
|
|
805
845
|
const fnRef = node;
|
|
806
846
|
return [$createFootnoteNode(fnRef.identifier)];
|
|
807
847
|
}
|
|
848
|
+
case 'blockAnchor': {
|
|
849
|
+
// Block anchor badge (#122): render `^ULID` as a compact badge instead
|
|
850
|
+
// of raw text, using BlockAnchorNode to preserve the id for round-trip.
|
|
851
|
+
const anchor = node;
|
|
852
|
+
return [$createBlockAnchorNode(anchor.id)];
|
|
853
|
+
}
|
|
808
854
|
case 'wikiLink': {
|
|
809
855
|
// Wiki-links from mdast-util-wiki-link: [[path|alias]]
|
|
810
856
|
const wikiLink = node;
|
|
@@ -849,6 +895,12 @@ function convertInlineNode(node) {
|
|
|
849
895
|
// (convertLinkNode) always emits it back as a wiki-link even when a host
|
|
850
896
|
// has disabled promotion of ordinary links (liminis#951).
|
|
851
897
|
link.setWikiLinkOrigin(true);
|
|
898
|
+
// Block-scoped link (#119): carried as a field separate from `url` so
|
|
899
|
+
// it never inherits the lossy `.md#`-anchor URL-string round trip the
|
|
900
|
+
// plain heading-anchor branches above use.
|
|
901
|
+
if (wikiLink.data?.blockId) {
|
|
902
|
+
link.setBlockId(wikiLink.data.blockId);
|
|
903
|
+
}
|
|
852
904
|
// Preserve empty-alias state for round-trip
|
|
853
905
|
if (wikiLink.data?._emptyAlias) {
|
|
854
906
|
link.setWikiAliasState('empty');
|
|
@@ -862,6 +914,26 @@ function convertInlineNode(node) {
|
|
|
862
914
|
link.append(textNode);
|
|
863
915
|
return [link];
|
|
864
916
|
}
|
|
917
|
+
case 'wikiEmbed': {
|
|
918
|
+
// Transclusion/embed from parse.ts's embed-sentinel post-process: `![[file#^id]]`
|
|
919
|
+
const wikiEmbed = node;
|
|
920
|
+
const target = wikiEmbed.value;
|
|
921
|
+
const blockId = wikiEmbed.data?.blockId;
|
|
922
|
+
// Never produced by parseMarkdown without both (FR-002/FR-013 guarantee
|
|
923
|
+
// a `wikiEmbed` always carries a file target and a blockId), but a
|
|
924
|
+
// hand-built mdast tree from an external `./markdown` consumer could
|
|
925
|
+
// still lack one — degrade to inert text rather than crash (FR-008's
|
|
926
|
+
// "never throw" spirit applies just as much to malformed input as to a
|
|
927
|
+
// missing resolver). Reconstructs whatever of the original syntax is
|
|
928
|
+
// available rather than dropping the content: a blank node would
|
|
929
|
+
// silently erase user-visible text for no parser-level reason.
|
|
930
|
+
if (!target || !blockId) {
|
|
931
|
+
console.warn('[mdastToLexical] wikiEmbed missing target or blockId:', wikiEmbed);
|
|
932
|
+
const fragment = blockId ? `${target ?? ''}#^${blockId}` : (target ?? '');
|
|
933
|
+
return [$createTextNode(`![[${fragment}]]`)];
|
|
934
|
+
}
|
|
935
|
+
return [$createTransclusionNodeFromMdast(target, blockId, wikiEmbed.data)];
|
|
936
|
+
}
|
|
865
937
|
case 'html': {
|
|
866
938
|
// Check for inline equation: $...$
|
|
867
939
|
const html = node.value;
|
|
@@ -925,7 +997,7 @@ function convertStrong(node) {
|
|
|
925
997
|
applyFormatToLinkChildren(n, 'bold', marker);
|
|
926
998
|
nodes.push(n);
|
|
927
999
|
}
|
|
928
|
-
else if ($isEquationNode(n) || $isFootnoteNode(n)) {
|
|
1000
|
+
else if ($isEquationNode(n) || $isFootnoteNode(n) || $isBlockAnchorNode(n)) {
|
|
929
1001
|
if (!n.hasFormat('bold')) {
|
|
930
1002
|
n.toggleFormat('bold');
|
|
931
1003
|
}
|
|
@@ -960,7 +1032,7 @@ function convertEmphasis(node) {
|
|
|
960
1032
|
applyFormatToLinkChildren(n, 'italic', marker);
|
|
961
1033
|
nodes.push(n);
|
|
962
1034
|
}
|
|
963
|
-
else if ($isEquationNode(n) || $isFootnoteNode(n)) {
|
|
1035
|
+
else if ($isEquationNode(n) || $isFootnoteNode(n) || $isBlockAnchorNode(n)) {
|
|
964
1036
|
if (!n.hasFormat('italic')) {
|
|
965
1037
|
n.toggleFormat('italic');
|
|
966
1038
|
}
|
|
@@ -1059,7 +1131,7 @@ function convertDelete(node) {
|
|
|
1059
1131
|
applyFormatToLinkChildren(n, 'strikethrough');
|
|
1060
1132
|
nodes.push(n);
|
|
1061
1133
|
}
|
|
1062
|
-
else if ($isEquationNode(n) || $isFootnoteNode(n)) {
|
|
1134
|
+
else if ($isEquationNode(n) || $isFootnoteNode(n) || $isBlockAnchorNode(n)) {
|
|
1063
1135
|
if (!n.hasFormat('strikethrough')) {
|
|
1064
1136
|
n.toggleFormat('strikethrough');
|
|
1065
1137
|
}
|
package/dist/host/defaults.js
CHANGED
package/dist/host/messages.d.ts
CHANGED
|
@@ -19,7 +19,13 @@ export interface HostMessageApi {
|
|
|
19
19
|
requestSettings: () => void;
|
|
20
20
|
applyTextEdits: (edits: TextEdit[], reason: 'typing' | 'drag' | 'paste' | 'format') => void;
|
|
21
21
|
writeAsset: (dataUri: string, suggestedName?: string) => void;
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `blockId` is additive (#119): a host that hasn't implemented block-aware
|
|
24
|
+
* navigation still receives `url` and opens the file exactly as before —
|
|
25
|
+
* FR-004's "degrades no worse than today's file-only wikilink navigation"
|
|
26
|
+
* falls out for free, with no host-side change required.
|
|
27
|
+
*/
|
|
28
|
+
openLink: (url: string, blockId?: string) => void;
|
|
23
29
|
}
|
|
24
30
|
export declare function createHostMessageApi(bridge: EditorHostBridge, log: EditorLogger): HostMessageApi;
|
|
25
31
|
/** Hook form of {@link createHostMessageApi}, bound to the ambient host services. */
|
package/dist/host/messages.js
CHANGED
|
@@ -31,9 +31,9 @@ export function createHostMessageApi(bridge, log) {
|
|
|
31
31
|
writeAsset: (dataUri, suggestedName) => {
|
|
32
32
|
postMessage({ type: 'WRITE_ASSET', dataUri, suggestedName });
|
|
33
33
|
},
|
|
34
|
-
openLink: (url) => {
|
|
35
|
-
log.debug('openLink', { url });
|
|
36
|
-
postMessage({ type: 'OPEN_LINK', url });
|
|
34
|
+
openLink: (url, blockId) => {
|
|
35
|
+
log.debug('openLink', { url, blockId });
|
|
36
|
+
postMessage(blockId ? { type: 'OPEN_LINK', url, blockId } : { type: 'OPEN_LINK', url });
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -70,6 +70,19 @@ export interface EditorHostServices {
|
|
|
70
70
|
notifyError?: (message: string, description?: string) => void;
|
|
71
71
|
/** Correction persistence + knowledge-graph services. */
|
|
72
72
|
corrections?: CorrectionHostServices;
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a workspace-global block reference (`file#^blockId`, #119) to
|
|
75
|
+
* that block's current markdown content. Backs both transclusion
|
|
76
|
+
* (`![[file#^id]]`) and existence-checking for block-scoped links
|
|
77
|
+
* (`[[file#^id]]`) — see `TransclusionComponent`/`WikiLinkExistencePlugin`.
|
|
78
|
+
*
|
|
79
|
+
* Returns `null` when the file or block id does not resolve (FR-009); the
|
|
80
|
+
* two are not distinguished here — a host that cares to tell them apart
|
|
81
|
+
* can encode that in its own lookup, but the contract only needs
|
|
82
|
+
* resolved-vs-not. Absent entirely (the default), transclusion renders an
|
|
83
|
+
* "unresolved" placeholder rather than throwing (FR-008).
|
|
84
|
+
*/
|
|
85
|
+
resolveTransclusion?: (file: string, blockId: string) => Promise<string | null>;
|
|
73
86
|
}
|
|
74
87
|
/** `EditorHostServices` with every member resolved to a concrete implementation. */
|
|
75
|
-
export type ResolvedEditorHostServices = Required<Pick<EditorHostServices, 'bridge' | 'logger' | 'notifyError'>> & Pick<EditorHostServices, 'resolveWikiLinks' | 'onScrollToAnchor' | 'corrections'>;
|
|
88
|
+
export type ResolvedEditorHostServices = Required<Pick<EditorHostServices, 'bridge' | 'logger' | 'notifyError'>> & Pick<EditorHostServices, 'resolveWikiLinks' | 'onScrollToAnchor' | 'corrections' | 'resolveTransclusion'>;
|