@liminis/editor 0.4.0 → 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 (41) hide show
  1. package/README.md +77 -8
  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/architecture.md +80 -0
  34. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  35. package/docs/diagrams/architecture-1-dark.svg +1 -0
  36. package/docs/diagrams/architecture-1.svg +1 -0
  37. package/docs/diagrams/architecture-2-dark.svg +1 -0
  38. package/docs/diagrams/architecture-2.svg +1 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +110 -6
  41. package/package.json +6 -4
package/README.md CHANGED
@@ -8,14 +8,16 @@ It is the editor from [Liminis](https://github.com/verveguy/liminis), extracted
8
8
  into this repository so it can stand on its own. MIT-licensed.
9
9
 
10
10
  Its history, and how to read the `ADR-0NNN`, `FR-NNN` and `#NNN` references that
11
- travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenance.md).
11
+ travelled with the code, are recorded in [`docs/provenance.md`](./docs/provenance.md)
12
+ ([on the docs site](https://v3rv.com/liminis-editor/guide/provenance/)).
12
13
 
13
14
  ## What you get
14
15
 
15
16
  - **`<Editor>`** — a WYSIWYG markdown editor. Tables, task lists (including in
16
17
  ordered lists), footnotes, definition lists, callouts, toggles, code blocks
17
18
  with Prism highlighting, images, LaTeX equations, Mermaid diagrams, C4
18
- 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]]`).
19
21
  - **A markdown pipeline** — `parseMarkdown` / `stringifyMarkdown` and the mdast
20
22
  ↔ Lexical mappers, usable with no editor mounted.
21
23
  - **An annotation mechanism** — range-anchored markers over document text that
@@ -441,6 +443,49 @@ read-only editor still shows the floating toolbar with the configured
441
443
  affordance (formatting controls are omitted there, since they would be
442
444
  inert).
443
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
+
444
489
  ## Documentation
445
490
 
446
491
  - [`docs/editor-api.md`](./docs/editor-api.md) — the `<Editor>` props and the
@@ -490,12 +535,35 @@ over the same round-trip fixture corpus the package's own test suite uses, so
490
535
  every fixture-representable node class renders somewhere in it. See
491
536
  [`examples/demo/README.md`](./examples/demo/README.md).
492
537
 
493
- `examples/demo` is also the source of the public GitHub Pages site: a
538
+ `examples/demo` is also the demo on the public GitHub Pages site, at
539
+ **[v3rv.com/liminis-editor/demo/](https://v3rv.com/liminis-editor/demo/)** — a
494
540
  `release`-triggered (not merge-triggered) build of this same shell, showing a
495
- visible version badge for the published release it represents and a
496
- Documentation tab rendering this README. Between releases the deployed site
497
- stays on the last published version even as `main` keeps moving — that
498
- staleness is intentional, not a bug (see `docs/decisions/adr-082.md`).
541
+ visible version badge for the published release it represents. Between releases
542
+ the deployed site stays on the last published version even as `main` keeps
543
+ moving that staleness is intentional, not a bug (see
544
+ `docs/decisions/adr-082.md`).
545
+
546
+ ## Documentation site
547
+
548
+ **[v3rv.com/liminis-editor](https://v3rv.com/liminis-editor/)** publishes the
549
+ pages in [`docs/`](./docs/) with search, cross-links, and the C4 diagrams
550
+ rendered live rather than as pictures.
551
+
552
+ `docs/*.md` remains the source of truth and is not moved or modified for the
553
+ site: it ships inside the published tarball, `tests/adr-citations.test.ts` walks
554
+ it, and it is cited by path from ADRs, the CHANGELOG and source comments.
555
+ `site/scripts/sync-docs.mjs` generates the site's copy from it, taking each
556
+ page's title from its H1.
557
+
558
+ ```bash
559
+ pnpm --dir site demo # build the demo and stage it at /demo/ (once)
560
+ pnpm --dir site dev # serve the docs at http://localhost:4321/liminis-editor/
561
+ pnpm --dir site diagrams # re-render the committed SVGs after editing a ```c4 fence
562
+ ```
563
+
564
+ `site/` is its own package, deliberately not a workspace member — the same
565
+ reasoning as `examples/`. An Astro toolchain has no business in the dev install
566
+ CI runs for lint, typecheck and test.
499
567
 
500
568
  ## Electron e2e shell
501
569
 
@@ -510,7 +578,8 @@ application, to exist. See
510
578
  ```bash
511
579
  pnpm build:examples # builds and packs the package once, then builds both
512
580
  # examples/demo and examples/electron against it
513
- pnpm build:site # builds examples/demo only — what the release-triggered
581
+ pnpm build:site # builds examples/demo only — wrapped by the site's
582
+ # `pnpm --dir site demo`, which the release-triggered
514
583
  # Pages deploy runs (see the Demo section above)
515
584
  ```
516
585
 
@@ -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;