@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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* package would silently get different — and wrong — wiki-link behaviour inside
|
|
9
9
|
* markdown tables (#347). Vendoring makes the package self-contained.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Five deliberate divergences from upstream:
|
|
12
12
|
* 1. The trailing-backslash strip (see `exitWikiLink`), previously carried as
|
|
13
13
|
* `liminis-app/patches/mdast-util-wiki-link@0.1.2.patch` in `verveguy/liminis`.
|
|
14
14
|
* 2. Real types instead of `any` on the public option and node shapes.
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* closure variable shared by every handler in one `fromMarkdown()` call.
|
|
17
17
|
* 4. `top()` asserts the stack frame is actually a wiki-link, so the
|
|
18
18
|
* cannot-nest assumption fails loudly rather than corrupting a sibling node.
|
|
19
|
+
* 5. A trailing `#^blockId` fragment (Obsidian block-reference syntax, #119)
|
|
20
|
+
* is split off `value` into `data.blockId` before `pageResolver` runs, so
|
|
21
|
+
* `data.permalink`/`data.exists` are computed from the file target alone.
|
|
22
|
+
* An ordinary heading anchor (`#heading`, no caret) is untouched — this
|
|
23
|
+
* only matches the caret-prefixed block-id form.
|
|
19
24
|
*
|
|
20
25
|
* Nothing else about the parse behaviour changes: `value`, `data.alias`,
|
|
21
26
|
* `data.permalink`, `data.exists`, `data.hName`, `data.hProperties` and
|
|
@@ -37,6 +42,8 @@ interface WikiLinkNode {
|
|
|
37
42
|
alias: string | null;
|
|
38
43
|
permalink: string | null;
|
|
39
44
|
exists: boolean | null;
|
|
45
|
+
/** Obsidian-style `#^blockId` fragment split off `value`, if present (#119). */
|
|
46
|
+
blockId?: string | null;
|
|
40
47
|
hName?: string;
|
|
41
48
|
hProperties?: {
|
|
42
49
|
className: string;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* package would silently get different — and wrong — wiki-link behaviour inside
|
|
9
9
|
* markdown tables (#347). Vendoring makes the package self-contained.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Five deliberate divergences from upstream:
|
|
12
12
|
* 1. The trailing-backslash strip (see `exitWikiLink`), previously carried as
|
|
13
13
|
* `liminis-app/patches/mdast-util-wiki-link@0.1.2.patch` in `verveguy/liminis`.
|
|
14
14
|
* 2. Real types instead of `any` on the public option and node shapes.
|
|
@@ -16,11 +16,24 @@
|
|
|
16
16
|
* closure variable shared by every handler in one `fromMarkdown()` call.
|
|
17
17
|
* 4. `top()` asserts the stack frame is actually a wiki-link, so the
|
|
18
18
|
* cannot-nest assumption fails loudly rather than corrupting a sibling node.
|
|
19
|
+
* 5. A trailing `#^blockId` fragment (Obsidian block-reference syntax, #119)
|
|
20
|
+
* is split off `value` into `data.blockId` before `pageResolver` runs, so
|
|
21
|
+
* `data.permalink`/`data.exists` are computed from the file target alone.
|
|
22
|
+
* An ordinary heading anchor (`#heading`, no caret) is untouched — this
|
|
23
|
+
* only matches the caret-prefixed block-id form.
|
|
19
24
|
*
|
|
20
25
|
* Nothing else about the parse behaviour changes: `value`, `data.alias`,
|
|
21
26
|
* `data.permalink`, `data.exists`, `data.hName`, `data.hProperties` and
|
|
22
27
|
* `data.hChildren` are computed exactly as upstream computes them.
|
|
23
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* Matches a trailing `#^blockId` fragment — the Obsidian block-reference
|
|
31
|
+
* convention — at the end of a wiki-link target. Deliberately narrower than a
|
|
32
|
+
* general `#fragment` match: an ordinary heading anchor (`[[file#heading]]`)
|
|
33
|
+
* has no caret and must keep flowing through the pre-existing same-file-anchor
|
|
34
|
+
* handling in the mapper untouched (FR-014).
|
|
35
|
+
*/
|
|
36
|
+
const BLOCK_ID_PATTERN = /#\^([^\s\]#]+)$/;
|
|
24
37
|
/**
|
|
25
38
|
* The node currently being built, read off the compile stack.
|
|
26
39
|
*
|
|
@@ -57,6 +70,7 @@ export function fromMarkdown(opts = {}) {
|
|
|
57
70
|
alias: null,
|
|
58
71
|
permalink: null,
|
|
59
72
|
exists: null,
|
|
73
|
+
blockId: null,
|
|
60
74
|
},
|
|
61
75
|
};
|
|
62
76
|
this.enter(node, token);
|
|
@@ -92,6 +106,17 @@ export function fromMarkdown(opts = {}) {
|
|
|
92
106
|
wikiLink.value = wikiLink.value.slice(0, -1);
|
|
93
107
|
}
|
|
94
108
|
// --------------------------------------------------------------------------
|
|
109
|
+
// --- Liminis divergence from upstream (#119) ------------------------------
|
|
110
|
+
// Split a trailing `#^blockId` fragment off the target before resolving,
|
|
111
|
+
// so `data.permalink`/`data.exists` are derived from the file target
|
|
112
|
+
// alone and a block-scoped link (`[[file#^id]]`) resolves exactly like
|
|
113
|
+
// today's file-only `[[file]]` for existence-checking purposes.
|
|
114
|
+
const blockIdMatch = wikiLink.value ? BLOCK_ID_PATTERN.exec(wikiLink.value) : null;
|
|
115
|
+
if (blockIdMatch) {
|
|
116
|
+
wikiLink.data.blockId = blockIdMatch[1];
|
|
117
|
+
wikiLink.value = wikiLink.value.slice(0, blockIdMatch.index);
|
|
118
|
+
}
|
|
119
|
+
// --------------------------------------------------------------------------
|
|
95
120
|
const pagePermalinks = pageResolver(wikiLink.value);
|
|
96
121
|
const target = pagePermalinks.find((p) => permalinks.includes(p));
|
|
97
122
|
const exists = target !== undefined;
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* Vendored from `mdast-util-wiki-link@0.1.2` (MIT, Mark Hudnall — see LICENSE
|
|
3
3
|
* in this directory).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `mdast-util-to-markdown/lib/util/safe` from **v0.6.5**
|
|
7
|
-
* deep import into a v0 duplicate of the v2
|
|
8
|
-
* package already uses. That duplicate
|
|
9
|
-
* dependency) exists solely to serve
|
|
10
|
-
* through v2's `state.safe()`,
|
|
11
|
-
*
|
|
5
|
+
* Two deliberate divergences from upstream:
|
|
6
|
+
* 1. Upstream imports `mdast-util-to-markdown/lib/util/safe` from **v0.6.5**
|
|
7
|
+
* of that package, a deep import into a v0 duplicate of the v2
|
|
8
|
+
* `mdast-util-to-markdown` this package already uses. That duplicate
|
|
9
|
+
* (and upstream's `@babel/runtime` dependency) exists solely to serve
|
|
10
|
+
* this file. Here the same escaping is done through v2's `state.safe()`,
|
|
11
|
+
* which is the supported API and produces the same result for the
|
|
12
|
+
* `{ before: '[', after: ']' }` case this handler uses.
|
|
13
|
+
* 2. A `data.blockId` fragment (#119) is re-appended as `#^blockId` after
|
|
14
|
+
* the target, mirroring the split `from-markdown.ts` performs on the way
|
|
15
|
+
* in — so a raw `./markdown`-subpath consumer building `wikiLink` nodes
|
|
16
|
+
* by hand (not just `parseMarkdown`) gets byte-identical round-tripping
|
|
17
|
+
* of the block-id fragment.
|
|
12
18
|
*/
|
|
13
19
|
import type { Options as ToMarkdownExtension } from 'mdast-util-to-markdown';
|
|
14
20
|
export interface WikiLinkToMarkdownOptions {
|
|
@@ -4,6 +4,8 @@ export function toMarkdown(opts = {}) {
|
|
|
4
4
|
const wikiLink = node;
|
|
5
5
|
const exit = state.enter('wikiLink');
|
|
6
6
|
const nodeValue = state.safe(wikiLink.value, { before: '[', after: ']' });
|
|
7
|
+
const blockId = wikiLink.data?.blockId;
|
|
8
|
+
const targetText = typeof blockId === 'string' && blockId.length > 0 ? `${nodeValue}#^${blockId}` : nodeValue;
|
|
7
9
|
// Second deliberate divergence from upstream. Upstream passes the alias
|
|
8
10
|
// through `safe()` unconditionally; `safe(undefined)` yields `''`, which is
|
|
9
11
|
// then unequal to a non-empty target, so a node carrying *no* alias
|
|
@@ -17,7 +19,7 @@ export function toMarkdown(opts = {}) {
|
|
|
17
19
|
const rawAlias = wikiLink.data?.alias;
|
|
18
20
|
const hasAlias = typeof rawAlias === 'string' && rawAlias.length > 0;
|
|
19
21
|
const nodeAlias = hasAlias ? state.safe(rawAlias, { before: '[', after: ']' }) : nodeValue;
|
|
20
|
-
const value = nodeAlias !== nodeValue ? `[[${
|
|
22
|
+
const value = nodeAlias !== nodeValue ? `[[${targetText}${aliasDivider}${nodeAlias}]]` : `[[${targetText}]]`;
|
|
21
23
|
exit();
|
|
22
24
|
return value;
|
|
23
25
|
};
|
package/dist/styles.css
CHANGED
|
@@ -669,6 +669,54 @@ body {
|
|
|
669
669
|
opacity: 0.8;
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
/* Block transclusion (#119): live-rendered content from `![[file#^id]]`. Five
|
|
673
|
+
states — do not collapse "unresolved"/"circular"/"depth-exceeded" into one
|
|
674
|
+
style; each needs to read as a distinct failure mode at a glance. */
|
|
675
|
+
.editor-transclusion-content {
|
|
676
|
+
display: inline;
|
|
677
|
+
padding: 0 0.2em;
|
|
678
|
+
border-left: 2px solid var(--liminis-editor-border);
|
|
679
|
+
background: var(--liminis-editor-code-bg);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.editor-transclusion-loading {
|
|
683
|
+
color: var(--liminis-editor-foreground-muted);
|
|
684
|
+
font-style: italic;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.editor-transclusion-unresolved,
|
|
688
|
+
.editor-transclusion-circular,
|
|
689
|
+
.editor-transclusion-depth-exceeded {
|
|
690
|
+
color: var(--liminis-editor-errorForeground);
|
|
691
|
+
border: 1px dashed var(--liminis-editor-errorForeground);
|
|
692
|
+
border-radius: 3px;
|
|
693
|
+
padding: 0 0.3em;
|
|
694
|
+
font-style: italic;
|
|
695
|
+
font-size: 0.9em;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.editor-transclusion-paragraph {
|
|
699
|
+
display: inline;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.editor-transclusion-list,
|
|
703
|
+
.editor-transclusion-list-item {
|
|
704
|
+
display: inline;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.editor-transclusion-list-item + .editor-transclusion-list-item::before {
|
|
708
|
+
content: ' ';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.editor-transclusion-list-item input[type='checkbox'] {
|
|
712
|
+
margin-right: 0.2em;
|
|
713
|
+
vertical-align: middle;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.editor-transclusion-heading {
|
|
717
|
+
font-weight: 600;
|
|
718
|
+
}
|
|
719
|
+
|
|
672
720
|
/* Tables */
|
|
673
721
|
.editor-table {
|
|
674
722
|
width: 100%;
|
package/dist/types.d.ts
CHANGED
|
@@ -290,6 +290,7 @@ export type UIToHostMessage = {
|
|
|
290
290
|
} | {
|
|
291
291
|
type: 'OPEN_LINK';
|
|
292
292
|
url: string;
|
|
293
|
+
blockId?: string;
|
|
293
294
|
};
|
|
294
295
|
export declare function validateHostToUIMessage(data: unknown): HostToUIMessage | null;
|
|
295
296
|
export type BlockType = 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'bulletList' | 'numberedList' | 'todoList' | 'quote' | 'code' | 'divider' | 'image' | 'table' | 'toggle' | 'callout' | 'link';
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# ADR-119: Block-Scoped Links and Transclusion Extend the Wiki-Link Construct, Not a Parallel Syntax
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-09-08
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #119 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
Liminis (the host app) mints a stable `^ULID` on structured content —
|
|
12
|
+
overwhelmingly checkbox action items — to unify restated occurrences of the
|
|
13
|
+
same task and flip every one when it completes. Until this issue, that id
|
|
14
|
+
was inert as far as this package's markdown pipeline was concerned: no
|
|
15
|
+
parser, node type, or renderer understood it. The issue asks for two
|
|
16
|
+
Obsidian-style capabilities built on the existing `[[wikilink]]` construct
|
|
17
|
+
rather than a second bracket family: `[[file#^id]]` as a block-scoped link,
|
|
18
|
+
and `![[file#^id]]` as a live transclusion of that block's current content.
|
|
19
|
+
|
|
20
|
+
Research traced two load-bearing constraints before any design could
|
|
21
|
+
proceed:
|
|
22
|
+
|
|
23
|
+
1. **The micromark wiki-link tokenizer is external, unvendored, and has no
|
|
24
|
+
`!`-prefix or `#`-fragment awareness.** `micromark-extension-wiki-link`
|
|
25
|
+
hooks only the `[` character; a leading `!` is claimed first by the
|
|
26
|
+
default image-label-start construct, and CommonMark's own bracket
|
|
27
|
+
resolution falls the *entire* `![[target]]` span back to literal text
|
|
28
|
+
when that construct fails to find `(url)`/`[ref]` — so the wiki-link
|
|
29
|
+
tokenizer never even gets a chance to fire on the inner `[[`. Vendoring a
|
|
30
|
+
second tokenizer package (mirroring how `mdast-util-wiki-link` was
|
|
31
|
+
already vendored for #347) was the obvious fix and the most expensive
|
|
32
|
+
one Research flagged: a new LICENSE/provenance trail, a second
|
|
33
|
+
divergence log, and parity tests, for behavior that (unlike #347) is
|
|
34
|
+
genuinely new syntax rather than a patch to existing behavior.
|
|
35
|
+
2. **A round-trip bug already lived on this issue's own headline example.**
|
|
36
|
+
`mdastToLexical.ts` keeps a `.md` extension in the Lexical URL for an
|
|
37
|
+
anchor-suffixed target (`notes.md#^01ABC`), but `lexicalToMdast.ts`
|
|
38
|
+
unconditionally strips `.md#` back to `#` on export
|
|
39
|
+
(`notes#^01ABC`) — not byte-identical to the input. Reusing the existing
|
|
40
|
+
single-URL-string channel for the new block-id fragment would inherit
|
|
41
|
+
this asymmetry on day one.
|
|
42
|
+
|
|
43
|
+
## Decision
|
|
44
|
+
|
|
45
|
+
**Extend `[[wikilink]]`, do not introduce a parallel bracket syntax**
|
|
46
|
+
(FR-001/FR-002), and land the tokenizer-level `!`-detection problem in
|
|
47
|
+
`parse.ts`'s existing text-level pre/post-processing layer instead of a
|
|
48
|
+
second vendored package.
|
|
49
|
+
|
|
50
|
+
### 1. A same-length sentinel substitution replaces vendoring a second tokenizer
|
|
51
|
+
|
|
52
|
+
Before parsing, `parse.ts` swaps a `!` for a Private-Use-Area codepoint
|
|
53
|
+
(`\u{E005}`, the next free slot after `annotate-sentinels.ts`'s E000–E003
|
|
54
|
+
and `stringify.ts`'s E004) — but **only** when it is immediately followed by
|
|
55
|
+
a complete, single-line `[[...]]` span containing no internal `]`. That
|
|
56
|
+
condition is not incidental: it is exactly the grammar
|
|
57
|
+
`micromark-extension-wiki-link`'s own `consumeTarget`/`consumeAlias` states
|
|
58
|
+
require to succeed (an un-doubled `]` or a line ending aborts the whole
|
|
59
|
+
construct). A naive `!(?=\[\[)` lookahead — tried first, and initially
|
|
60
|
+
shipped — collides with a real image whose alt text happens to start with a
|
|
61
|
+
literal bracket (`![[leading] bracket](img.png)` is `![` + alt text
|
|
62
|
+
`[leading] bracket` + `](img.png)`, containing the raw substring `![[`);
|
|
63
|
+
substituting there prevents the image construct (which needs the literal
|
|
64
|
+
`!`) from ever being tried. The `903-image-alt-leading-bracket` regression
|
|
65
|
+
fixture caught this before merge; the narrower pattern fixes it because the
|
|
66
|
+
run between `[[` and `]]` in that image hits a single `]` and never
|
|
67
|
+
completes.
|
|
68
|
+
|
|
69
|
+
After parsing, a post-process retypes a sentinel-preceded `wikiLink` node to
|
|
70
|
+
`wikiEmbed` **only when it carries a `blockId`**; otherwise it restores the
|
|
71
|
+
literal `!` (FR-013 — `![[file]]` with no anchor degrades to an ordinary
|
|
72
|
+
`[[file]]` link, not an embed, matching what a hand-typed `\![[file]]`
|
|
73
|
+
already does with no sentinel involved at all). Since the swap is one
|
|
74
|
+
codepoint for one codepoint, it needs no offset-remapping — unlike the
|
|
75
|
+
pipe-escaping/empty-alias-normalization steps already in this file, which
|
|
76
|
+
change length and do carry `Replacement` tracking.
|
|
77
|
+
|
|
78
|
+
### 2. `#^blockId` extraction lives in the vendored `mdast-util-wiki-link`, not in `parse.ts`
|
|
79
|
+
|
|
80
|
+
A trailing `#^blockId` fragment is split off `wikiLink.value` into
|
|
81
|
+
`data.blockId` inside the vendored `from-markdown.ts`, before
|
|
82
|
+
`pageResolver` runs (so `data.permalink`/`data.exists` are computed from
|
|
83
|
+
the file target alone) — and re-appended by `to-markdown.ts` on the way
|
|
84
|
+
out. This is pure token-value splitting, not pipeline-level text surgery,
|
|
85
|
+
so a raw `./markdown`-subpath consumer building their own pipeline gets
|
|
86
|
+
`data.blockId` for free. The `!`-prefixed embed marker does **not** get
|
|
87
|
+
this treatment: it stays main-`parseMarkdown`-pipeline-only, the same
|
|
88
|
+
asymmetry this package already accepts for empty-alias normalization and
|
|
89
|
+
pipe-escaping (see `docs/markdown-pipeline.md`).
|
|
90
|
+
|
|
91
|
+
### 3. File and blockId are separate fields everywhere, never folded into `url`/`value`
|
|
92
|
+
|
|
93
|
+
mdast carries `data.blockId` alongside `value` (the file target). Lexical
|
|
94
|
+
carries it as `CustomLinkNode.__blockId`, a field independent of `__url`.
|
|
95
|
+
This is not a style preference — it is what sidesteps the `.md#`-stripping
|
|
96
|
+
bug traced in Context without depending on that pre-existing bug being
|
|
97
|
+
fixed first, and it is what User Story 1 asks for directly ("a link
|
|
98
|
+
carrying both the file target and the block id" as identifiable data, not
|
|
99
|
+
an opaque combined string).
|
|
100
|
+
|
|
101
|
+
### 4. Transclusion is a new node type end-to-end: mdast `wikiEmbed`, Lexical `TransclusionNode`
|
|
102
|
+
|
|
103
|
+
Not a boolean field on `wikiLink`. Every existing `wikiLink` consumer
|
|
104
|
+
(including this repo's own mapper) is untouched — zero risk to FR-014 — at
|
|
105
|
+
the cost of the `./markdown`-parity gap already accepted in point 2.
|
|
106
|
+
`TransclusionNode` is an **inline** `DecoratorNode`, unlike block-level
|
|
107
|
+
`MermaidNode`: `wikiEmbed` is phrasing content (the same mdast family as
|
|
108
|
+
`wikiLink`/`image`), so this matches where it actually sits in the tree
|
|
109
|
+
rather than forcing paragraph-promotion logic to accommodate it.
|
|
110
|
+
`alias`/`emptyAlias` are stored on the node purely for byte-identical
|
|
111
|
+
round-trip — an embed renders live content, never its alias text, so
|
|
112
|
+
(unlike `wikiLink`) there is no rendered text on export to infer "was there
|
|
113
|
+
an alias" from; it has to be an explicit field.
|
|
114
|
+
|
|
115
|
+
### 5. One resolver, two consumers
|
|
116
|
+
|
|
117
|
+
`resolveTransclusion(file, blockId) => Promise<string | null>` is the only
|
|
118
|
+
new host service. It backs both transclusion content
|
|
119
|
+
(`TransclusionComponent`) and block-scoped-link existence styling
|
|
120
|
+
(`WikiLinkExistencePlugin`, which now splits plain and block-scoped links
|
|
121
|
+
into separate resolver paths) — avoiding a second, redundant host contract
|
|
122
|
+
for what is, from an existence-checking point of view, the same question
|
|
123
|
+
("does `file#^id` resolve to something").
|
|
124
|
+
|
|
125
|
+
### 6. Resolution happens in the lazily-loaded component, not the mapper
|
|
126
|
+
|
|
127
|
+
`mdastToLexical.ts`/`lexicalToMdast.ts` stay synchronous; the actual
|
|
128
|
+
resolver call happens inside `TransclusionComponent`, mirroring
|
|
129
|
+
`MermaidNode`/`MermaidComponent`'s existing static-node-plus-async-component
|
|
130
|
+
split. `resolveAndRenderTransclusion` (in `transclusion-render.tsx`) is
|
|
131
|
+
pure and framework-light enough to unit-test without mounting Lexical or
|
|
132
|
+
React at all.
|
|
133
|
+
|
|
134
|
+
### 7. Cycle guard: per-branch visited path; depth bound: 8
|
|
135
|
+
|
|
136
|
+
The guard is a visited-path array of `file#^blockId` keys threaded through
|
|
137
|
+
recursive resolution, **not** a single global "already transcluded
|
|
138
|
+
anywhere" set — the same block transcluded from two unrelated sites in one
|
|
139
|
+
document must not falsely trip the cycle check for the second site (a
|
|
140
|
+
requirement the fixture/unit-test suite pins down explicitly). The depth
|
|
141
|
+
bound (8) is checked *before* each level's resolver call, so a document
|
|
142
|
+
that would exceed it never spends host I/O on content that gets discarded.
|
|
143
|
+
Both are Plan-stage numeric/algorithmic choices with no existing precedent
|
|
144
|
+
in this codebase to inherit from — no recursion-guard or visited-set
|
|
145
|
+
pattern for cross-block content resolution existed here before this issue.
|
|
146
|
+
|
|
147
|
+
### 8. The transclusion mini-renderer is intentionally partial
|
|
148
|
+
|
|
149
|
+
Resolved content is parsed with the existing `parseMarkdown` and walked by
|
|
150
|
+
a small dedicated mdast→JSX renderer — not a second `LexicalComposer`
|
|
151
|
+
instance, since transclusion is explicitly render-only (bidirectional
|
|
152
|
+
editing of transcluded content is Out of Scope). Coverage is scoped to
|
|
153
|
+
paragraph/inline-formatting/checkbox-list-items/inline-code/nested-
|
|
154
|
+
`wikiEmbed`, with a generic plain-text fallback (recursively join every
|
|
155
|
+
`.value` found) for anything else. This satisfies "never crash on arbitrary
|
|
156
|
+
block content" (FR-015 — any `^id`-carrying block is a valid target, not
|
|
157
|
+
just checkboxes) without committing to full visual parity for every mdast
|
|
158
|
+
construct.
|
|
159
|
+
|
|
160
|
+
### 9. `openLink` gets an additive optional `blockId` parameter
|
|
161
|
+
|
|
162
|
+
A host that has not implemented block-aware navigation still receives
|
|
163
|
+
`url` and opens the file exactly as today — FR-004's "degrades no worse
|
|
164
|
+
than today's file-only wikilink navigation" falls out for free, with no
|
|
165
|
+
host-side change required. The `OPEN_LINK` wire message omits `blockId`
|
|
166
|
+
entirely when absent, rather than sending `blockId: undefined`.
|
|
167
|
+
|
|
168
|
+
## Consequences
|
|
169
|
+
|
|
170
|
+
**Good:**
|
|
171
|
+
|
|
172
|
+
- `[[file#^id]]` and `![[file#^id]]` round-trip byte-identically through
|
|
173
|
+
`parseMarkdown -> stringifyMarkdown` (SC-001), verified for both forms,
|
|
174
|
+
aliased and not, standalone and inside a table cell (the #347
|
|
175
|
+
pipe-in-tables regression class).
|
|
176
|
+
- No existing wiki-link behavior changed: the full pre-existing wiki-link
|
|
177
|
+
and #347 test suites pass unmodified (SC-005), and `<Editor>` mounted
|
|
178
|
+
with no host services at all renders `![[file#^id]]` without throwing,
|
|
179
|
+
as a clearly marked "unresolved" placeholder (SC-003/FR-008).
|
|
180
|
+
- A constructed transclusion cycle (direct, longer A→B→C→A, and unrelated
|
|
181
|
+
same-block-two-sites) all resolve to the correct terminal state — cycle,
|
|
182
|
+
resolved, or depth-exceeded — verified by automated test (SC-004), with
|
|
183
|
+
no vendored second tokenizer package and its associated LICENSE/parity
|
|
184
|
+
burden.
|
|
185
|
+
|
|
186
|
+
**Bad / accepted:**
|
|
187
|
+
|
|
188
|
+
- **`./markdown`-subpath parity gap grows.** A raw external consumer
|
|
189
|
+
building their own pipeline from the exported extensions gets
|
|
190
|
+
`data.blockId` on `wikiLink` for free, but never sees a `wikiEmbed` node
|
|
191
|
+
at all — embed detection is main-`parseMarkdown`-pipeline-only. This
|
|
192
|
+
mirrors an asymmetry this package already had (empty-alias handling,
|
|
193
|
+
pipe-escaping), documented in `docs/markdown-pipeline.md` rather than
|
|
194
|
+
left implicit.
|
|
195
|
+
- **`resolveTransclusion` is pull-based, with no push/invalidation
|
|
196
|
+
channel.** "Reflects the change on next render" (FR-006) means exactly
|
|
197
|
+
that: `TransclusionComponent` re-resolves on every Lexical editor update
|
|
198
|
+
in its own document, which covers same-document edits automatically, but
|
|
199
|
+
a change to the source block made *elsewhere* (a different open
|
|
200
|
+
document, or outside the editor entirely) only shows up once the host
|
|
201
|
+
itself triggers a re-render of the transcluding document. A host wanting
|
|
202
|
+
tighter cross-document freshness has to build that itself.
|
|
203
|
+
- **Recursive resolution is unmemoized.** Every visible transclusion
|
|
204
|
+
re-resolves, and re-parses its resolved markdown, on every dirty editor
|
|
205
|
+
update, with no caching across renders. Acceptable given the depth bound
|
|
206
|
+
caps worst-case fan-out per transclusion site, but a future optimization
|
|
207
|
+
target if hosts report cost on documents with many transclusions.
|
|
208
|
+
- **The mini-renderer's node coverage is deliberately partial** (point 8).
|
|
209
|
+
A transcluded block using an mdast construct outside its handled set
|
|
210
|
+
(tables, footnotes, math, HTML) degrades to plain extracted text rather
|
|
211
|
+
than rendering richly. This is a documented v1 scope limit, not a defect
|
|
212
|
+
discovered later.
|
|
213
|
+
- **A wikilink target carrying an explicit `.md` extension does not
|
|
214
|
+
round-trip byte-identically through the *full* Lexical editor pipeline**
|
|
215
|
+
(parse → import → export → stringify), independent of blockId: separate
|
|
216
|
+
from the anchor-specific bug this ADR's Context traced (which point 3
|
|
217
|
+
above does fix), `lexicalToMdast.ts`'s `convertLinkNode` unconditionally
|
|
218
|
+
strips a trailing `.md` from any wikilink URL on export, and
|
|
219
|
+
`CustomLinkNode`'s URL is the only channel carrying the file target — it
|
|
220
|
+
cannot distinguish "the source wrote `notes.md`" from "the source wrote
|
|
221
|
+
`notes` and import added the extension". This is a pre-existing, general
|
|
222
|
+
property of `convertLinkNode` unrelated to and predating this issue;
|
|
223
|
+
fixing it is out of scope here. Recorded as
|
|
224
|
+
`fixtures/roundtrip/known-defects/other-wikilink-blockid-md-extension-stripped`
|
|
225
|
+
(see that corpus's `README.md`), not silently worked around.
|
|
226
|
+
|
|
227
|
+
**Neutral:**
|
|
228
|
+
|
|
229
|
+
- The headless `./nodes` entry point (no React mounted) gets an inert,
|
|
230
|
+
data-only `TransclusionNode` — consistent with how Mermaid/C4/equations
|
|
231
|
+
already behave there (lazy-render only when actually mounted in a
|
|
232
|
+
browser DOM), not a gap specific to this feature.
|
|
233
|
+
|
|
234
|
+
## References
|
|
235
|
+
|
|
236
|
+
- Issue #119 (this decision)
|
|
237
|
+
- `src/markdown/vendor/mdast-util-wiki-link/README.md` (the vendoring
|
|
238
|
+
rationale this issue extends rather than duplicates, and the fifth
|
|
239
|
+
divergence — blockId splitting — this issue adds to it)
|
|
240
|
+
- `docs/markdown-pipeline.md` (the `./markdown`-subpath parity gap this
|
|
241
|
+
issue grows, and the embed-sentinel mechanism)
|
|
242
|
+
- `docs/editor-api.md` (`resolveTransclusion` host-service row)
|
|
243
|
+
- `src/app/mapper/__tests__/fixtures/roundtrip/README.md` (the
|
|
244
|
+
`known-defects/` convention this issue's accepted `.md`-extension gap
|
|
245
|
+
follows)
|
|
246
|
+
- `docs/decisions/adr-075.md` (the host-seam / persistence boundary and
|
|
247
|
+
closed `exports` map this issue's resolver and node registration follow)
|