@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.
- package/README.md +77 -8
- package/dist/app/editor/LinkClickPlugin.js +5 -1
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
- package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
- package/dist/app/editor/editorNodes.js +2 -1
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
- package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
- package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
- package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
- package/dist/app/editor/nodes/TransclusionNode.js +141 -0
- package/dist/app/editor/nodes/index.d.ts +2 -0
- package/dist/app/editor/nodes/index.js +1 -0
- package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
- package/dist/app/editor/nodes/transclusion-loading.js +29 -0
- package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
- package/dist/app/editor/nodes/transclusion-render.js +186 -0
- package/dist/app/mapper/lexicalToMdast.js +55 -6
- package/dist/app/mapper/mdastToLexical.js +67 -1
- package/dist/host/defaults.js +1 -0
- package/dist/host/messages.d.ts +7 -1
- package/dist/host/messages.js +3 -3
- package/dist/host/types.d.ts +14 -1
- package/dist/markdown/parse.js +225 -1
- package/dist/markdown/stringify.js +30 -10
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
- package/dist/styles.css +48 -0
- package/dist/types.d.ts +1 -0
- package/docs/architecture.md +80 -0
- package/docs/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/diagrams/architecture-1-dark.svg +1 -0
- package/docs/diagrams/architecture-1.svg +1 -0
- package/docs/diagrams/architecture-2-dark.svg +1 -0
- package/docs/diagrams/architecture-2.svg +1 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +110 -6
- 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
|
|
235
|
-
* inline HTML. When one of these is a mark's
|
|
236
|
-
* that mark by construction, so the mark's
|
|
237
|
-
* whole construct rather than inside its
|
|
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) ||
|
|
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;
|