@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.
Files changed (41) hide show
  1. package/README.md +66 -1
  2. package/dist/app/editor/LinkClickPlugin.js +5 -1
  3. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
  4. package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
  5. package/dist/app/editor/editorNodes.js +3 -1
  6. package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
  7. package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
  8. package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
  9. package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
  10. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  11. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  12. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  13. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  14. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  15. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  16. package/dist/app/editor/nodes/index.d.ts +4 -0
  17. package/dist/app/editor/nodes/index.js +2 -0
  18. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  19. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  20. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  21. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  22. package/dist/app/mapper/lexicalToMdast.js +116 -22
  23. package/dist/app/mapper/mdastToLexical.js +76 -4
  24. package/dist/host/defaults.js +1 -0
  25. package/dist/host/messages.d.ts +7 -1
  26. package/dist/host/messages.js +3 -3
  27. package/dist/host/types.d.ts +14 -1
  28. package/dist/markdown/parse.js +535 -1
  29. package/dist/markdown/stringify.js +35 -10
  30. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  31. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  32. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  33. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  34. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  35. package/dist/styles.css +48 -0
  36. package/dist/types.d.ts +1 -0
  37. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  38. package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +323 -6
  41. 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
- * Four deliberate divergences from upstream:
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
- * Four deliberate divergences from upstream:
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
- * One deliberate divergence from upstream: upstream imports
6
- * `mdast-util-to-markdown/lib/util/safe` from **v0.6.5** of that package, a
7
- * deep import into a v0 duplicate of the v2 `mdast-util-to-markdown` this
8
- * package already uses. That duplicate (and upstream's `@babel/runtime`
9
- * dependency) exists solely to serve this file. Here the same escaping is done
10
- * through v2's `state.safe()`, which is the supported API and produces the same
11
- * result for the `{ before: '[', after: ']' }` case this handler uses.
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 ? `[[${nodeValue}${aliasDivider}${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)