@liminis/editor 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +77 -8
  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 +2 -1
  6. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  7. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  8. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  9. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  10. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  11. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  12. package/dist/app/editor/nodes/index.d.ts +2 -0
  13. package/dist/app/editor/nodes/index.js +1 -0
  14. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  15. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  16. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  17. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  18. package/dist/app/mapper/lexicalToMdast.js +55 -6
  19. package/dist/app/mapper/mdastToLexical.js +67 -1
  20. package/dist/host/defaults.js +1 -0
  21. package/dist/host/messages.d.ts +7 -1
  22. package/dist/host/messages.js +3 -3
  23. package/dist/host/types.d.ts +14 -1
  24. package/dist/markdown/parse.js +225 -1
  25. package/dist/markdown/stringify.js +30 -10
  26. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  27. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  28. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  29. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  30. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  31. package/dist/styles.css +48 -0
  32. package/dist/types.d.ts +1 -0
  33. package/docs/architecture.md +80 -0
  34. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  35. package/docs/diagrams/architecture-1-dark.svg +1 -0
  36. package/docs/diagrams/architecture-1.svg +1 -0
  37. package/docs/diagrams/architecture-2-dark.svg +1 -0
  38. package/docs/diagrams/architecture-2.svg +1 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +110 -6
  41. package/package.json +6 -4
@@ -0,0 +1,80 @@
1
+ # Architecture
2
+
3
+ Which entry point to import, what each one costs you, and where the boundary
4
+ between this package and its host actually falls.
5
+
6
+ ## The package and the world around it
7
+
8
+ `@liminis/editor` is a component, not an application. It has no storage, no file
9
+ dialogs, no network, and no opinion about where a document came from. Everything
10
+ it needs from its environment arrives through the host seam as an injected
11
+ function with a working default, so `<Editor>` renders in a host that supplies
12
+ nothing at all — and a host that supplies everything gets an editor wired into
13
+ its own file system, its own clipboard, and its own annotation store.
14
+
15
+ ```c4 static height=24rem
16
+ Person(author, "Author", "Writes and edits markdown documents")
17
+
18
+ System_Boundary(host, "Host application") {
19
+ Container(shell, "Host shell", "Electron or browser", "Owns documents, storage and windows. Supplies the seam.")
20
+ Container(editor, "@liminis/editor", "React + Lexical", "The editing surface, the markdown pipeline and annotations")
21
+ }
22
+
23
+ System_Ext(store, "Document store", "Files, a database, a sync service — the host's concern, never this package's")
24
+
25
+ Rel(author, shell, "Opens and edits documents")
26
+ Rel(shell, editor, "Mounts <Editor>, injects host services")
27
+ Rel(editor, shell, "Reports changes, requests services through the seam")
28
+ Rel(shell, store, "Reads and writes")
29
+ ```
30
+ <picture><source media="(prefers-color-scheme: dark)" srcset="./diagrams/architecture-1-dark.svg" /><img src="./diagrams/architecture-1.svg" alt="Diagram 1 from architecture.md" /></picture>
31
+
32
+ The arrow that matters is the one from the editor back to the shell. It is not a
33
+ callback bolted on for one feature; it is how every capability the editor cannot
34
+ implement for itself is obtained. A host that ignores it still gets an editor,
35
+ because every one of those services has a default.
36
+
37
+ ## Entry points
38
+
39
+ The package is split so that a consumer pays only for what it imports. The
40
+ divide that does the most work is `./headless`: it is DOM-free, which is what
41
+ lets the Electron **main** process import it at all.
42
+
43
+ ```c4 static height=26rem
44
+ Container_Boundary(pkg, "@liminis/editor") {
45
+ Component(main, ".", "React", "<Editor>, the host seam, the annotation UI")
46
+ Component(md, "./markdown", "mdast", "parseMarkdown / stringifyMarkdown, no editor mounted")
47
+ Component(ann, "./annotations", "TypeScript", "Range anchoring that survives edits")
48
+ Component(headless, "./headless", "DOM-free", "Safe in an Electron main process")
49
+ Component(nodes, "./nodes", "Lexical", "The custom node set, for a host building its own editor")
50
+ }
51
+
52
+ System_Ext(lexical, "Lexical", "Peer dependency — the editing engine")
53
+ System_Ext(mdast, "mdast / micromark", "GFM, math, footnotes, definition lists, wiki-links")
54
+ System_Ext(diagrams, "@liminis/diagrams", "Renders the C4 diagrams on this page")
55
+
56
+ Rel(main, nodes, "Registers")
57
+ Rel(main, md, "Parses and serialises through")
58
+ Rel(main, ann, "Anchors annotations with")
59
+ Rel(nodes, lexical, "Extends")
60
+ Rel(md, mdast, "Built on")
61
+ Rel(headless, diagrams, "Re-exports renderC4DiagramToSVG")
62
+ Rel(headless, mdast, "Parses with")
63
+ ```
64
+ <picture><source media="(prefers-color-scheme: dark)" srcset="./diagrams/architecture-2-dark.svg" /><img src="./diagrams/architecture-2.svg" alt="Diagram 2 from architecture.md" /></picture>
65
+
66
+ `./headless` re-exporting `renderC4DiagramToSVG` is the reason the two packages
67
+ appear together here: a C4 diagram in a document has to render in the main
68
+ process too, where there is no DOM, and `@liminis/diagrams/server` is DOM-free
69
+ for exactly that reason.
70
+
71
+ ## What this means when you install it
72
+
73
+ - Importing `.` brings React and Lexical. That is the editor.
74
+ - Importing `./markdown` or `./annotations` brings neither. They are ordinary
75
+ TypeScript over mdast, usable in a script or a server.
76
+ - Importing `./headless` is a promise enforced by its own doc comment: nothing
77
+ reachable from it may touch `document`, `window`, or Lexical.
78
+ - Lexical is a **peer** dependency. The host owns the version, because a host
79
+ that mounts its own Lexical plugins beside this editor must not end up with
80
+ two copies of it.
@@ -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)
@@ -0,0 +1 @@
1
+ <svg width="1286" height="535" viewBox="-50 0 1286 535" xmlns="http://www.w3.org/2000/svg" data-diagram="c4" style="font-family:system-ui, -apple-system, sans-serif"><g class="boundaries-layer"><g><rect x="190" y="40" width="440" height="455" rx="8" ry="8" fill="#131619" stroke="#47657d" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="410" y="64" text-anchor="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Host application</text></g></g><g class="nodes-layer"><g><circle cx="50" cy="156" r="14" fill="#006b2d" stroke="#009848" stroke-width="1.5"></circle><rect x="30" y="174" width="40" height="30" rx="20" ry="8" fill="#006b2d" stroke="#009848" stroke-width="1.5"></rect><text x="50" y="232" text-anchor="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Author</text><text x="50" y="246" text-anchor="middle" fill="#d6d6d6" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7">Writes and edits ...</text></g><g><rect x="230" y="135" width="360" height="110" rx="8" ry="8" fill="#16191c" stroke="#0098e0" stroke-width="2.5" stroke-dasharray="none"></rect><text x="410" y="178" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Host shell</text><text x="410" y="194" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[Electron or browser]</text><text x="410" y="216" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="410" dy="0">Owns documents, storage and windows. Supplies the seam.</tspan></text></g><g><rect x="230" y="345" width="360" height="110" rx="8" ry="8" fill="#16191c" stroke="#0098e0" stroke-width="2.5" stroke-dasharray="none"></rect><text x="410" y="388" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">@liminis/editor</text><text x="410" y="404" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[React + Lexical]</text><text x="410" y="426" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="410" dy="0">The editing surface, the markdown pipeline and</tspan><tspan x="410" dy="14">annotations</tspan></text></g><g><rect x="820" y="143" width="360" height="94" rx="8" ry="8" fill="#2d2d2d" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1000" y="184" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Document store</text><text x="1000" y="204" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1000" dy="0">Files, a database, a sync service — the host&#x27;s concern,</tspan><tspan x="1000" dy="14">never this package&#x27;s</tspan></text></g></g><g class="edges-layer"><g><path d="M 110 190 L 222 190" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="230,190 222,194 222,186" fill="#a7a7a7"></polygon><text x="170" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 170, 190)"><tspan x="170" y="194">Opens and edits documents</tspan></text></g><g><path d="M 397.5 245 L 397.5 337" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="397.5,345 393.5,337 401.5,337" fill="#a7a7a7"></polygon><rect x="387.5" y="285" width="20" height="20" rx="3" fill="#16191c" stroke="#a7a7a7" stroke-width="1.5"></rect><text x="397.5" y="299" text-anchor="middle" fill="#a7a7a7" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">B</text></g><g><path d="M 422.5 345 L 422.5 253" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="422.5,245 426.5,253 418.5,253" fill="#a7a7a7"></polygon><rect x="412.5" y="285" width="20" height="20" rx="3" fill="#16191c" stroke="#a7a7a7" stroke-width="1.5"></rect><text x="422.5" y="299" text-anchor="middle" fill="#a7a7a7" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">A</text></g><g><path d="M 590 190 L 653.6 190" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 756.4 190 L 812 190" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="820,190 812,194 812,186" fill="#a7a7a7"></polygon><text x="705" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 705, 190)"><tspan x="705" y="194">Reads and writes</tspan></text></g></g><g class="legend-layer"><g><rect x="920" y="20" width="18" height="18" rx="3" fill="#16191c" stroke="#a7a7a7" stroke-width="1.5"></rect><text x="929" y="33" text-anchor="middle" fill="#a7a7a7" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">A</text><text x="946" y="33" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif">Reports changes, requests services through the seam</text></g><g><rect x="920" y="44" width="18" height="18" rx="3" fill="#16191c" stroke="#a7a7a7" stroke-width="1.5"></rect><text x="929" y="57" text-anchor="middle" fill="#a7a7a7" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">B</text><text x="946" y="57" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif">Mounts &lt;Editor&gt;, injects host services</text></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg width="1286" height="535" viewBox="-50 0 1286 535" xmlns="http://www.w3.org/2000/svg" data-diagram="c4" style="font-family:system-ui, -apple-system, sans-serif"><g class="boundaries-layer"><g><rect x="190" y="40" width="440" height="455" rx="8" ry="8" fill="#f4f7fb" stroke="#63869b" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="410" y="64" text-anchor="middle" fill="#3d3d3d" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Host application</text></g></g><g class="nodes-layer"><g><circle cx="50" cy="156" r="14" fill="#006b2d" stroke="#004b1e" stroke-width="1.5"></circle><rect x="30" y="174" width="40" height="30" rx="20" ry="8" fill="#006b2d" stroke="#004b1e" stroke-width="1.5"></rect><text x="50" y="232" text-anchor="middle" fill="#3d3d3d" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Author</text><text x="50" y="246" text-anchor="middle" fill="#3d3d3d" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7">Writes and edits ...</text></g><g><rect x="230" y="135" width="360" height="110" rx="8" ry="8" fill="#ffffff" stroke="#005998" stroke-width="2.5" stroke-dasharray="none"></rect><text x="410" y="178" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Host shell</text><text x="410" y="194" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[Electron or browser]</text><text x="410" y="216" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="410" dy="0">Owns documents, storage and windows. Supplies the seam.</tspan></text></g><g><rect x="230" y="345" width="360" height="110" rx="8" ry="8" fill="#ffffff" stroke="#005998" stroke-width="2.5" stroke-dasharray="none"></rect><text x="410" y="388" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">@liminis/editor</text><text x="410" y="404" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[React + Lexical]</text><text x="410" y="426" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="410" dy="0">The editing surface, the markdown pipeline and</tspan><tspan x="410" dy="14">annotations</tspan></text></g><g><rect x="820" y="143" width="360" height="94" rx="8" ry="8" fill="#f0f4f8" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1000" y="184" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Document store</text><text x="1000" y="204" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1000" dy="0">Files, a database, a sync service — the host&#x27;s concern,</tspan><tspan x="1000" dy="14">never this package&#x27;s</tspan></text></g></g><g class="edges-layer"><g><path d="M 110 190 L 222 190" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="230,190 222,194 222,186" fill="#565656"></polygon><text x="170" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 170, 190)"><tspan x="170" y="194">Opens and edits documents</tspan></text></g><g><path d="M 397.5 245 L 397.5 337" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="397.5,345 393.5,337 401.5,337" fill="#565656"></polygon><rect x="387.5" y="285" width="20" height="20" rx="3" fill="#ffffff" stroke="#565656" stroke-width="1.5"></rect><text x="397.5" y="299" text-anchor="middle" fill="#565656" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">B</text></g><g><path d="M 422.5 345 L 422.5 253" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="422.5,245 426.5,253 418.5,253" fill="#565656"></polygon><rect x="412.5" y="285" width="20" height="20" rx="3" fill="#ffffff" stroke="#565656" stroke-width="1.5"></rect><text x="422.5" y="299" text-anchor="middle" fill="#565656" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">A</text></g><g><path d="M 590 190 L 653.6 190" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 756.4 190 L 812 190" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="820,190 812,194 812,186" fill="#565656"></polygon><text x="705" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 705, 190)"><tspan x="705" y="194">Reads and writes</tspan></text></g></g><g class="legend-layer"><g><rect x="920" y="20" width="18" height="18" rx="3" fill="#ffffff" stroke="#565656" stroke-width="1.5"></rect><text x="929" y="33" text-anchor="middle" fill="#565656" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">A</text><text x="946" y="33" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif">Reports changes, requests services through the seam</text></g><g><rect x="920" y="44" width="18" height="18" rx="3" fill="#ffffff" stroke="#565656" stroke-width="1.5"></rect><text x="929" y="57" text-anchor="middle" fill="#565656" font-size="10" font-weight="600" font-family="system-ui, -apple-system, sans-serif">B</text><text x="946" y="57" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif">Mounts &lt;Editor&gt;, injects host services</text></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg width="1761" height="545" viewBox="0 0 1761 545" xmlns="http://www.w3.org/2000/svg" data-diagram="c4" style="font-family:system-ui, -apple-system, sans-serif"><g class="boundaries-layer"></g><g class="nodes-layer"><g><rect x="40" y="40" width="1105" height="455" rx="8" ry="8" fill="#131619" stroke="#47657d" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="592.5" y="64" text-anchor="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">@liminis/editor</text></g><g><rect x="388" y="135" width="294" height="110" rx="4" ry="4" fill="#16191c" stroke="#2b89bf" stroke-width="2.5"></rect><text x="535" y="186" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">.</text><text x="535" y="202" text-anchor="middle" fill="#b2b2b2" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[React]</text></g><g><rect x="355" y="345" width="360" height="110" rx="4" ry="4" fill="#16191c" stroke="#2b89bf" stroke-width="2.5"></rect><text x="535" y="396" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./markdown</text><text x="535" y="412" text-anchor="middle" fill="#b2b2b2" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[mdast]</text></g><g><rect x="80" y="345" width="245" height="110" rx="4" ry="4" fill="#16191c" stroke="#2b89bf" stroke-width="2.5"></rect><text x="202.5" y="396" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./annotations</text><text x="202.5" y="412" text-anchor="middle" fill="#b2b2b2" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[TypeScript]</text></g><g><rect x="712" y="135" width="240" height="110" rx="4" ry="4" fill="#16191c" stroke="#2b89bf" stroke-width="2.5"></rect><text x="832" y="186" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./headless</text><text x="832" y="202" text-anchor="middle" fill="#b2b2b2" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[DOM-free]</text></g><g><rect x="745" y="345" width="360" height="110" rx="4" ry="4" fill="#16191c" stroke="#2b89bf" stroke-width="2.5"></rect><text x="925" y="396" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./nodes</text><text x="925" y="412" text-anchor="middle" fill="#b2b2b2" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[Lexical]</text></g><g><rect x="1371" y="411" width="252" height="94" rx="8" ry="8" fill="#2d2d2d" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1497" y="452" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Lexical</text><text x="1497" y="472" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1497" dy="0">Peer dependency — the editing engine</tspan></text></g><g><rect x="1371" y="277" width="350" height="94" rx="8" ry="8" fill="#2d2d2d" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1546" y="318" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">mdast / micromark</text><text x="1546" y="338" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1546" dy="0">GFM, math, footnotes, definition lists, wiki-links</tspan></text></g><g><rect x="1371" y="143" width="252" height="94" rx="8" ry="8" fill="#2d2d2d" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1497" y="184" text-anchor="middle" dominant-baseline="middle" fill="#d6d6d6" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">@liminis/diagrams</text><text x="1497" y="204" text-anchor="middle" fill="#b2b2b2" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1497" dy="0">Renders the C4 diagrams on this page</tspan></text></g></g><g class="edges-layer"><g><path d="M 637.143 245 L 703.68 280.828" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 756.904 309.487 L 815.813 341.207" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="822.857,345 813.917,344.729 817.71,337.685" fill="#a7a7a7"></polygon><text x="730" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(28.301, 730, 295)"><tspan x="730" y="299">Registers</tspan></text></g><g><path d="M 535 245 L 535 287.2" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 535 304.2 L 535 337" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="535,345 531,337 539,337" fill="#a7a7a7"></polygon><text x="535" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 535, 295)"><tspan x="535" y="299">Parses and serialises through</tspan></text></g><g><path d="M 447.917 245 L 432.353 254.83" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 304.515 335.569 L 296.347 340.728" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="289.583,345 294.211,337.346 298.483,344.11" fill="#a7a7a7"></polygon><text x="368.75" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(-32.276, 368.75, 295)"><tspan x="368.75" y="299">Anchors annotations with</tspan></text></g><g><path d="M 1105 418.252 L 1214.019 429.306" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 1262.122 434.184 L 1363.041 444.417" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="1371,445.224 1362.637,448.397 1363.444,440.437" fill="#a7a7a7"></polygon><text x="1238" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(5.79, 1238, 431.738)"><tspan x="1238" y="435.738">Extends</tspan></text></g><g><path d="M 715 386.469 L 1015.824 363.855" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 1070.071 359.777 L 1363.023 337.755" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="1371,337.155 1363.322,341.743 1362.723,333.766" fill="#a7a7a7"></polygon><text x="1043" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(-4.299, 1043, 361.812)"><tspan x="1043" y="365.812">Built on</tspan></text></g><g><path d="M 952 190 L 1098 190" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 1225 190 L 1363 190" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="1371,190 1363,194 1363,186" fill="#a7a7a7"></polygon><text x="1161.5" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 1161.5, 190)"><tspan x="1161.5" y="183">Re-exports</tspan><tspan x="1161.5" y="197">renderC4DiagramToSVG</tspan></text></g><g><path d="M 952 212.521 L 1125.974 245.172" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><path d="M 1197.279 258.554 L 1363.137 289.681" fill="none" stroke="#a7a7a7" stroke-width="1.5"></path><polygon points="1371,291.157 1362.399,293.613 1363.875,285.75" fill="#a7a7a7"></polygon><text x="1161.5" text-anchor="middle" fill="#bebebe" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(10.629, 1161.5, 251.839)"><tspan x="1161.5" y="255.839">Parses with</tspan></text></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg width="1761" height="545" viewBox="0 0 1761 545" xmlns="http://www.w3.org/2000/svg" data-diagram="c4" style="font-family:system-ui, -apple-system, sans-serif"><g class="boundaries-layer"></g><g class="nodes-layer"><g><rect x="40" y="40" width="1105" height="455" rx="8" ry="8" fill="#f4f7fb" stroke="#63869b" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="592.5" y="64" text-anchor="middle" fill="#3d3d3d" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">@liminis/editor</text></g><g><rect x="388" y="135" width="294" height="110" rx="4" ry="4" fill="#f8f8f8" stroke="#0068a0" stroke-width="2.5"></rect><text x="535" y="186" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">.</text><text x="535" y="202" text-anchor="middle" fill="#626262" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[React]</text></g><g><rect x="355" y="345" width="360" height="110" rx="4" ry="4" fill="#f8f8f8" stroke="#0068a0" stroke-width="2.5"></rect><text x="535" y="396" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./markdown</text><text x="535" y="412" text-anchor="middle" fill="#626262" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[mdast]</text></g><g><rect x="80" y="345" width="245" height="110" rx="4" ry="4" fill="#f8f8f8" stroke="#0068a0" stroke-width="2.5"></rect><text x="202.5" y="396" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./annotations</text><text x="202.5" y="412" text-anchor="middle" fill="#626262" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[TypeScript]</text></g><g><rect x="712" y="135" width="240" height="110" rx="4" ry="4" fill="#f8f8f8" stroke="#0068a0" stroke-width="2.5"></rect><text x="832" y="186" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./headless</text><text x="832" y="202" text-anchor="middle" fill="#626262" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[DOM-free]</text></g><g><rect x="745" y="345" width="360" height="110" rx="4" ry="4" fill="#f8f8f8" stroke="#0068a0" stroke-width="2.5"></rect><text x="925" y="396" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="13" font-weight="500" font-family="system-ui, -apple-system, sans-serif">./nodes</text><text x="925" y="412" text-anchor="middle" fill="#626262" font-size="10" font-family="system-ui, -apple-system, sans-serif" opacity="0.8">[Lexical]</text></g><g><rect x="1371" y="411" width="252" height="94" rx="8" ry="8" fill="#f0f4f8" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1497" y="452" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">Lexical</text><text x="1497" y="472" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1497" dy="0">Peer dependency — the editing engine</tspan></text></g><g><rect x="1371" y="277" width="350" height="94" rx="8" ry="8" fill="#f0f4f8" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1546" y="318" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">mdast / micromark</text><text x="1546" y="338" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1546" dy="0">GFM, math, footnotes, definition lists, wiki-links</tspan></text></g><g><rect x="1371" y="143" width="252" height="94" rx="8" ry="8" fill="#f0f4f8" stroke="#667280" stroke-width="2.5" stroke-dasharray="8 4"></rect><text x="1497" y="184" text-anchor="middle" dominant-baseline="middle" fill="#303030" font-size="14" font-weight="600" font-family="system-ui, -apple-system, sans-serif">@liminis/diagrams</text><text x="1497" y="204" text-anchor="middle" fill="#626262" font-size="11" font-family="system-ui, -apple-system, sans-serif" opacity="0.7"><tspan x="1497" dy="0">Renders the C4 diagrams on this page</tspan></text></g></g><g class="edges-layer"><g><path d="M 637.143 245 L 703.68 280.828" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 756.904 309.487 L 815.813 341.207" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="822.857,345 813.917,344.729 817.71,337.685" fill="#565656"></polygon><text x="730" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(28.301, 730, 295)"><tspan x="730" y="299">Registers</tspan></text></g><g><path d="M 535 245 L 535 287.2" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 535 304.2 L 535 337" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="535,345 531,337 539,337" fill="#565656"></polygon><text x="535" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 535, 295)"><tspan x="535" y="299">Parses and serialises through</tspan></text></g><g><path d="M 447.917 245 L 432.353 254.83" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 304.515 335.569 L 296.347 340.728" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="289.583,345 294.211,337.346 298.483,344.11" fill="#565656"></polygon><text x="368.75" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(-32.276, 368.75, 295)"><tspan x="368.75" y="299">Anchors annotations with</tspan></text></g><g><path d="M 1105 418.252 L 1214.019 429.306" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 1262.122 434.184 L 1363.041 444.417" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="1371,445.224 1362.637,448.397 1363.444,440.437" fill="#565656"></polygon><text x="1238" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(5.79, 1238, 431.738)"><tspan x="1238" y="435.738">Extends</tspan></text></g><g><path d="M 715 386.469 L 1015.824 363.855" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 1070.071 359.777 L 1363.023 337.755" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="1371,337.155 1363.322,341.743 1362.723,333.766" fill="#565656"></polygon><text x="1043" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(-4.299, 1043, 361.812)"><tspan x="1043" y="365.812">Built on</tspan></text></g><g><path d="M 952 190 L 1098 190" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 1225 190 L 1363 190" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="1371,190 1363,194 1363,186" fill="#565656"></polygon><text x="1161.5" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(0, 1161.5, 190)"><tspan x="1161.5" y="183">Re-exports</tspan><tspan x="1161.5" y="197">renderC4DiagramToSVG</tspan></text></g><g><path d="M 952 212.521 L 1125.974 245.172" fill="none" stroke="#565656" stroke-width="1.5"></path><path d="M 1197.279 258.554 L 1363.137 289.681" fill="none" stroke="#565656" stroke-width="1.5"></path><polygon points="1371,291.157 1362.399,293.613 1363.875,285.75" fill="#565656"></polygon><text x="1161.5" text-anchor="middle" fill="#4a4a4a" font-size="11" font-family="system-ui, -apple-system, sans-serif" transform="rotate(10.629, 1161.5, 251.839)"><tspan x="1161.5" y="255.839">Parses with</tspan></text></g></g></svg>
@@ -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
- So: **if your pipeline enables GFM tables, or you care about `[[target|]]`, call
204
- `parseMarkdown` rather than assembling the extensions yourself.** Reach for the
205
- raw extensions only when you control the input and neither case applies.
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`. `wikiLinkToMarkdown` is the
210
- faithful vendored upstream serializer, not a byte-for-byte match for what the
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.4.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 \u2014 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 \u2014 `useEffectEvent`, `Activity`, `cacheSignal`, `ViewTransition`, `captureOwnerStack` \u2014 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 \u2014 see `//lexicalPeerPolicy`.",
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 \u2014 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.",
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",
@@ -99,7 +100,7 @@
99
100
  "react-dom": "^19.2.0"
100
101
  },
101
102
  "dependencies": {
102
- "@liminis/diagrams": "^0.1.1",
103
+ "@liminis/diagrams": "^0.1.5",
103
104
  "@mathjax/src": "^4.1.1",
104
105
  "lucide-react": "^1.11.0",
105
106
  "mdast-util-definition-list": "^2.0.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",