@liminis/editor 0.4.1 → 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 +45 -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 +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/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +110 -6
- package/package.json +5 -3
|
@@ -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)
|
package/docs/editor-api.md
CHANGED
|
@@ -234,6 +234,7 @@ import { EditorHostProvider, Editor } from '@liminis/editor'
|
|
|
234
234
|
| `bridge` | `EditorHostBridge` | The channel to your environment. Two methods only: `postMessage(message)` and `addMessageHandler(handler)` returning an unsubscribe. Every higher-level helper (`requestInit`, `applyTextEdits`, `writeAsset`, `openLink`) is built on `postMessage` inside the package, so every host adapter emits byte-identical payloads. Defaults to a no-op bridge. |
|
|
235
235
|
| `logger` | `(namespace: string) => EditorLogger` | Namespaced logger factory. `EditorLogger` is `{ debug, info, warn, error }`, structurally compatible with most app loggers. |
|
|
236
236
|
| `resolveWikiLinks` | `(targets: string[]) => Promise<Record<string, string \| null>>` | Map wiki-link targets to existing paths; `null` for unresolved. Drives the "this page does not exist yet" styling. |
|
|
237
|
+
| `resolveTransclusion` | `(file: string, blockId: string) => Promise<string \| null>` | Resolve a workspace-global block reference (`file#^blockId`, #119) to that block's current markdown content. Backs both live transclusion (`![[file#^id]]`) and existence styling for block-scoped links (`[[file#^id]]`). `null` for unresolved (unknown file or unknown block id — the parser does not distinguish the two; a resolver that cares to can). Absent, a transclusion renders a clearly marked "unresolved" placeholder rather than throwing. |
|
|
237
238
|
| `onScrollToAnchor` | `(cb: (anchor: string) => void) => () => void` | Subscribe to host-driven scroll requests. Returns an unsubscribe. |
|
|
238
239
|
| `notifyError` | `(message: string, description?: string) => void` | Surface a user-visible error. Defaults to a console warning. |
|
|
239
240
|
| `corrections` | `CorrectionHostServices` | Persistence and knowledge-graph services backing the correction feature: `readCorrections`, `writeCorrections`, `suggestEntities`, `suggestPassages`, `applyCorrections`. The in-editor correction UI is package-side; the file and the graph are yours. |
|
|
@@ -199,17 +199,121 @@ inside the extension:
|
|
|
199
199
|
`[[target]]` only because `parseMarkdown` substitutes a sentinel before parsing
|
|
200
200
|
and sets `data._emptyAlias` after. The extensions alone produce no wiki-link
|
|
201
201
|
node at all for that input.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
raw
|
|
202
|
+
- **Transclusion/embed detection (`![[target]]`, #119).** The `!`-prefixed form
|
|
203
|
+
is recognized only by `parseMarkdown`'s own embed-sentinel pre/post-processing
|
|
204
|
+
(see below) — the raw extensions have no concept of it at all. A `!` before a
|
|
205
|
+
raw-extension `[[...]]` is just ordinary preceding text; the extensions never
|
|
206
|
+
produce a `wikiEmbed` node. `data.blockId` on a plain `wikiLink` node *is*
|
|
207
|
+
available through the raw extensions (it's pure token-value splitting inside
|
|
208
|
+
the vendored `mdast-util-wiki-link`, not pipeline-level surgery) — only the
|
|
209
|
+
embed marker itself is main-pipeline-only.
|
|
210
|
+
|
|
211
|
+
So: **if your pipeline enables GFM tables, cares about `[[target|]]`, or needs
|
|
212
|
+
`![[target#^id]]` transclusion detection, call `parseMarkdown` rather than
|
|
213
|
+
assembling the extensions yourself.** Reach for the raw extensions only when
|
|
214
|
+
you control the input and none of those cases apply.
|
|
206
215
|
|
|
207
216
|
Note also that `<Editor>`'s own *serialization* does not go through
|
|
208
217
|
`wikiLinkToMarkdown`: `stringifyMarkdown` carries a hand-rolled wiki-link handler
|
|
209
|
-
that additionally understands `data._emptyAlias
|
|
210
|
-
faithful vendored upstream serializer
|
|
218
|
+
that additionally understands `data._emptyAlias` and `wikiEmbed` nodes.
|
|
219
|
+
`wikiLinkToMarkdown` is the faithful vendored upstream serializer (extended with
|
|
220
|
+
`data.blockId` re-appending, see below), not a byte-for-byte match for what the
|
|
211
221
|
editor emits.
|
|
212
222
|
|
|
223
|
+
## Block-scoped links and transclusion (#119)
|
|
224
|
+
|
|
225
|
+
`[[file#^id]]` is a block-scoped link — the same `[[...]]` construct as
|
|
226
|
+
above, extended with an optional `#^blockId` fragment (Obsidian's
|
|
227
|
+
block-reference convention: a caret immediately after the `#`). It parses to
|
|
228
|
+
the same `wikiLink` node shape, with `data.blockId` set:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
{ type: 'wikiLink', value: 'file', data: { alias, permalink, exists, blockId: 'id', /* … */ } }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
An ordinary heading anchor (`[[file#heading]]`, no caret) is untouched —
|
|
235
|
+
`data.blockId` is only ever set for the caret-prefixed form, so this is
|
|
236
|
+
purely additive to the existing anchor-link behavior described elsewhere in
|
|
237
|
+
this document.
|
|
238
|
+
|
|
239
|
+
`![[file#^id]]` — the same target+id addressing, `!`-prefixed — is
|
|
240
|
+
**transclusion**: a live, resolver-driven rendering of that block's current
|
|
241
|
+
content in place of the reference, not a link. It parses to a distinct
|
|
242
|
+
`wikiEmbed` node, not a `wikiLink` with a flag:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
{ type: 'wikiEmbed', value: 'file', data: { alias, blockId: 'id', /* … */ } }
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`![[file]]` with no `#^id` fragment (whole-file transclusion) is not a
|
|
249
|
+
supported construct — the parser leaves it as an ordinary `[[file]]` link
|
|
250
|
+
(no embedding), never a `wikiEmbed`.
|
|
251
|
+
|
|
252
|
+
### Why `wikiEmbed` is a separate node type, not a field on `wikiLink`
|
|
253
|
+
|
|
254
|
+
Every existing `wikiLink` consumer — including this repository's own
|
|
255
|
+
mdast↔Lexical mappers — is untouched by this addition. `[[file#^id]]` (link)
|
|
256
|
+
and `![[file#^id]]` (embed) are trivially distinguishable by `node.type` for
|
|
257
|
+
any downstream consumer, rather than requiring a new-field check added to
|
|
258
|
+
code that predates this feature.
|
|
259
|
+
|
|
260
|
+
### How the embed marker is detected without a second vendored tokenizer
|
|
261
|
+
|
|
262
|
+
`micromark-extension-wiki-link` (unvendored, straight from npm) hooks only
|
|
263
|
+
the `[` character, with no `!`-prefix awareness. A literal `!` immediately
|
|
264
|
+
before `[[` is claimed *first* by the default CommonMark image-label-start
|
|
265
|
+
construct; when that construct fails to find a following `(url)`/`[ref]`
|
|
266
|
+
(which it always does for `[[target]]` — that isn't image syntax), bracket
|
|
267
|
+
resolution falls the *entire* `![[target]]` span back to one literal text
|
|
268
|
+
node, without the wiki-link tokenizer ever getting a chance to fire on the
|
|
269
|
+
inner `[[`.
|
|
270
|
+
|
|
271
|
+
Rather than vendoring a second tokenizer package to add `!`-prefix detection
|
|
272
|
+
(the LICENSE/provenance/parity-test burden this repository already paid once
|
|
273
|
+
for #347's `mdast-util-wiki-link` fix), `parseMarkdown` swaps a `!` for a
|
|
274
|
+
Private-Use-Area sentinel codepoint *before* parsing — but only when it is
|
|
275
|
+
immediately followed by a complete, single-line `[[...]]` span with no
|
|
276
|
+
internal `]`, exactly the grammar the tokenizer's own target/alias states
|
|
277
|
+
require to succeed. That condition is load-bearing, not incidental: a naive
|
|
278
|
+
"any `!` before `[[`" substitution collides with a real image whose alt text
|
|
279
|
+
starts with a literal bracket (`![[leading] bracket](img.png)` contains the
|
|
280
|
+
raw substring `![[`), and would prevent the image construct — which needs
|
|
281
|
+
the literal `!` — from ever being tried.
|
|
282
|
+
|
|
283
|
+
After parsing, a post-process retypes a sentinel-preceded `wikiLink` node to
|
|
284
|
+
`wikiEmbed` only when it carries a `blockId`; otherwise the literal `!` is
|
|
285
|
+
restored and the node stays an ordinary `wikiLink` (`![[file]]` with no id
|
|
286
|
+
degrades to a plain link, per the "not a supported construct" rule above).
|
|
287
|
+
The swap is one codepoint for one codepoint, so it needs no offset-remapping
|
|
288
|
+
the way the pipe-escaping/empty-alias-normalization pre-passes above do.
|
|
289
|
+
|
|
290
|
+
**If you are maintaining this package: do not remove or loosen the
|
|
291
|
+
"complete span, no internal `]`" condition on the embed-marker substitution
|
|
292
|
+
in `parse.ts`.** It looks like it could be simplified to a bare
|
|
293
|
+
`!(?=\[\[)` lookahead. Doing so silently corrupts any image whose alt text
|
|
294
|
+
starts with a bracketed span — caught before merge by the
|
|
295
|
+
`903-image-alt-leading-bracket` round-trip fixture, which is the regression
|
|
296
|
+
gate for this specific failure mode.
|
|
297
|
+
|
|
298
|
+
### The transclusion resolver
|
|
299
|
+
|
|
300
|
+
`![[file#^id]]` renders the resolved block's live content via an optional
|
|
301
|
+
host-injected `resolveTransclusion(file, blockId) => Promise<string | null>`
|
|
302
|
+
(see `docs/editor-api.md`), consumed by a lazily-loaded component — not at
|
|
303
|
+
mapper time, since resolution is async and `mdastToLexical`/`lexicalToMdast`
|
|
304
|
+
are synchronous. With no resolver injected, or one that returns `null`, the
|
|
305
|
+
transclusion renders a clearly marked "unresolved" placeholder rather than
|
|
306
|
+
throwing or rendering nothing.
|
|
307
|
+
|
|
308
|
+
**If you are maintaining this package: do not remove the cycle/depth guard**
|
|
309
|
+
in `src/app/editor/nodes/transclusion-render.tsx`. A transclusion cycle (A
|
|
310
|
+
embeds B, B embeds A — directly, or transitively through a longer chain) is
|
|
311
|
+
guarded by a per-branch visited-path of `file#^blockId` keys, and nested
|
|
312
|
+
transclusion is bounded to a depth of 8, both checked *before* the resolver
|
|
313
|
+
is called at each level. Removing either turns an authoring mistake into an
|
|
314
|
+
infinite loop or unbounded recursion instead of a contained "circular
|
|
315
|
+
transclusion"/"nested too deeply" indicator.
|
|
316
|
+
|
|
213
317
|
## Wiki-link promotion on export
|
|
214
318
|
|
|
215
319
|
Everything above is about *parsing* `[[target]]` syntax the author already wrote.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liminis/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"//publishing": "Publishing is deliberate, never incidental. `private: true` was this package's guard until verveguy/liminis-editor#39 took the publish decision; it is gone because that decision was taken, not because it was tidied away. The guard is now `prepublishOnly` -> scripts/guard-publish.mjs, which refuses unless LIMINIS_ALLOW_PUBLISH=1 is set explicitly. That variable is set at step scope in .github/workflows/publish.yml and nowhere else, so a release is the only path that publishes. Note that `npm publish --dry-run` does NOT report a private package as blocked (npm 10.8.2), which is why the guard is a script rather than a flag.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Lexical-based markdown WYSIWYG editor with mdast round-trip and a host-injection seam",
|
|
@@ -74,14 +74,15 @@
|
|
|
74
74
|
"test:watch": "vitest",
|
|
75
75
|
"test:coverage": "vitest run --coverage",
|
|
76
76
|
"verify:package": "node scripts/verify-package.mjs",
|
|
77
|
+
"check:drift": "node scripts/check-lockfile-drift.mjs",
|
|
77
78
|
"docs:theming": "node scripts/generate-theming-docs.mjs",
|
|
78
79
|
"docs:theming-baseline": "node scripts/update-theming-baseline.mjs",
|
|
79
80
|
"demo": "node scripts/run-demo.mjs",
|
|
80
81
|
"build:examples": "node scripts/build-examples.mjs",
|
|
81
82
|
"build:site": "node scripts/build-site.mjs"
|
|
82
83
|
},
|
|
83
|
-
"//peerDependencies": "Ranges are the compatibility contract, not the tested matrix. CI resolves one point version per range (currently react 19.2.x latest and lexical 0.49.x latest), so every caret range here has an untested lower half
|
|
84
|
-
"//lexicalPeerPolicy": "The twelve Lexical ranges are a single-caret bump per Lexical minor actually adopted and tested here, not a wide multi-version band. `react`'s untested lower half (above) is safe because a break there would be a bug in a stable major; Lexical is `0.x`, where every minor is permitted to break, so a wide band would claim compatibility with untested, potentially-breaking releases. Widening a range later is patch-safe; narrowing one is breaking
|
|
84
|
+
"//peerDependencies": "Ranges are the compatibility contract, not the tested matrix. CI resolves one point version per range (currently react 19.2.x latest and lexical 0.49.x latest), so every caret range here has an untested lower half — narrowing `react` to the devDependency's `^19.2.5` would not change that, it would only reject working consumers. The react floor is not a claim, it is enforced: `tests/package-manifest-contract.test.ts` (\"imports no React API newer than the declared 19.2.0 peer floor\") scans every source file for post-19.0 named imports — `useEffectEvent`, `Activity`, `cacheSignal`, `ViewTransition`, `captureOwnerStack` — and fails if one appears. Reaching for a newer API therefore breaks CI until you raise the floor here too, which is the pairing this note would otherwise only assert. Raise a floor when the code actually starts needing a later API, not to mirror whatever CI happened to install. This reasoning does not extend to the Lexical ranges below — see `//lexicalPeerPolicy`.",
|
|
85
|
+
"//lexicalPeerPolicy": "The twelve Lexical ranges are a single-caret bump per Lexical minor actually adopted and tested here, not a wide multi-version band. `react`'s untested lower half (above) is safe because a break there would be a bug in a stable major; Lexical is `0.x`, where every minor is permitted to break, so a wide band would claim compatibility with untested, potentially-breaking releases. Widening a range later is patch-safe; narrowing one is breaking — so this stays narrow by default and moves forward only when the code is actually run against the new minor. See docs/decisions/adr-92-lexical-peer-range-policy.md.",
|
|
85
86
|
"peerDependencies": {
|
|
86
87
|
"@lexical/code": "^0.49.0",
|
|
87
88
|
"@lexical/code-prism": "^0.49.0",
|
|
@@ -150,6 +151,7 @@
|
|
|
150
151
|
"lexical": "^0.49.0",
|
|
151
152
|
"react": "^19.2.5",
|
|
152
153
|
"react-dom": "^19.2.5",
|
|
154
|
+
"semver": "^7.8.5",
|
|
153
155
|
"tsc-alias": "^1.9.1",
|
|
154
156
|
"typescript": "^6.0.3",
|
|
155
157
|
"typescript-eslint": "^8.59.0",
|