@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
@@ -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;
@@ -0,0 +1,141 @@
1
+ /* eslint-disable react-refresh/only-export-components */
2
+ /**
3
+ * TransclusionNode - Live-rendered block transclusion (`![[file#^id]]`, #119)
4
+ *
5
+ * An **inline** DecoratorNode, unlike the block-level `MermaidNode`: a
6
+ * `wikiEmbed` mdast node is phrasing content (same family as `wikiLink`/
7
+ * `image`), so this matches where it actually sits in the tree rather than
8
+ * forcing paragraph-promotion logic to accommodate it.
9
+ *
10
+ * `file`/`blockId` are the sole identity the node carries; `alias` is stored
11
+ * only for byte-identical round-trip (`![[file#^id|alias]]`) — an embed
12
+ * renders the resolved block's *live content*, never the alias text, so
13
+ * nothing here displays it. Content resolution is async and host-resolver-
14
+ * driven (see `transclusion-render.tsx`), so — mirroring `MermaidNode` — the
15
+ * actual resolver call happens inside the lazily-loaded `TransclusionComponent`
16
+ * at decorate-time, not here: this node is a static, serializable value.
17
+ */
18
+ import { DecoratorNode, $applyNodeReplacement, } from 'lexical';
19
+ import { createElement, lazy, Suspense } from 'react';
20
+ import { renderTransclusionLoading } from './transclusion-loading.js';
21
+ const TransclusionComponent = lazy(() => import('./TransclusionComponent.js'));
22
+ function $convertTransclusionElement(domNode) {
23
+ const file = domNode.getAttribute('data-lexical-transclusion-file');
24
+ const blockId = domNode.getAttribute('data-lexical-transclusion-block-id');
25
+ if (file && blockId) {
26
+ const alias = domNode.getAttribute('data-lexical-transclusion-alias');
27
+ const emptyAlias = domNode.getAttribute('data-lexical-transclusion-empty-alias') === 'true';
28
+ const node = $createTransclusionNode(file, blockId, alias, emptyAlias);
29
+ return { node };
30
+ }
31
+ return null;
32
+ }
33
+ export class TransclusionNode extends DecoratorNode {
34
+ __file;
35
+ __blockId;
36
+ __alias;
37
+ __emptyAlias;
38
+ static getType() {
39
+ return 'transclusion';
40
+ }
41
+ static clone(node) {
42
+ return new TransclusionNode(node.__file, node.__blockId, node.__alias, node.__emptyAlias, node.__key);
43
+ }
44
+ constructor(file, blockId, alias = null, emptyAlias = false, key) {
45
+ super(key);
46
+ this.__file = file;
47
+ this.__blockId = blockId;
48
+ this.__alias = alias;
49
+ this.__emptyAlias = emptyAlias;
50
+ }
51
+ static importJSON(serializedNode) {
52
+ return $createTransclusionNode(serializedNode.file, serializedNode.blockId, serializedNode.alias, serializedNode.emptyAlias);
53
+ }
54
+ exportJSON() {
55
+ return {
56
+ type: 'transclusion',
57
+ version: 1,
58
+ file: this.__file,
59
+ blockId: this.__blockId,
60
+ alias: this.__alias,
61
+ emptyAlias: this.__emptyAlias,
62
+ };
63
+ }
64
+ createDOM() {
65
+ const element = document.createElement('span');
66
+ element.className = 'editor-transclusion';
67
+ return element;
68
+ }
69
+ exportDOM() {
70
+ const element = document.createElement('span');
71
+ element.setAttribute('data-lexical-transclusion-file', this.__file);
72
+ element.setAttribute('data-lexical-transclusion-block-id', this.__blockId);
73
+ if (this.__alias !== null) {
74
+ element.setAttribute('data-lexical-transclusion-alias', this.__alias);
75
+ }
76
+ if (this.__emptyAlias) {
77
+ element.setAttribute('data-lexical-transclusion-empty-alias', 'true');
78
+ }
79
+ element.className = 'transclusion-export';
80
+ // Never crash a copy/paste-shaped export by trying to resolve content
81
+ // synchronously (FR-008) — a plain placeholder is a faithful, inert
82
+ // stand-in for the live view this node otherwise renders.
83
+ element.textContent = renderPlaceholderText(this.__file, this.__blockId);
84
+ return { element };
85
+ }
86
+ static importDOM() {
87
+ return {
88
+ span: (domNode) => {
89
+ if (!domNode.hasAttribute('data-lexical-transclusion-file')) {
90
+ return null;
91
+ }
92
+ return {
93
+ conversion: $convertTransclusionElement,
94
+ priority: 2,
95
+ };
96
+ },
97
+ };
98
+ }
99
+ updateDOM() {
100
+ return false;
101
+ }
102
+ isInline() {
103
+ return true;
104
+ }
105
+ getFile() {
106
+ return this.__file;
107
+ }
108
+ getBlockId() {
109
+ return this.__blockId;
110
+ }
111
+ getAlias() {
112
+ return this.__alias;
113
+ }
114
+ setAlias(alias) {
115
+ const writable = this.getWritable();
116
+ writable.__alias = alias;
117
+ }
118
+ getEmptyAlias() {
119
+ return this.__emptyAlias;
120
+ }
121
+ setEmptyAlias(emptyAlias) {
122
+ const writable = this.getWritable();
123
+ writable.__emptyAlias = emptyAlias;
124
+ }
125
+ decorate() {
126
+ return createElement(Suspense, { fallback: renderTransclusionLoading() }, createElement(TransclusionComponent, {
127
+ file: this.__file,
128
+ blockId: this.__blockId,
129
+ nodeKey: this.__key,
130
+ }));
131
+ }
132
+ }
133
+ function renderPlaceholderText(file, blockId) {
134
+ return `![[${file}#^${blockId}]]`;
135
+ }
136
+ export function $createTransclusionNode(file, blockId, alias = null, emptyAlias = false) {
137
+ return $applyNodeReplacement(new TransclusionNode(file, blockId, alias, emptyAlias));
138
+ }
139
+ export function $isTransclusionNode(node) {
140
+ return node instanceof TransclusionNode;
141
+ }
@@ -10,6 +10,8 @@ export { EquationNode, $createEquationNode, $isEquationNode } from './EquationNo
10
10
  export type { SerializedEquationNode } from './EquationNode.js';
11
11
  export { MermaidNode, $createMermaidNode, $isMermaidNode } from './MermaidNode.js';
12
12
  export type { SerializedMermaidNode } from './MermaidNode.js';
13
+ export { TransclusionNode, $createTransclusionNode, $isTransclusionNode } from './TransclusionNode.js';
14
+ export type { SerializedTransclusionNode } from './TransclusionNode.js';
13
15
  export { C4Node, $createC4Node, $isC4Node } from './C4Node.js';
14
16
  export type { SerializedC4Node } from './C4Node.js';
15
17
  export { FrontmatterNode, $createFrontmatterNode, $isFrontmatterNode } from './FrontmatterNode.js';
@@ -22,6 +24,8 @@ export { CustomListItemNode, $createCustomListItemNode, $isCustomListItemNode }
22
24
  export type { SerializedCustomListItemNode } from './CustomListItemNode.js';
23
25
  export { FootnoteNode, $createFootnoteNode, $isFootnoteNode } from './FootnoteNode.js';
24
26
  export type { SerializedFootnoteNode } from './FootnoteNode.js';
27
+ export { BlockAnchorNode, $createBlockAnchorNode, $isBlockAnchorNode } from './BlockAnchorNode.js';
28
+ export type { SerializedBlockAnchorNode } from './BlockAnchorNode.js';
25
29
  export { DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, } from './DefinitionListNode.js';
26
30
  export type { SerializedDefinitionListNode, SerializedDefinitionTermNode, SerializedDefinitionDescriptionNode, } from './DefinitionListNode.js';
27
31
  export { HtmlNode, $createHtmlNode, $isHtmlNode } from './HtmlNode.js';
@@ -6,12 +6,14 @@ export { ImageNode, $createImageNode, $isImageNode } from './ImageNode.js';
6
6
  export { HorizontalRuleNode, $createHorizontalRuleNode, $isHorizontalRuleNode } from './HorizontalRuleNode.js';
7
7
  export { EquationNode, $createEquationNode, $isEquationNode } from './EquationNode.js';
8
8
  export { MermaidNode, $createMermaidNode, $isMermaidNode } from './MermaidNode.js';
9
+ export { TransclusionNode, $createTransclusionNode, $isTransclusionNode } from './TransclusionNode.js';
9
10
  export { C4Node, $createC4Node, $isC4Node } from './C4Node.js';
10
11
  export { FrontmatterNode, $createFrontmatterNode, $isFrontmatterNode } from './FrontmatterNode.js';
11
12
  export { CustomLinkNode, $createCustomLinkNode, $isCustomLinkNode } from './CustomLinkNode.js';
12
13
  export { CustomListNode, $createCustomListNode, $isCustomListNode } from './CustomListNode.js';
13
14
  export { CustomListItemNode, $createCustomListItemNode, $isCustomListItemNode } from './CustomListItemNode.js';
14
15
  export { FootnoteNode, $createFootnoteNode, $isFootnoteNode } from './FootnoteNode.js';
16
+ export { BlockAnchorNode, $createBlockAnchorNode, $isBlockAnchorNode } from './BlockAnchorNode.js';
15
17
  export { DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, } from './DefinitionListNode.js';
16
18
  export { HtmlNode, $createHtmlNode, $isHtmlNode } from './HtmlNode.js';
17
19
  export { ListItemParagraphBreakNode, $createListItemParagraphBreakNode, $isListItemParagraphBreakNode, } from './ListItemParagraphBreakNode.js';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The transient "waiting" visual for block transclusion (#119) — split into
3
+ * its own, deliberately dependency-light module.
4
+ *
5
+ * `TransclusionNode.tsx` needs this for its `Suspense` fallback, and
6
+ * `TransclusionNode.tsx` is part of the `./nodes` subpath's *static* (eagerly
7
+ * evaluated) import graph — unlike `TransclusionComponent.tsx`, which is only
8
+ * ever reached through a dynamic `import()`. `transclusion-render.tsx`
9
+ * imports `parseMarkdown` (and, transitively, the whole micromark/mdast-util
10
+ * pipeline) to do its real work; if `TransclusionNode.tsx` imported this
11
+ * function from that module instead, `./nodes` would statically pull in the
12
+ * entire markdown pipeline it does not otherwise need — exactly the weight
13
+ * `src/__tests__/nodes-subpath.test.ts` exists to keep out. Keeping this one
14
+ * function here, with no import of `transclusion-render.tsx`, is what keeps
15
+ * that boundary intact.
16
+ */
17
+ import { type ReactNode } from 'react';
18
+ /**
19
+ * The transient "resolver call in flight" state — not part of
20
+ * `TransclusionRenderState` (`transclusion-render.tsx`) because it is a UI
21
+ * concern of the lazily-mounted `TransclusionComponent`, not an outcome the
22
+ * pure resolver ever produces (it only returns once fully settled). Also
23
+ * used as the `Suspense` fallback in `TransclusionNode.decorate()` while the
24
+ * component's own code chunk is still loading, so both "waiting" cases look
25
+ * the same.
26
+ */
27
+ export declare function renderTransclusionLoading(): ReactNode;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The transient "waiting" visual for block transclusion (#119) — split into
3
+ * its own, deliberately dependency-light module.
4
+ *
5
+ * `TransclusionNode.tsx` needs this for its `Suspense` fallback, and
6
+ * `TransclusionNode.tsx` is part of the `./nodes` subpath's *static* (eagerly
7
+ * evaluated) import graph — unlike `TransclusionComponent.tsx`, which is only
8
+ * ever reached through a dynamic `import()`. `transclusion-render.tsx`
9
+ * imports `parseMarkdown` (and, transitively, the whole micromark/mdast-util
10
+ * pipeline) to do its real work; if `TransclusionNode.tsx` imported this
11
+ * function from that module instead, `./nodes` would statically pull in the
12
+ * entire markdown pipeline it does not otherwise need — exactly the weight
13
+ * `src/__tests__/nodes-subpath.test.ts` exists to keep out. Keeping this one
14
+ * function here, with no import of `transclusion-render.tsx`, is what keeps
15
+ * that boundary intact.
16
+ */
17
+ import { createElement } from 'react';
18
+ /**
19
+ * The transient "resolver call in flight" state — not part of
20
+ * `TransclusionRenderState` (`transclusion-render.tsx`) because it is a UI
21
+ * concern of the lazily-mounted `TransclusionComponent`, not an outcome the
22
+ * pure resolver ever produces (it only returns once fully settled). Also
23
+ * used as the `Suspense` fallback in `TransclusionNode.decorate()` while the
24
+ * component's own code chunk is still loading, so both "waiting" cases look
25
+ * the same.
26
+ */
27
+ export function renderTransclusionLoading() {
28
+ return createElement('span', { className: 'editor-transclusion-loading' }, 'Loading…');
29
+ }
@@ -0,0 +1,49 @@
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 { type ReactNode } from 'react';
14
+ /** A host-injected resolver, matching `EditorHostServices.resolveTransclusion`. */
15
+ export type TransclusionResolver = (file: string, blockId: string) => Promise<string | null>;
16
+ /**
17
+ * Nested transclusion depth bound (FR-011). A Plan-stage numeric choice, not
18
+ * derived from anything structural — deep enough that legitimate nesting
19
+ * (a summary block quoting a handful of sub-tasks, one level each) never
20
+ * hits it, shallow enough that a missed-cycle edge case still terminates
21
+ * fast. Checked *before* the resolver call at each level, so a document
22
+ * that would exceed it never spends host I/O on content that gets discarded.
23
+ */
24
+ export declare const MAX_TRANSCLUSION_DEPTH = 8;
25
+ export type TransclusionRenderState = {
26
+ kind: 'resolved';
27
+ content: ReactNode;
28
+ } | {
29
+ kind: 'unresolved';
30
+ } | {
31
+ kind: 'circular';
32
+ } | {
33
+ kind: 'depth-exceeded';
34
+ };
35
+ /**
36
+ * Resolve a `file#^blockId` reference to rendered content, guarding against
37
+ * cycles and unbounded nesting.
38
+ *
39
+ * `visitedPath` is the chain of `file#^blockId` keys already open on *this*
40
+ * branch of the resolution tree (ancestors, not a single global "already
41
+ * transcluded anywhere" set) — the same block transcluded from two unrelated
42
+ * sites in the same document must not falsely trip the cycle guard for the
43
+ * second site.
44
+ */
45
+ export declare function resolveAndRenderTransclusion(file: string, blockId: string, resolver: TransclusionResolver | undefined, visitedPath?: readonly string[]): Promise<TransclusionRenderState>;
46
+ /** Render a {@link TransclusionRenderState} to a React node, for both the
47
+ * top-level `TransclusionComponent` and a nested `wikiEmbed` inside
48
+ * resolved content — the two share the same visual vocabulary. */
49
+ export declare function renderTransclusionState(state: TransclusionRenderState): ReactNode;