@liminis/editor 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -1
- 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 +3 -1
- package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
- package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
- package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
- package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
- 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 +4 -0
- package/dist/app/editor/nodes/index.js +2 -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 +116 -22
- package/dist/app/mapper/mdastToLexical.js +76 -4
- 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 +535 -1
- package/dist/markdown/stringify.js +35 -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/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +323 -6
- package/package.json +5 -3
|
@@ -19,10 +19,19 @@ export class CustomLinkNode extends LinkNode {
|
|
|
19
19
|
* @internal
|
|
20
20
|
*/
|
|
21
21
|
__wikiLinkOrigin;
|
|
22
|
+
/**
|
|
23
|
+
* Obsidian-style `#^blockId` fragment (#119), when this link is a
|
|
24
|
+
* block-scoped wiki-link (`[[file#^id]]`). `null` for an ordinary
|
|
25
|
+
* file-only or heading-anchor wiki-link. Deliberately a field separate
|
|
26
|
+
* from `__url` — see `mdastToLexical.ts`/`lexicalToMdast.ts` for why.
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
__blockId;
|
|
22
30
|
constructor(url, attributes, key) {
|
|
23
31
|
super(url, attributes, key);
|
|
24
32
|
this.__wikiAliasState = null;
|
|
25
33
|
this.__wikiLinkOrigin = false;
|
|
34
|
+
this.__blockId = null;
|
|
26
35
|
}
|
|
27
36
|
static getType() {
|
|
28
37
|
return 'link'; // Use same type to replace the default LinkNode
|
|
@@ -31,6 +40,7 @@ export class CustomLinkNode extends LinkNode {
|
|
|
31
40
|
const cloned = new CustomLinkNode(node.__url, { rel: node.__rel, target: node.__target, title: node.__title }, node.__key);
|
|
32
41
|
cloned.__wikiAliasState = node.__wikiAliasState;
|
|
33
42
|
cloned.__wikiLinkOrigin = node.__wikiLinkOrigin;
|
|
43
|
+
cloned.__blockId = node.__blockId;
|
|
34
44
|
return cloned;
|
|
35
45
|
}
|
|
36
46
|
createDOM(config) {
|
|
@@ -54,6 +64,9 @@ export class CustomLinkNode extends LinkNode {
|
|
|
54
64
|
if (this.isWikiLink()) {
|
|
55
65
|
element.setAttribute('data-wiki-link', 'true');
|
|
56
66
|
element.setAttribute('data-wiki-target', this.__url);
|
|
67
|
+
if (this.__blockId) {
|
|
68
|
+
element.setAttribute('data-block-id', this.__blockId);
|
|
69
|
+
}
|
|
57
70
|
}
|
|
58
71
|
else if (this.isExternalLink()) {
|
|
59
72
|
// External links get blue styling to differentiate from wiki-links
|
|
@@ -97,6 +110,7 @@ export class CustomLinkNode extends LinkNode {
|
|
|
97
110
|
else {
|
|
98
111
|
anchor.removeAttribute('data-wiki-link');
|
|
99
112
|
anchor.removeAttribute('data-wiki-target');
|
|
113
|
+
anchor.removeAttribute('data-block-id');
|
|
100
114
|
anchor.classList.remove('editor-link-broken');
|
|
101
115
|
if (this.isExternalLink()) {
|
|
102
116
|
anchor.classList.add('editor-link-external');
|
|
@@ -106,6 +120,14 @@ export class CustomLinkNode extends LinkNode {
|
|
|
106
120
|
}
|
|
107
121
|
}
|
|
108
122
|
}
|
|
123
|
+
if (this.__blockId !== prevNode.__blockId) {
|
|
124
|
+
if (this.__blockId && this.isWikiLink()) {
|
|
125
|
+
anchor.setAttribute('data-block-id', this.__blockId);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
anchor.removeAttribute('data-block-id');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
109
131
|
if (target !== prevNode.__target) {
|
|
110
132
|
if (target) {
|
|
111
133
|
anchor.target = target;
|
|
@@ -155,6 +177,9 @@ export class CustomLinkNode extends LinkNode {
|
|
|
155
177
|
if (serializedNode.wikiLinkOrigin) {
|
|
156
178
|
node.__wikiLinkOrigin = true;
|
|
157
179
|
}
|
|
180
|
+
if (serializedNode.blockId) {
|
|
181
|
+
node.__blockId = serializedNode.blockId;
|
|
182
|
+
}
|
|
158
183
|
node.setFormat(serializedNode.format);
|
|
159
184
|
node.setIndent(serializedNode.indent);
|
|
160
185
|
node.setDirection(serializedNode.direction);
|
|
@@ -167,6 +192,7 @@ export class CustomLinkNode extends LinkNode {
|
|
|
167
192
|
version: 1,
|
|
168
193
|
wikiAliasState: this.__wikiAliasState ?? undefined,
|
|
169
194
|
wikiLinkOrigin: this.__wikiLinkOrigin ? true : undefined,
|
|
195
|
+
blockId: this.__blockId ?? undefined,
|
|
170
196
|
};
|
|
171
197
|
}
|
|
172
198
|
setWikiAliasState(state) {
|
|
@@ -183,6 +209,13 @@ export class CustomLinkNode extends LinkNode {
|
|
|
183
209
|
getWikiLinkOrigin() {
|
|
184
210
|
return this.__wikiLinkOrigin;
|
|
185
211
|
}
|
|
212
|
+
setBlockId(blockId) {
|
|
213
|
+
const writable = this.getWritable();
|
|
214
|
+
writable.__blockId = blockId;
|
|
215
|
+
}
|
|
216
|
+
getBlockId() {
|
|
217
|
+
return this.__blockId;
|
|
218
|
+
}
|
|
186
219
|
}
|
|
187
220
|
function convertAnchorElement(domNode) {
|
|
188
221
|
let node = null;
|
|
@@ -195,6 +228,10 @@ function convertAnchorElement(domNode) {
|
|
|
195
228
|
target: domNode.getAttribute('target'),
|
|
196
229
|
title: domNode.getAttribute('title'),
|
|
197
230
|
});
|
|
231
|
+
const blockId = domNode.getAttribute('data-block-id');
|
|
232
|
+
if (blockId) {
|
|
233
|
+
node.__blockId = blockId;
|
|
234
|
+
}
|
|
198
235
|
}
|
|
199
236
|
}
|
|
200
237
|
return { node };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransclusionComponent - resolves and renders a block transclusion
|
|
3
|
+
* (`![[file#^id]]`, #119)
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `MermaidComponent`'s split: `TransclusionNode` is a static,
|
|
6
|
+
* serializable value (`file`/`blockId`/`alias`); the actual host-resolver
|
|
7
|
+
* call happens here, inside the lazily-loaded component, since resolution is
|
|
8
|
+
* `Promise`-based and the mdast<->Lexical mapper is synchronous.
|
|
9
|
+
*/
|
|
10
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
11
|
+
import { useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { useEditorHost } from '../../../host/context.js';
|
|
13
|
+
import { resolveAndRenderTransclusion, renderTransclusionState, } from './transclusion-render.js';
|
|
14
|
+
import { renderTransclusionLoading } from './transclusion-loading.js';
|
|
15
|
+
// Matches WikiLinkExistencePlugin's debounce window for the same reason:
|
|
16
|
+
// both re-run a host resolver on every dirty editor update, and without
|
|
17
|
+
// debouncing that fires one resolver call (potentially I/O-bound) per
|
|
18
|
+
// keystroke per visible reference.
|
|
19
|
+
const RESOLVE_DEBOUNCE_MS = 300;
|
|
20
|
+
export default function TransclusionComponent({ file, blockId }) {
|
|
21
|
+
const [editor] = useLexicalComposerContext();
|
|
22
|
+
const { resolveTransclusion } = useEditorHost();
|
|
23
|
+
const [state, setState] = useState(null);
|
|
24
|
+
// Guards against a stale resolution landing after a newer one already
|
|
25
|
+
// started (e.g. file/blockId changed, or two update-listener firings
|
|
26
|
+
// overlap) — only the most recent request is allowed to commit state.
|
|
27
|
+
const generationRef = useRef(0);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
let debounceTimeout = null;
|
|
31
|
+
const resolve = async () => {
|
|
32
|
+
const generation = ++generationRef.current;
|
|
33
|
+
const result = await resolveAndRenderTransclusion(file, blockId, resolveTransclusion, []);
|
|
34
|
+
if (!cancelled && generation === generationRef.current) {
|
|
35
|
+
setState(result);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
void resolve();
|
|
39
|
+
// FR-006/SC-002: re-resolve on every document change so an edit to the
|
|
40
|
+
// source block — in this document, or elsewhere once the host's own
|
|
41
|
+
// resolver reflects it — shows up without the host having to remount
|
|
42
|
+
// the editor. Pull-based and unmemoized (every dirty update re-resolves
|
|
43
|
+
// every visible transclusion): an accepted v1 cost, not a correctness
|
|
44
|
+
// gap — see the Plan's "no push/invalidation channel" risk note. The
|
|
45
|
+
// debounce below only bounds *how often* that cost is paid per burst of
|
|
46
|
+
// edits, mirroring WikiLinkExistencePlugin's existing convention.
|
|
47
|
+
const unregister = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
|
|
48
|
+
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
|
|
49
|
+
if (debounceTimeout)
|
|
50
|
+
clearTimeout(debounceTimeout);
|
|
51
|
+
debounceTimeout = setTimeout(() => { void resolve(); }, RESOLVE_DEBOUNCE_MS);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
if (debounceTimeout)
|
|
57
|
+
clearTimeout(debounceTimeout);
|
|
58
|
+
unregister();
|
|
59
|
+
};
|
|
60
|
+
}, [editor, file, blockId, resolveTransclusion]);
|
|
61
|
+
if (state === null) {
|
|
62
|
+
return renderTransclusionLoading();
|
|
63
|
+
}
|
|
64
|
+
return renderTransclusionState(state);
|
|
65
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransclusionNode - Live-rendered block transclusion (`![[file#^id]]`, #119)
|
|
3
|
+
*
|
|
4
|
+
* An **inline** DecoratorNode, unlike the block-level `MermaidNode`: a
|
|
5
|
+
* `wikiEmbed` mdast node is phrasing content (same family as `wikiLink`/
|
|
6
|
+
* `image`), so this matches where it actually sits in the tree rather than
|
|
7
|
+
* forcing paragraph-promotion logic to accommodate it.
|
|
8
|
+
*
|
|
9
|
+
* `file`/`blockId` are the sole identity the node carries; `alias` is stored
|
|
10
|
+
* only for byte-identical round-trip (`![[file#^id|alias]]`) — an embed
|
|
11
|
+
* renders the resolved block's *live content*, never the alias text, so
|
|
12
|
+
* nothing here displays it. Content resolution is async and host-resolver-
|
|
13
|
+
* driven (see `transclusion-render.tsx`), so — mirroring `MermaidNode` — the
|
|
14
|
+
* actual resolver call happens inside the lazily-loaded `TransclusionComponent`
|
|
15
|
+
* at decorate-time, not here: this node is a static, serializable value.
|
|
16
|
+
*/
|
|
17
|
+
import { DecoratorNode, DOMConversionMap, DOMExportOutput, LexicalNode, NodeKey, SerializedLexicalNode, Spread } from 'lexical';
|
|
18
|
+
export type SerializedTransclusionNode = Spread<{
|
|
19
|
+
file: string;
|
|
20
|
+
blockId: string;
|
|
21
|
+
alias: string | null;
|
|
22
|
+
emptyAlias: boolean;
|
|
23
|
+
}, SerializedLexicalNode>;
|
|
24
|
+
export declare class TransclusionNode extends DecoratorNode<JSX.Element> {
|
|
25
|
+
__file: string;
|
|
26
|
+
__blockId: string;
|
|
27
|
+
__alias: string | null;
|
|
28
|
+
__emptyAlias: boolean;
|
|
29
|
+
static getType(): string;
|
|
30
|
+
static clone(node: TransclusionNode): TransclusionNode;
|
|
31
|
+
constructor(file: string, blockId: string, alias?: string | null, emptyAlias?: boolean, key?: NodeKey);
|
|
32
|
+
static importJSON(serializedNode: SerializedTransclusionNode): TransclusionNode;
|
|
33
|
+
exportJSON(): SerializedTransclusionNode;
|
|
34
|
+
createDOM(): HTMLElement;
|
|
35
|
+
exportDOM(): DOMExportOutput;
|
|
36
|
+
static importDOM(): DOMConversionMap | null;
|
|
37
|
+
updateDOM(): boolean;
|
|
38
|
+
isInline(): boolean;
|
|
39
|
+
getFile(): string;
|
|
40
|
+
getBlockId(): string;
|
|
41
|
+
getAlias(): string | null;
|
|
42
|
+
setAlias(alias: string | null): void;
|
|
43
|
+
getEmptyAlias(): boolean;
|
|
44
|
+
setEmptyAlias(emptyAlias: boolean): void;
|
|
45
|
+
decorate(): JSX.Element;
|
|
46
|
+
}
|
|
47
|
+
export declare function $createTransclusionNode(file: string, blockId: string, alias?: string | null, emptyAlias?: boolean): TransclusionNode;
|
|
48
|
+
export declare function $isTransclusionNode(node: LexicalNode | null | undefined): node is TransclusionNode;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components */
|
|
2
|
+
/**
|
|
3
|
+
* TransclusionNode - Live-rendered block transclusion (`![[file#^id]]`, #119)
|
|
4
|
+
*
|
|
5
|
+
* An **inline** DecoratorNode, unlike the block-level `MermaidNode`: a
|
|
6
|
+
* `wikiEmbed` mdast node is phrasing content (same family as `wikiLink`/
|
|
7
|
+
* `image`), so this matches where it actually sits in the tree rather than
|
|
8
|
+
* forcing paragraph-promotion logic to accommodate it.
|
|
9
|
+
*
|
|
10
|
+
* `file`/`blockId` are the sole identity the node carries; `alias` is stored
|
|
11
|
+
* only for byte-identical round-trip (`![[file#^id|alias]]`) — an embed
|
|
12
|
+
* renders the resolved block's *live content*, never the alias text, so
|
|
13
|
+
* nothing here displays it. Content resolution is async and host-resolver-
|
|
14
|
+
* driven (see `transclusion-render.tsx`), so — mirroring `MermaidNode` — the
|
|
15
|
+
* actual resolver call happens inside the lazily-loaded `TransclusionComponent`
|
|
16
|
+
* at decorate-time, not here: this node is a static, serializable value.
|
|
17
|
+
*/
|
|
18
|
+
import { DecoratorNode, $applyNodeReplacement, } from 'lexical';
|
|
19
|
+
import { createElement, lazy, Suspense } from 'react';
|
|
20
|
+
import { renderTransclusionLoading } from './transclusion-loading.js';
|
|
21
|
+
const TransclusionComponent = lazy(() => import('./TransclusionComponent.js'));
|
|
22
|
+
function $convertTransclusionElement(domNode) {
|
|
23
|
+
const file = domNode.getAttribute('data-lexical-transclusion-file');
|
|
24
|
+
const blockId = domNode.getAttribute('data-lexical-transclusion-block-id');
|
|
25
|
+
if (file && blockId) {
|
|
26
|
+
const alias = domNode.getAttribute('data-lexical-transclusion-alias');
|
|
27
|
+
const emptyAlias = domNode.getAttribute('data-lexical-transclusion-empty-alias') === 'true';
|
|
28
|
+
const node = $createTransclusionNode(file, blockId, alias, emptyAlias);
|
|
29
|
+
return { node };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
export class TransclusionNode extends DecoratorNode {
|
|
34
|
+
__file;
|
|
35
|
+
__blockId;
|
|
36
|
+
__alias;
|
|
37
|
+
__emptyAlias;
|
|
38
|
+
static getType() {
|
|
39
|
+
return 'transclusion';
|
|
40
|
+
}
|
|
41
|
+
static clone(node) {
|
|
42
|
+
return new TransclusionNode(node.__file, node.__blockId, node.__alias, node.__emptyAlias, node.__key);
|
|
43
|
+
}
|
|
44
|
+
constructor(file, blockId, alias = null, emptyAlias = false, key) {
|
|
45
|
+
super(key);
|
|
46
|
+
this.__file = file;
|
|
47
|
+
this.__blockId = blockId;
|
|
48
|
+
this.__alias = alias;
|
|
49
|
+
this.__emptyAlias = emptyAlias;
|
|
50
|
+
}
|
|
51
|
+
static importJSON(serializedNode) {
|
|
52
|
+
return $createTransclusionNode(serializedNode.file, serializedNode.blockId, serializedNode.alias, serializedNode.emptyAlias);
|
|
53
|
+
}
|
|
54
|
+
exportJSON() {
|
|
55
|
+
return {
|
|
56
|
+
type: 'transclusion',
|
|
57
|
+
version: 1,
|
|
58
|
+
file: this.__file,
|
|
59
|
+
blockId: this.__blockId,
|
|
60
|
+
alias: this.__alias,
|
|
61
|
+
emptyAlias: this.__emptyAlias,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
createDOM() {
|
|
65
|
+
const element = document.createElement('span');
|
|
66
|
+
element.className = 'editor-transclusion';
|
|
67
|
+
return element;
|
|
68
|
+
}
|
|
69
|
+
exportDOM() {
|
|
70
|
+
const element = document.createElement('span');
|
|
71
|
+
element.setAttribute('data-lexical-transclusion-file', this.__file);
|
|
72
|
+
element.setAttribute('data-lexical-transclusion-block-id', this.__blockId);
|
|
73
|
+
if (this.__alias !== null) {
|
|
74
|
+
element.setAttribute('data-lexical-transclusion-alias', this.__alias);
|
|
75
|
+
}
|
|
76
|
+
if (this.__emptyAlias) {
|
|
77
|
+
element.setAttribute('data-lexical-transclusion-empty-alias', 'true');
|
|
78
|
+
}
|
|
79
|
+
element.className = 'transclusion-export';
|
|
80
|
+
// Never crash a copy/paste-shaped export by trying to resolve content
|
|
81
|
+
// synchronously (FR-008) — a plain placeholder is a faithful, inert
|
|
82
|
+
// stand-in for the live view this node otherwise renders.
|
|
83
|
+
element.textContent = renderPlaceholderText(this.__file, this.__blockId);
|
|
84
|
+
return { element };
|
|
85
|
+
}
|
|
86
|
+
static importDOM() {
|
|
87
|
+
return {
|
|
88
|
+
span: (domNode) => {
|
|
89
|
+
if (!domNode.hasAttribute('data-lexical-transclusion-file')) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
conversion: $convertTransclusionElement,
|
|
94
|
+
priority: 2,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
updateDOM() {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
isInline() {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
getFile() {
|
|
106
|
+
return this.__file;
|
|
107
|
+
}
|
|
108
|
+
getBlockId() {
|
|
109
|
+
return this.__blockId;
|
|
110
|
+
}
|
|
111
|
+
getAlias() {
|
|
112
|
+
return this.__alias;
|
|
113
|
+
}
|
|
114
|
+
setAlias(alias) {
|
|
115
|
+
const writable = this.getWritable();
|
|
116
|
+
writable.__alias = alias;
|
|
117
|
+
}
|
|
118
|
+
getEmptyAlias() {
|
|
119
|
+
return this.__emptyAlias;
|
|
120
|
+
}
|
|
121
|
+
setEmptyAlias(emptyAlias) {
|
|
122
|
+
const writable = this.getWritable();
|
|
123
|
+
writable.__emptyAlias = emptyAlias;
|
|
124
|
+
}
|
|
125
|
+
decorate() {
|
|
126
|
+
return createElement(Suspense, { fallback: renderTransclusionLoading() }, createElement(TransclusionComponent, {
|
|
127
|
+
file: this.__file,
|
|
128
|
+
blockId: this.__blockId,
|
|
129
|
+
nodeKey: this.__key,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function renderPlaceholderText(file, blockId) {
|
|
134
|
+
return `![[${file}#^${blockId}]]`;
|
|
135
|
+
}
|
|
136
|
+
export function $createTransclusionNode(file, blockId, alias = null, emptyAlias = false) {
|
|
137
|
+
return $applyNodeReplacement(new TransclusionNode(file, blockId, alias, emptyAlias));
|
|
138
|
+
}
|
|
139
|
+
export function $isTransclusionNode(node) {
|
|
140
|
+
return node instanceof TransclusionNode;
|
|
141
|
+
}
|
|
@@ -10,6 +10,8 @@ export { EquationNode, $createEquationNode, $isEquationNode } from './EquationNo
|
|
|
10
10
|
export type { SerializedEquationNode } from './EquationNode.js';
|
|
11
11
|
export { MermaidNode, $createMermaidNode, $isMermaidNode } from './MermaidNode.js';
|
|
12
12
|
export type { SerializedMermaidNode } from './MermaidNode.js';
|
|
13
|
+
export { TransclusionNode, $createTransclusionNode, $isTransclusionNode } from './TransclusionNode.js';
|
|
14
|
+
export type { SerializedTransclusionNode } from './TransclusionNode.js';
|
|
13
15
|
export { C4Node, $createC4Node, $isC4Node } from './C4Node.js';
|
|
14
16
|
export type { SerializedC4Node } from './C4Node.js';
|
|
15
17
|
export { FrontmatterNode, $createFrontmatterNode, $isFrontmatterNode } from './FrontmatterNode.js';
|
|
@@ -22,6 +24,8 @@ export { CustomListItemNode, $createCustomListItemNode, $isCustomListItemNode }
|
|
|
22
24
|
export type { SerializedCustomListItemNode } from './CustomListItemNode.js';
|
|
23
25
|
export { FootnoteNode, $createFootnoteNode, $isFootnoteNode } from './FootnoteNode.js';
|
|
24
26
|
export type { SerializedFootnoteNode } from './FootnoteNode.js';
|
|
27
|
+
export { BlockAnchorNode, $createBlockAnchorNode, $isBlockAnchorNode } from './BlockAnchorNode.js';
|
|
28
|
+
export type { SerializedBlockAnchorNode } from './BlockAnchorNode.js';
|
|
25
29
|
export { DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, } from './DefinitionListNode.js';
|
|
26
30
|
export type { SerializedDefinitionListNode, SerializedDefinitionTermNode, SerializedDefinitionDescriptionNode, } from './DefinitionListNode.js';
|
|
27
31
|
export { HtmlNode, $createHtmlNode, $isHtmlNode } from './HtmlNode.js';
|
|
@@ -6,12 +6,14 @@ export { ImageNode, $createImageNode, $isImageNode } from './ImageNode.js';
|
|
|
6
6
|
export { HorizontalRuleNode, $createHorizontalRuleNode, $isHorizontalRuleNode } from './HorizontalRuleNode.js';
|
|
7
7
|
export { EquationNode, $createEquationNode, $isEquationNode } from './EquationNode.js';
|
|
8
8
|
export { MermaidNode, $createMermaidNode, $isMermaidNode } from './MermaidNode.js';
|
|
9
|
+
export { TransclusionNode, $createTransclusionNode, $isTransclusionNode } from './TransclusionNode.js';
|
|
9
10
|
export { C4Node, $createC4Node, $isC4Node } from './C4Node.js';
|
|
10
11
|
export { FrontmatterNode, $createFrontmatterNode, $isFrontmatterNode } from './FrontmatterNode.js';
|
|
11
12
|
export { CustomLinkNode, $createCustomLinkNode, $isCustomLinkNode } from './CustomLinkNode.js';
|
|
12
13
|
export { CustomListNode, $createCustomListNode, $isCustomListNode } from './CustomListNode.js';
|
|
13
14
|
export { CustomListItemNode, $createCustomListItemNode, $isCustomListItemNode } from './CustomListItemNode.js';
|
|
14
15
|
export { FootnoteNode, $createFootnoteNode, $isFootnoteNode } from './FootnoteNode.js';
|
|
16
|
+
export { BlockAnchorNode, $createBlockAnchorNode, $isBlockAnchorNode } from './BlockAnchorNode.js';
|
|
15
17
|
export { DefinitionListNode, DefinitionTermNode, DefinitionDescriptionNode, $createDefinitionListNode, $createDefinitionTermNode, $createDefinitionDescriptionNode, $isDefinitionListNode, $isDefinitionTermNode, $isDefinitionDescriptionNode, } from './DefinitionListNode.js';
|
|
16
18
|
export { HtmlNode, $createHtmlNode, $isHtmlNode } from './HtmlNode.js';
|
|
17
19
|
export { ListItemParagraphBreakNode, $createListItemParagraphBreakNode, $isListItemParagraphBreakNode, } from './ListItemParagraphBreakNode.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transient "waiting" visual for block transclusion (#119) — split into
|
|
3
|
+
* its own, deliberately dependency-light module.
|
|
4
|
+
*
|
|
5
|
+
* `TransclusionNode.tsx` needs this for its `Suspense` fallback, and
|
|
6
|
+
* `TransclusionNode.tsx` is part of the `./nodes` subpath's *static* (eagerly
|
|
7
|
+
* evaluated) import graph — unlike `TransclusionComponent.tsx`, which is only
|
|
8
|
+
* ever reached through a dynamic `import()`. `transclusion-render.tsx`
|
|
9
|
+
* imports `parseMarkdown` (and, transitively, the whole micromark/mdast-util
|
|
10
|
+
* pipeline) to do its real work; if `TransclusionNode.tsx` imported this
|
|
11
|
+
* function from that module instead, `./nodes` would statically pull in the
|
|
12
|
+
* entire markdown pipeline it does not otherwise need — exactly the weight
|
|
13
|
+
* `src/__tests__/nodes-subpath.test.ts` exists to keep out. Keeping this one
|
|
14
|
+
* function here, with no import of `transclusion-render.tsx`, is what keeps
|
|
15
|
+
* that boundary intact.
|
|
16
|
+
*/
|
|
17
|
+
import { type ReactNode } from 'react';
|
|
18
|
+
/**
|
|
19
|
+
* The transient "resolver call in flight" state — not part of
|
|
20
|
+
* `TransclusionRenderState` (`transclusion-render.tsx`) because it is a UI
|
|
21
|
+
* concern of the lazily-mounted `TransclusionComponent`, not an outcome the
|
|
22
|
+
* pure resolver ever produces (it only returns once fully settled). Also
|
|
23
|
+
* used as the `Suspense` fallback in `TransclusionNode.decorate()` while the
|
|
24
|
+
* component's own code chunk is still loading, so both "waiting" cases look
|
|
25
|
+
* the same.
|
|
26
|
+
*/
|
|
27
|
+
export declare function renderTransclusionLoading(): ReactNode;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transient "waiting" visual for block transclusion (#119) — split into
|
|
3
|
+
* its own, deliberately dependency-light module.
|
|
4
|
+
*
|
|
5
|
+
* `TransclusionNode.tsx` needs this for its `Suspense` fallback, and
|
|
6
|
+
* `TransclusionNode.tsx` is part of the `./nodes` subpath's *static* (eagerly
|
|
7
|
+
* evaluated) import graph — unlike `TransclusionComponent.tsx`, which is only
|
|
8
|
+
* ever reached through a dynamic `import()`. `transclusion-render.tsx`
|
|
9
|
+
* imports `parseMarkdown` (and, transitively, the whole micromark/mdast-util
|
|
10
|
+
* pipeline) to do its real work; if `TransclusionNode.tsx` imported this
|
|
11
|
+
* function from that module instead, `./nodes` would statically pull in the
|
|
12
|
+
* entire markdown pipeline it does not otherwise need — exactly the weight
|
|
13
|
+
* `src/__tests__/nodes-subpath.test.ts` exists to keep out. Keeping this one
|
|
14
|
+
* function here, with no import of `transclusion-render.tsx`, is what keeps
|
|
15
|
+
* that boundary intact.
|
|
16
|
+
*/
|
|
17
|
+
import { createElement } from 'react';
|
|
18
|
+
/**
|
|
19
|
+
* The transient "resolver call in flight" state — not part of
|
|
20
|
+
* `TransclusionRenderState` (`transclusion-render.tsx`) because it is a UI
|
|
21
|
+
* concern of the lazily-mounted `TransclusionComponent`, not an outcome the
|
|
22
|
+
* pure resolver ever produces (it only returns once fully settled). Also
|
|
23
|
+
* used as the `Suspense` fallback in `TransclusionNode.decorate()` while the
|
|
24
|
+
* component's own code chunk is still loading, so both "waiting" cases look
|
|
25
|
+
* the same.
|
|
26
|
+
*/
|
|
27
|
+
export function renderTransclusionLoading() {
|
|
28
|
+
return createElement('span', { className: 'editor-transclusion-loading' }, 'Loading…');
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, host-resolver-driven resolution and rendering for block transclusion
|
|
3
|
+
* (`![[file#^id]]`, #119). Split out from `TransclusionComponent.tsx` so the
|
|
4
|
+
* cycle/depth guard and the mdast->JSX mini renderer are unit-testable
|
|
5
|
+
* without mounting Lexical or React.
|
|
6
|
+
*
|
|
7
|
+
* Not a second `LexicalComposer`: transclusion is render-only (bidirectional
|
|
8
|
+
* editing of transcluded content is explicitly out of scope), so resolved
|
|
9
|
+
* block content is parsed once with the existing `parseMarkdown` and walked
|
|
10
|
+
* into plain React elements by a small dedicated renderer here, rather than
|
|
11
|
+
* mounting a second editable surface.
|
|
12
|
+
*/
|
|
13
|
+
import { type ReactNode } from 'react';
|
|
14
|
+
/** A host-injected resolver, matching `EditorHostServices.resolveTransclusion`. */
|
|
15
|
+
export type TransclusionResolver = (file: string, blockId: string) => Promise<string | null>;
|
|
16
|
+
/**
|
|
17
|
+
* Nested transclusion depth bound (FR-011). A Plan-stage numeric choice, not
|
|
18
|
+
* derived from anything structural — deep enough that legitimate nesting
|
|
19
|
+
* (a summary block quoting a handful of sub-tasks, one level each) never
|
|
20
|
+
* hits it, shallow enough that a missed-cycle edge case still terminates
|
|
21
|
+
* fast. Checked *before* the resolver call at each level, so a document
|
|
22
|
+
* that would exceed it never spends host I/O on content that gets discarded.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MAX_TRANSCLUSION_DEPTH = 8;
|
|
25
|
+
export type TransclusionRenderState = {
|
|
26
|
+
kind: 'resolved';
|
|
27
|
+
content: ReactNode;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'unresolved';
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'circular';
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'depth-exceeded';
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a `file#^blockId` reference to rendered content, guarding against
|
|
37
|
+
* cycles and unbounded nesting.
|
|
38
|
+
*
|
|
39
|
+
* `visitedPath` is the chain of `file#^blockId` keys already open on *this*
|
|
40
|
+
* branch of the resolution tree (ancestors, not a single global "already
|
|
41
|
+
* transcluded anywhere" set) — the same block transcluded from two unrelated
|
|
42
|
+
* sites in the same document must not falsely trip the cycle guard for the
|
|
43
|
+
* second site.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveAndRenderTransclusion(file: string, blockId: string, resolver: TransclusionResolver | undefined, visitedPath?: readonly string[]): Promise<TransclusionRenderState>;
|
|
46
|
+
/** Render a {@link TransclusionRenderState} to a React node, for both the
|
|
47
|
+
* top-level `TransclusionComponent` and a nested `wikiEmbed` inside
|
|
48
|
+
* resolved content — the two share the same visual vocabulary. */
|
|
49
|
+
export declare function renderTransclusionState(state: TransclusionRenderState): ReactNode;
|