@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
@@ -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';
@@ -6,6 +6,7 @@ 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';
@@ -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;
@@ -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, $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,22 @@ 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
+ $isHtmlNode(node) ||
249
+ $isTransclusionNode(node));
244
250
  }
245
251
  /**
246
252
  * Inclusive bounds of the maximal run of consecutive siblings in `flat` that
@@ -790,6 +796,14 @@ function convertLexicalNode(node) {
790
796
  if ($isHtmlNode(node)) {
791
797
  return [{ type: 'html', value: node.getHtml() }];
792
798
  }
799
+ if ($isTransclusionNode(node)) {
800
+ // TransclusionNode is inline (#119) and is always constructed as a
801
+ // paragraph's child by mdastToLexical.ts; reached here only if it
802
+ // somehow ends up as a direct block-level child (e.g. a paste-driven
803
+ // DOM import) — wrap it in a paragraph rather than losing it, mirroring
804
+ // convertEquationNode's inline-equation branch above.
805
+ return [{ type: 'paragraph', children: [convertTransclusionNode(node)] }];
806
+ }
793
807
  // Fallback: create paragraph
794
808
  const paragraph = {
795
809
  type: 'paragraph',
@@ -1338,8 +1352,36 @@ function convertSingleInlineChild(child) {
1338
1352
  // Inline HTML preserved opaquely: convert back to a phrasing html mdast node
1339
1353
  return [{ type: 'html', value: child.getHtml() }];
1340
1354
  }
1355
+ else if ($isTransclusionNode(child)) {
1356
+ // Transclusion embed (#119): convert back to a wikiEmbed mdast node
1357
+ return [convertTransclusionNode(child)];
1358
+ }
1341
1359
  return [];
1342
1360
  }
1361
+ /**
1362
+ * Convert a `TransclusionNode` back to a `wikiEmbed` mdast node.
1363
+ *
1364
+ * `alias`/`_emptyAlias` are carried purely for byte-identical round-trip
1365
+ * (FR-003) — read directly off the node's own explicit fields rather than
1366
+ * inferred from any rendered text, since an embed never displays its alias
1367
+ * (see `mdastToLexical.ts`'s `$createTransclusionNodeFromMdast` for the
1368
+ * matching import-side reasoning).
1369
+ */
1370
+ function convertTransclusionNode(node) {
1371
+ const alias = node.getAlias();
1372
+ const data = { blockId: node.getBlockId() };
1373
+ if (alias) {
1374
+ data.alias = alias;
1375
+ }
1376
+ else if (node.getEmptyAlias()) {
1377
+ data._emptyAlias = true;
1378
+ }
1379
+ return {
1380
+ type: 'wikiEmbed',
1381
+ value: node.getFile(),
1382
+ data,
1383
+ };
1384
+ }
1343
1385
  // Flattens a list of leaf-level inline nodes into mdast content with no
1344
1386
  // format wrapping — the base case once every bold/italic/strikethrough bit
1345
1387
  // a run carries has been consumed by an enclosing wrapper.
@@ -1892,6 +1934,13 @@ function convertLinkNode(node) {
1892
1934
  else {
1893
1935
  data._noAlias = true;
1894
1936
  }
1937
+ // Block-scoped link (#119): carried as a field separate from `target`
1938
+ // (never folded into the value/URL string) — see CustomLinkNode's own
1939
+ // doc comment for why.
1940
+ const blockId = linkNode.getBlockId?.();
1941
+ if (blockId) {
1942
+ data.blockId = blockId;
1943
+ }
1895
1944
  return {
1896
1945
  type: 'wikiLink',
1897
1946
  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, $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) {
@@ -849,6 +889,12 @@ function convertInlineNode(node) {
849
889
  // (convertLinkNode) always emits it back as a wiki-link even when a host
850
890
  // has disabled promotion of ordinary links (liminis#951).
851
891
  link.setWikiLinkOrigin(true);
892
+ // Block-scoped link (#119): carried as a field separate from `url` so
893
+ // it never inherits the lossy `.md#`-anchor URL-string round trip the
894
+ // plain heading-anchor branches above use.
895
+ if (wikiLink.data?.blockId) {
896
+ link.setBlockId(wikiLink.data.blockId);
897
+ }
852
898
  // Preserve empty-alias state for round-trip
853
899
  if (wikiLink.data?._emptyAlias) {
854
900
  link.setWikiAliasState('empty');
@@ -862,6 +908,26 @@ function convertInlineNode(node) {
862
908
  link.append(textNode);
863
909
  return [link];
864
910
  }
911
+ case 'wikiEmbed': {
912
+ // Transclusion/embed from parse.ts's embed-sentinel post-process: `![[file#^id]]`
913
+ const wikiEmbed = node;
914
+ const target = wikiEmbed.value;
915
+ const blockId = wikiEmbed.data?.blockId;
916
+ // Never produced by parseMarkdown without both (FR-002/FR-013 guarantee
917
+ // a `wikiEmbed` always carries a file target and a blockId), but a
918
+ // hand-built mdast tree from an external `./markdown` consumer could
919
+ // still lack one — degrade to inert text rather than crash (FR-008's
920
+ // "never throw" spirit applies just as much to malformed input as to a
921
+ // missing resolver). Reconstructs whatever of the original syntax is
922
+ // available rather than dropping the content: a blank node would
923
+ // silently erase user-visible text for no parser-level reason.
924
+ if (!target || !blockId) {
925
+ console.warn('[mdastToLexical] wikiEmbed missing target or blockId:', wikiEmbed);
926
+ const fragment = blockId ? `${target ?? ''}#^${blockId}` : (target ?? '');
927
+ return [$createTextNode(`![[${fragment}]]`)];
928
+ }
929
+ return [$createTransclusionNodeFromMdast(target, blockId, wikiEmbed.data)];
930
+ }
865
931
  case 'html': {
866
932
  // Check for inline equation: $...$
867
933
  const html = node.value;