@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.
Files changed (41) hide show
  1. package/README.md +66 -1
  2. package/dist/app/editor/LinkClickPlugin.js +5 -1
  3. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
  4. package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
  5. package/dist/app/editor/editorNodes.js +3 -1
  6. package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
  7. package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
  8. package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
  9. package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
  10. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  11. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  12. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  13. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  14. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  15. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  16. package/dist/app/editor/nodes/index.d.ts +4 -0
  17. package/dist/app/editor/nodes/index.js +2 -0
  18. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  19. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  20. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  21. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  22. package/dist/app/mapper/lexicalToMdast.js +116 -22
  23. package/dist/app/mapper/mdastToLexical.js +76 -4
  24. package/dist/host/defaults.js +1 -0
  25. package/dist/host/messages.d.ts +7 -1
  26. package/dist/host/messages.js +3 -3
  27. package/dist/host/types.d.ts +14 -1
  28. package/dist/markdown/parse.js +535 -1
  29. package/dist/markdown/stringify.js +35 -10
  30. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  31. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  32. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  33. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  34. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  35. package/dist/styles.css +48 -0
  36. package/dist/types.d.ts +1 -0
  37. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  38. package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +323 -6
  41. 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 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.
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) || $isImageNode(node) || $isEquationNode(node) || $isFootnoteNode(node) || $isHtmlNode(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 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.
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 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.
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
  }
@@ -43,5 +43,6 @@ export function resolveHostServices(services) {
43
43
  resolveWikiLinks: services?.resolveWikiLinks,
44
44
  onScrollToAnchor: services?.onScrollToAnchor,
45
45
  corrections: services?.corrections,
46
+ resolveTransclusion: services?.resolveTransclusion,
46
47
  };
47
48
  }
@@ -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
- openLink: (url: string) => void;
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. */
@@ -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
  }
@@ -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'>;