@liminis/editor 0.4.1 → 0.5.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 (36) hide show
  1. package/README.md +45 -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 +2 -1
  6. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  7. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  8. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  9. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  10. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  11. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  12. package/dist/app/editor/nodes/index.d.ts +2 -0
  13. package/dist/app/editor/nodes/index.js +1 -0
  14. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  15. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  16. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  17. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  18. package/dist/app/mapper/lexicalToMdast.js +55 -6
  19. package/dist/app/mapper/mdastToLexical.js +67 -1
  20. package/dist/host/defaults.js +1 -0
  21. package/dist/host/messages.d.ts +7 -1
  22. package/dist/host/messages.js +3 -3
  23. package/dist/host/types.d.ts +14 -1
  24. package/dist/markdown/parse.js +225 -1
  25. package/dist/markdown/stringify.js +30 -10
  26. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  27. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  28. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  29. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  30. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  31. package/dist/styles.css +48 -0
  32. package/dist/types.d.ts +1 -0
  33. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  34. package/docs/editor-api.md +1 -0
  35. package/docs/markdown-pipeline.md +110 -6
  36. package/package.json +5 -3
package/README.md CHANGED
@@ -16,7 +16,8 @@ travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenanc
16
16
  - **`<Editor>`** — a WYSIWYG markdown editor. Tables, task lists (including in
17
17
  ordered lists), footnotes, definition lists, callouts, toggles, code blocks
18
18
  with Prism highlighting, images, LaTeX equations, Mermaid diagrams, C4
19
- diagrams, YAML frontmatter, and wiki-links.
19
+ diagrams, YAML frontmatter, and wiki-links — including block-scoped links
20
+ and live transclusion (`[[file#^id]]` / `![[file#^id]]`).
20
21
  - **A markdown pipeline** — `parseMarkdown` / `stringifyMarkdown` and the mdast
21
22
  ↔ Lexical mappers, usable with no editor mounted.
22
23
  - **An annotation mechanism** — range-anchored markers over document text that
@@ -442,6 +443,49 @@ read-only editor still shows the floating toolbar with the configured
442
443
  affordance (formatting controls are omitted there, since they would be
443
444
  inert).
444
445
 
446
+ ## Block-scoped links and transclusion
447
+
448
+ Wiki-links (`[[target]]` / `[[target|alias]]`) extend to an optional
449
+ Obsidian-style block-id fragment: `[[file#^id]]` links to one specific block
450
+ inside a file rather than the file as a whole, and `![[file#^id]]`
451
+ **transcludes** it — renders that block's actual, current content inline at
452
+ the reference site. This is a live view, not a copy: if the source block's
453
+ text changes, every transclusion of it reflects that on next render.
454
+
455
+ Resolving `file#^id` to content is host work, through one optional injected
456
+ function:
457
+
458
+ ```tsx
459
+ <EditorHostProvider services={{ resolveTransclusion: async (file, blockId) => {
460
+ // look up the block by id across your whole corpus — ids are workspace-global,
461
+ // not scoped to one file (matching Liminis's own `^ULID` convention)
462
+ return lookupBlockContent(file, blockId) // string | null
463
+ } }}>
464
+ <Editor initialContent={markdown} onChange={setMarkdown} />
465
+ </EditorHostProvider>
466
+ ```
467
+
468
+ With no resolver injected, or one that returns `null`, `![[file#^id]]`
469
+ renders a clearly marked "unresolved" placeholder rather than throwing —
470
+ consistent with every other host service in this package. A transclusion
471
+ cycle (block A transcludes B, which transcludes A — directly or through a
472
+ longer chain) is detected and rendered as a "circular transclusion"
473
+ indicator rather than hanging; nested transclusion is supported to a bounded
474
+ depth, beyond which it degrades to a clear fallback rather than a crash.
475
+
476
+ **If you are maintaining this package: do not remove the cycle/depth guard**
477
+ in `src/app/editor/nodes/transclusion-render.tsx`, and **do not loosen the
478
+ embed-marker detection** in `src/markdown/parse.ts` to a bare `!` lookahead
479
+ — see `docs/markdown-pipeline.md`'s "Block-scoped links and transclusion"
480
+ section for what each guards against and the regression fixture that pins
481
+ it down.
482
+
483
+ `[[file#^id]]` (link-only, no `!`) instead requests navigation to that
484
+ specific block when the host supports it, and degrades no worse than
485
+ today's file-only wiki-link navigation when it doesn't — no extra host
486
+ wiring required beyond the resolver above, which also backs its
487
+ "does this block exist" styling.
488
+
445
489
  ## Documentation
446
490
 
447
491
  - [`docs/editor-api.md`](./docs/editor-api.md) — the `<Editor>` props and the
@@ -43,7 +43,11 @@ export function LinkClickPlugin({ editable = true }) {
43
43
  // URL is stored in data-href (not href) to prevent webview interception
44
44
  const url = linkElement.getAttribute('data-href');
45
45
  if (url) {
46
- openLink(url);
46
+ // Block-scoped link (#119): additive, so a host that hasn't
47
+ // implemented block-aware navigation still gets `url` and opens
48
+ // the file exactly as before (FR-004).
49
+ const blockId = linkElement.getAttribute('data-block-id') ?? undefined;
50
+ openLink(url, blockId);
47
51
  }
48
52
  }
49
53
  // In editable mode without modifier: No action needed - since there's no href,
@@ -2,14 +2,24 @@
2
2
  * WikiLinkExistencePlugin - Checks wiki-links and marks broken ones
3
3
  *
4
4
  * This plugin scans for wiki-links in the editor DOM and checks if their
5
- * target files exist anywhere in the workspace. Links to non-existent files
6
- * get a CSS class applied to render them in red.
5
+ * target files (or, for a block-scoped link, target block #119) exist
6
+ * anywhere in the workspace. Links that don't resolve get a CSS class
7
+ * applied to render them in red.
7
8
  *
8
- * Uses the host-supplied `resolveWikiLinks` service, which handles:
9
+ * A plain file-only link (`data-wiki-target`, no `data-block-id`) uses the
10
+ * host-supplied `resolveWikiLinks` service, which handles:
9
11
  * - Directory links (e.g., "entities/teams/") → resolves to index.md or README.md
10
12
  * - File links with extension (e.g., "notes.md") → checks directly
11
13
  * - File links without extension (e.g., "notes") → tries .md, .mdc
12
14
  *
15
+ * A block-scoped link (`data-block-id` present, `[[file#^id]]`) instead
16
+ * checks via `resolveTransclusion` — the same host resolver transclusion
17
+ * uses for content, per the Plan's "one resolver, two consumers" decision.
18
+ * `resolveTransclusion` returns content, not a boolean, but existence is
19
+ * exactly "did this resolve to something non-null" (FR-004's edge case: an
20
+ * id that doesn't exist is unresolved, not an error, with the same styling
21
+ * an unresolved file-only wikilink already gets).
22
+ *
13
23
  * The check is performed:
14
24
  * - When the document is loaded
15
25
  * - When the document content changes (debounced)
@@ -5,21 +5,31 @@ import { useEditorHost } from '../../host/context.js';
5
5
  * WikiLinkExistencePlugin - Checks wiki-links and marks broken ones
6
6
  *
7
7
  * This plugin scans for wiki-links in the editor DOM and checks if their
8
- * target files exist anywhere in the workspace. Links to non-existent files
9
- * get a CSS class applied to render them in red.
8
+ * target files (or, for a block-scoped link, target block #119) exist
9
+ * anywhere in the workspace. Links that don't resolve get a CSS class
10
+ * applied to render them in red.
10
11
  *
11
- * Uses the host-supplied `resolveWikiLinks` service, which handles:
12
+ * A plain file-only link (`data-wiki-target`, no `data-block-id`) uses the
13
+ * host-supplied `resolveWikiLinks` service, which handles:
12
14
  * - Directory links (e.g., "entities/teams/") → resolves to index.md or README.md
13
15
  * - File links with extension (e.g., "notes.md") → checks directly
14
16
  * - File links without extension (e.g., "notes") → tries .md, .mdc
15
17
  *
18
+ * A block-scoped link (`data-block-id` present, `[[file#^id]]`) instead
19
+ * checks via `resolveTransclusion` — the same host resolver transclusion
20
+ * uses for content, per the Plan's "one resolver, two consumers" decision.
21
+ * `resolveTransclusion` returns content, not a boolean, but existence is
22
+ * exactly "did this resolve to something non-null" (FR-004's edge case: an
23
+ * id that doesn't exist is unresolved, not an error, with the same styling
24
+ * an unresolved file-only wikilink already gets).
25
+ *
16
26
  * The check is performed:
17
27
  * - When the document is loaded
18
28
  * - When the document content changes (debounced)
19
29
  */
20
30
  export function WikiLinkExistencePlugin() {
21
31
  const [editor] = useLexicalComposerContext();
22
- const { resolveWikiLinks } = useEditorHost();
32
+ const { resolveWikiLinks, resolveTransclusion } = useEditorHost();
23
33
  const checkTimeoutRef = useRef(null);
24
34
  const lastCheckedRef = useRef(new Set());
25
35
  useEffect(() => {
@@ -27,47 +37,48 @@ export function WikiLinkExistencePlugin() {
27
37
  if (!rootElement)
28
38
  return;
29
39
  const checkWikiLinks = async () => {
30
- // Find all wiki-link elements
40
+ // Find all wiki-link elements, split into plain (file-only) and
41
+ // block-scoped (carrying data-block-id) groups.
31
42
  const wikiLinks = rootElement.querySelectorAll('a[data-wiki-link="true"]');
32
43
  if (wikiLinks.length === 0)
33
44
  return;
34
- // Collect unique target paths
35
- const targets = new Set();
45
+ const plainTargets = new Set();
46
+ const blockRefs = new Set();
36
47
  wikiLinks.forEach((link) => {
37
48
  const target = link.getAttribute('data-wiki-target');
38
- if (target) {
39
- targets.add(target);
49
+ if (!target)
50
+ return;
51
+ const blockId = link.getAttribute('data-block-id');
52
+ if (blockId) {
53
+ blockRefs.add(`${target}#^${blockId}`);
54
+ }
55
+ else {
56
+ plainTargets.add(target);
40
57
  }
41
58
  });
42
- if (targets.size === 0)
59
+ if (plainTargets.size === 0 && blockRefs.size === 0)
43
60
  return;
44
- // Skip if we've already checked these exact targets
45
- const targetsArray = Array.from(targets);
46
- const targetKey = targetsArray.sort().join('|');
47
- if (lastCheckedRef.current.has(targetKey)) {
61
+ // Skip if we've already checked this exact combination of targets.
62
+ const checkKey = [...plainTargets, ...blockRefs].sort().join('|');
63
+ if (lastCheckedRef.current.has(checkKey)) {
48
64
  return;
49
65
  }
50
- // Use the host-supplied resolver which handles directory links, etc.
51
66
  try {
52
- if (!resolveWikiLinks) {
53
- console.warn('[WikiLinkExistencePlugin] resolveWikiLinks host service not available');
54
- return;
55
- }
56
- // Resolve all wiki-link paths
57
- const resolved = await resolveWikiLinks(targetsArray);
58
- lastCheckedRef.current.add(targetKey);
67
+ const plainResolved = await resolvePlainTargets(plainTargets, resolveWikiLinks);
68
+ const blockResolved = await resolveBlockRefs(blockRefs, resolveTransclusion);
69
+ lastCheckedRef.current.add(checkKey);
59
70
  // Update CSS classes on wiki-links
60
71
  wikiLinks.forEach((link) => {
61
72
  const target = link.getAttribute('data-wiki-target');
62
- if (target) {
63
- // Link exists if resolver returned a non-null path
64
- const exists = resolved[target] !== null;
65
- if (!exists) {
66
- link.classList.add('editor-link-broken');
67
- }
68
- else {
69
- link.classList.remove('editor-link-broken');
70
- }
73
+ if (!target)
74
+ return;
75
+ const blockId = link.getAttribute('data-block-id');
76
+ const exists = blockId ? blockResolved.get(`${target}#^${blockId}`) : plainResolved.get(target);
77
+ if (exists === false) {
78
+ link.classList.add('editor-link-broken');
79
+ }
80
+ else {
81
+ link.classList.remove('editor-link-broken');
71
82
  }
72
83
  });
73
84
  }
@@ -99,6 +110,54 @@ export function WikiLinkExistencePlugin() {
99
110
  clearTimeout(checkTimeoutRef.current);
100
111
  }
101
112
  };
102
- }, [editor, resolveWikiLinks]);
113
+ }, [editor, resolveWikiLinks, resolveTransclusion]);
103
114
  return null;
104
115
  }
116
+ /**
117
+ * Resolve plain (file-only) wiki-link targets via `resolveWikiLinks`.
118
+ * Absent the resolver (or no targets to check), every target is left
119
+ * unresolved-status `undefined` so the caller leaves its styling alone
120
+ * rather than marking it broken — consistent with the rest of this host
121
+ * seam's "service missing = feature unavailable, do nothing" convention.
122
+ */
123
+ async function resolvePlainTargets(targets, resolveWikiLinks) {
124
+ const result = new Map();
125
+ if (targets.size === 0 || !resolveWikiLinks) {
126
+ return result;
127
+ }
128
+ const resolved = await resolveWikiLinks([...targets]);
129
+ for (const target of targets) {
130
+ // A missing key (as opposed to an explicit `null`) is treated as
131
+ // existing, matching this plugin's pre-#119 behavior — resolveWikiLinks
132
+ // isn't contractually required to return an entry for every target, and
133
+ // FR-014 requires plain wikilink resolution to be unaffected by this
134
+ // feature.
135
+ result.set(target, resolved[target] !== null);
136
+ }
137
+ return result;
138
+ }
139
+ /**
140
+ * Resolve block-scoped references (`file#^blockId` keys) via
141
+ * `resolveTransclusion`, one call per unique reference (the resolver's
142
+ * contract is single-reference, unlike `resolveWikiLinks`'s batch shape).
143
+ */
144
+ async function resolveBlockRefs(refs, resolveTransclusion) {
145
+ const result = new Map();
146
+ if (refs.size === 0 || !resolveTransclusion) {
147
+ return result;
148
+ }
149
+ await Promise.all([...refs].map(async (ref) => {
150
+ const separatorIndex = ref.indexOf('#^');
151
+ const file = ref.slice(0, separatorIndex);
152
+ const blockId = ref.slice(separatorIndex + 2);
153
+ try {
154
+ const content = await resolveTransclusion(file, blockId);
155
+ result.set(ref, content !== null && content !== undefined);
156
+ }
157
+ catch {
158
+ // FR-009: a rejected resolver is treated as "unresolved", not an error.
159
+ result.set(ref, false);
160
+ }
161
+ }));
162
+ return result;
163
+ }
@@ -3,7 +3,7 @@ import { CodeNode, CodeHighlightNode } from '@lexical/code';
3
3
  import { AutoLinkNode } from '@lexical/link';
4
4
  import { MarkNode } from '@lexical/mark';
5
5
  import { TableNode, TableRowNode, TableCellNode } from '@lexical/table';
6
- import { CalloutNode, ToggleContainerNode, ToggleTitleNode, ToggleContentNode, ImageNode, HorizontalRuleNode, EquationNode, MermaidNode, C4Node, FrontmatterNode, FootnoteNode, HtmlNode, ListItemParagraphBreakNode, CustomLinkNode, CustomListNode, DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, CustomListItemNode, } from './nodes/index.js';
6
+ import { CalloutNode, ToggleContainerNode, ToggleTitleNode, ToggleContentNode, ImageNode, HorizontalRuleNode, EquationNode, MermaidNode, C4Node, FrontmatterNode, FootnoteNode, HtmlNode, ListItemParagraphBreakNode, CustomLinkNode, CustomListNode, DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, CustomListItemNode, TransclusionNode, } from './nodes/index.js';
7
7
  export const editorNodes = [
8
8
  HeadingNode,
9
9
  QuoteNode,
@@ -24,6 +24,7 @@ export const editorNodes = [
24
24
  HorizontalRuleNode,
25
25
  EquationNode,
26
26
  MermaidNode,
27
+ TransclusionNode,
27
28
  C4Node,
28
29
  FrontmatterNode,
29
30
  FootnoteNode,
@@ -3,6 +3,10 @@ import { DOMConversionMap, EditorConfig, LexicalNode } from 'lexical';
3
3
  export type SerializedCustomLinkNode = SerializedLinkNode & {
4
4
  wikiAliasState?: 'empty';
5
5
  wikiLinkOrigin?: true;
6
+ /** Obsidian-style `#^blockId` fragment (#119), carried as a field separate
7
+ * from `url` so it never has to round-trip through the lossy `.md#`
8
+ * URL-string channel the plain anchor-link path uses. */
9
+ blockId?: string;
6
10
  };
7
11
  /**
8
12
  * CustomLinkNode - Extends Lexical's LinkNode to prevent VS Code webview link interception
@@ -24,6 +28,14 @@ export declare class CustomLinkNode extends LinkNode {
24
28
  * @internal
25
29
  */
26
30
  __wikiLinkOrigin: boolean;
31
+ /**
32
+ * Obsidian-style `#^blockId` fragment (#119), when this link is a
33
+ * block-scoped wiki-link (`[[file#^id]]`). `null` for an ordinary
34
+ * file-only or heading-anchor wiki-link. Deliberately a field separate
35
+ * from `__url` — see `mdastToLexical.ts`/`lexicalToMdast.ts` for why.
36
+ * @internal
37
+ */
38
+ __blockId: string | null;
27
39
  constructor(url: string, attributes?: {
28
40
  rel?: null | string;
29
41
  target?: null | string;
@@ -48,6 +60,8 @@ export declare class CustomLinkNode extends LinkNode {
48
60
  getWikiAliasState(): 'empty' | null;
49
61
  setWikiLinkOrigin(origin: boolean): void;
50
62
  getWikiLinkOrigin(): boolean;
63
+ setBlockId(blockId: string | null): void;
64
+ getBlockId(): string | null;
51
65
  }
52
66
  export declare function $createCustomLinkNode(url: string, attributes?: {
53
67
  rel?: null | string;
@@ -19,10 +19,19 @@ export class CustomLinkNode extends LinkNode {
19
19
  * @internal
20
20
  */
21
21
  __wikiLinkOrigin;
22
+ /**
23
+ * Obsidian-style `#^blockId` fragment (#119), when this link is a
24
+ * block-scoped wiki-link (`[[file#^id]]`). `null` for an ordinary
25
+ * file-only or heading-anchor wiki-link. Deliberately a field separate
26
+ * from `__url` — see `mdastToLexical.ts`/`lexicalToMdast.ts` for why.
27
+ * @internal
28
+ */
29
+ __blockId;
22
30
  constructor(url, attributes, key) {
23
31
  super(url, attributes, key);
24
32
  this.__wikiAliasState = null;
25
33
  this.__wikiLinkOrigin = false;
34
+ this.__blockId = null;
26
35
  }
27
36
  static getType() {
28
37
  return 'link'; // Use same type to replace the default LinkNode
@@ -31,6 +40,7 @@ export class CustomLinkNode extends LinkNode {
31
40
  const cloned = new CustomLinkNode(node.__url, { rel: node.__rel, target: node.__target, title: node.__title }, node.__key);
32
41
  cloned.__wikiAliasState = node.__wikiAliasState;
33
42
  cloned.__wikiLinkOrigin = node.__wikiLinkOrigin;
43
+ cloned.__blockId = node.__blockId;
34
44
  return cloned;
35
45
  }
36
46
  createDOM(config) {
@@ -54,6 +64,9 @@ export class CustomLinkNode extends LinkNode {
54
64
  if (this.isWikiLink()) {
55
65
  element.setAttribute('data-wiki-link', 'true');
56
66
  element.setAttribute('data-wiki-target', this.__url);
67
+ if (this.__blockId) {
68
+ element.setAttribute('data-block-id', this.__blockId);
69
+ }
57
70
  }
58
71
  else if (this.isExternalLink()) {
59
72
  // External links get blue styling to differentiate from wiki-links
@@ -97,6 +110,7 @@ export class CustomLinkNode extends LinkNode {
97
110
  else {
98
111
  anchor.removeAttribute('data-wiki-link');
99
112
  anchor.removeAttribute('data-wiki-target');
113
+ anchor.removeAttribute('data-block-id');
100
114
  anchor.classList.remove('editor-link-broken');
101
115
  if (this.isExternalLink()) {
102
116
  anchor.classList.add('editor-link-external');
@@ -106,6 +120,14 @@ export class CustomLinkNode extends LinkNode {
106
120
  }
107
121
  }
108
122
  }
123
+ if (this.__blockId !== prevNode.__blockId) {
124
+ if (this.__blockId && this.isWikiLink()) {
125
+ anchor.setAttribute('data-block-id', this.__blockId);
126
+ }
127
+ else {
128
+ anchor.removeAttribute('data-block-id');
129
+ }
130
+ }
109
131
  if (target !== prevNode.__target) {
110
132
  if (target) {
111
133
  anchor.target = target;
@@ -155,6 +177,9 @@ export class CustomLinkNode extends LinkNode {
155
177
  if (serializedNode.wikiLinkOrigin) {
156
178
  node.__wikiLinkOrigin = true;
157
179
  }
180
+ if (serializedNode.blockId) {
181
+ node.__blockId = serializedNode.blockId;
182
+ }
158
183
  node.setFormat(serializedNode.format);
159
184
  node.setIndent(serializedNode.indent);
160
185
  node.setDirection(serializedNode.direction);
@@ -167,6 +192,7 @@ export class CustomLinkNode extends LinkNode {
167
192
  version: 1,
168
193
  wikiAliasState: this.__wikiAliasState ?? undefined,
169
194
  wikiLinkOrigin: this.__wikiLinkOrigin ? true : undefined,
195
+ blockId: this.__blockId ?? undefined,
170
196
  };
171
197
  }
172
198
  setWikiAliasState(state) {
@@ -183,6 +209,13 @@ export class CustomLinkNode extends LinkNode {
183
209
  getWikiLinkOrigin() {
184
210
  return this.__wikiLinkOrigin;
185
211
  }
212
+ setBlockId(blockId) {
213
+ const writable = this.getWritable();
214
+ writable.__blockId = blockId;
215
+ }
216
+ getBlockId() {
217
+ return this.__blockId;
218
+ }
186
219
  }
187
220
  function convertAnchorElement(domNode) {
188
221
  let node = null;
@@ -195,6 +228,10 @@ function convertAnchorElement(domNode) {
195
228
  target: domNode.getAttribute('target'),
196
229
  title: domNode.getAttribute('title'),
197
230
  });
231
+ const blockId = domNode.getAttribute('data-block-id');
232
+ if (blockId) {
233
+ node.__blockId = blockId;
234
+ }
198
235
  }
199
236
  }
200
237
  return { node };
@@ -0,0 +1,8 @@
1
+ import { NodeKey } from 'lexical';
2
+ interface TransclusionComponentProps {
3
+ file: string;
4
+ blockId: string;
5
+ nodeKey: NodeKey;
6
+ }
7
+ export default function TransclusionComponent({ file, blockId }: TransclusionComponentProps): JSX.Element;
8
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * TransclusionComponent - resolves and renders a block transclusion
3
+ * (`![[file#^id]]`, #119)
4
+ *
5
+ * Mirrors `MermaidComponent`'s split: `TransclusionNode` is a static,
6
+ * serializable value (`file`/`blockId`/`alias`); the actual host-resolver
7
+ * call happens here, inside the lazily-loaded component, since resolution is
8
+ * `Promise`-based and the mdast<->Lexical mapper is synchronous.
9
+ */
10
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
11
+ import { useEffect, useRef, useState } from 'react';
12
+ import { useEditorHost } from '../../../host/context.js';
13
+ import { resolveAndRenderTransclusion, renderTransclusionState, } from './transclusion-render.js';
14
+ import { renderTransclusionLoading } from './transclusion-loading.js';
15
+ // Matches WikiLinkExistencePlugin's debounce window for the same reason:
16
+ // both re-run a host resolver on every dirty editor update, and without
17
+ // debouncing that fires one resolver call (potentially I/O-bound) per
18
+ // keystroke per visible reference.
19
+ const RESOLVE_DEBOUNCE_MS = 300;
20
+ export default function TransclusionComponent({ file, blockId }) {
21
+ const [editor] = useLexicalComposerContext();
22
+ const { resolveTransclusion } = useEditorHost();
23
+ const [state, setState] = useState(null);
24
+ // Guards against a stale resolution landing after a newer one already
25
+ // started (e.g. file/blockId changed, or two update-listener firings
26
+ // overlap) — only the most recent request is allowed to commit state.
27
+ const generationRef = useRef(0);
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ let debounceTimeout = null;
31
+ const resolve = async () => {
32
+ const generation = ++generationRef.current;
33
+ const result = await resolveAndRenderTransclusion(file, blockId, resolveTransclusion, []);
34
+ if (!cancelled && generation === generationRef.current) {
35
+ setState(result);
36
+ }
37
+ };
38
+ void resolve();
39
+ // FR-006/SC-002: re-resolve on every document change so an edit to the
40
+ // source block — in this document, or elsewhere once the host's own
41
+ // resolver reflects it — shows up without the host having to remount
42
+ // the editor. Pull-based and unmemoized (every dirty update re-resolves
43
+ // every visible transclusion): an accepted v1 cost, not a correctness
44
+ // gap — see the Plan's "no push/invalidation channel" risk note. The
45
+ // debounce below only bounds *how often* that cost is paid per burst of
46
+ // edits, mirroring WikiLinkExistencePlugin's existing convention.
47
+ const unregister = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
48
+ if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
49
+ if (debounceTimeout)
50
+ clearTimeout(debounceTimeout);
51
+ debounceTimeout = setTimeout(() => { void resolve(); }, RESOLVE_DEBOUNCE_MS);
52
+ }
53
+ });
54
+ return () => {
55
+ cancelled = true;
56
+ if (debounceTimeout)
57
+ clearTimeout(debounceTimeout);
58
+ unregister();
59
+ };
60
+ }, [editor, file, blockId, resolveTransclusion]);
61
+ if (state === null) {
62
+ return renderTransclusionLoading();
63
+ }
64
+ return renderTransclusionState(state);
65
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TransclusionNode - Live-rendered block transclusion (`![[file#^id]]`, #119)
3
+ *
4
+ * An **inline** DecoratorNode, unlike the block-level `MermaidNode`: a
5
+ * `wikiEmbed` mdast node is phrasing content (same family as `wikiLink`/
6
+ * `image`), so this matches where it actually sits in the tree rather than
7
+ * forcing paragraph-promotion logic to accommodate it.
8
+ *
9
+ * `file`/`blockId` are the sole identity the node carries; `alias` is stored
10
+ * only for byte-identical round-trip (`![[file#^id|alias]]`) — an embed
11
+ * renders the resolved block's *live content*, never the alias text, so
12
+ * nothing here displays it. Content resolution is async and host-resolver-
13
+ * driven (see `transclusion-render.tsx`), so — mirroring `MermaidNode` — the
14
+ * actual resolver call happens inside the lazily-loaded `TransclusionComponent`
15
+ * at decorate-time, not here: this node is a static, serializable value.
16
+ */
17
+ import { DecoratorNode, DOMConversionMap, DOMExportOutput, LexicalNode, NodeKey, SerializedLexicalNode, Spread } from 'lexical';
18
+ export type SerializedTransclusionNode = Spread<{
19
+ file: string;
20
+ blockId: string;
21
+ alias: string | null;
22
+ emptyAlias: boolean;
23
+ }, SerializedLexicalNode>;
24
+ export declare class TransclusionNode extends DecoratorNode<JSX.Element> {
25
+ __file: string;
26
+ __blockId: string;
27
+ __alias: string | null;
28
+ __emptyAlias: boolean;
29
+ static getType(): string;
30
+ static clone(node: TransclusionNode): TransclusionNode;
31
+ constructor(file: string, blockId: string, alias?: string | null, emptyAlias?: boolean, key?: NodeKey);
32
+ static importJSON(serializedNode: SerializedTransclusionNode): TransclusionNode;
33
+ exportJSON(): SerializedTransclusionNode;
34
+ createDOM(): HTMLElement;
35
+ exportDOM(): DOMExportOutput;
36
+ static importDOM(): DOMConversionMap | null;
37
+ updateDOM(): boolean;
38
+ isInline(): boolean;
39
+ getFile(): string;
40
+ getBlockId(): string;
41
+ getAlias(): string | null;
42
+ setAlias(alias: string | null): void;
43
+ getEmptyAlias(): boolean;
44
+ setEmptyAlias(emptyAlias: boolean): void;
45
+ decorate(): JSX.Element;
46
+ }
47
+ export declare function $createTransclusionNode(file: string, blockId: string, alias?: string | null, emptyAlias?: boolean): TransclusionNode;
48
+ export declare function $isTransclusionNode(node: LexicalNode | null | undefined): node is TransclusionNode;