@llui/markdown-editor 0.3.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 (54) hide show
  1. package/dist/editor.d.ts +15 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +12 -2
  4. package/dist/editor.js.map +1 -1
  5. package/dist/index.d.ts +6 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/block-drag.d.ts +74 -0
  10. package/dist/plugins/block-drag.d.ts.map +1 -0
  11. package/dist/plugins/block-drag.js +983 -0
  12. package/dist/plugins/block-drag.js.map +1 -0
  13. package/dist/plugins/code-language.d.ts +60 -0
  14. package/dist/plugins/code-language.d.ts.map +1 -0
  15. package/dist/plugins/code-language.js +308 -0
  16. package/dist/plugins/code-language.js.map +1 -0
  17. package/dist/plugins/frontmatter.d.ts +45 -0
  18. package/dist/plugins/frontmatter.d.ts.map +1 -0
  19. package/dist/plugins/frontmatter.js +281 -0
  20. package/dist/plugins/frontmatter.js.map +1 -0
  21. package/dist/plugins/overlay.d.ts +1 -0
  22. package/dist/plugins/overlay.d.ts.map +1 -1
  23. package/dist/plugins/overlay.js +7 -0
  24. package/dist/plugins/overlay.js.map +1 -1
  25. package/dist/plugins/wikilink.d.ts +110 -0
  26. package/dist/plugins/wikilink.d.ts.map +1 -0
  27. package/dist/plugins/wikilink.js +692 -0
  28. package/dist/plugins/wikilink.js.map +1 -0
  29. package/dist/styles/block-drag.css +118 -0
  30. package/dist/styles/editor.css +112 -0
  31. package/dist/transformers/code.d.ts +24 -0
  32. package/dist/transformers/code.d.ts.map +1 -0
  33. package/dist/transformers/code.js +167 -0
  34. package/dist/transformers/code.js.map +1 -0
  35. package/dist/transformers/gfm.d.ts.map +1 -1
  36. package/dist/transformers/gfm.js +7 -2
  37. package/dist/transformers/gfm.js.map +1 -1
  38. package/dist/transformers/registry.d.ts +3 -0
  39. package/dist/transformers/registry.d.ts.map +1 -1
  40. package/dist/transformers/registry.js +26 -0
  41. package/dist/transformers/registry.js.map +1 -1
  42. package/package.json +42 -28
  43. package/src/editor.ts +27 -4
  44. package/src/index.ts +58 -1
  45. package/src/plugins/block-drag.ts +1175 -0
  46. package/src/plugins/code-language.ts +378 -0
  47. package/src/plugins/frontmatter.ts +324 -0
  48. package/src/plugins/overlay.ts +7 -0
  49. package/src/plugins/wikilink.ts +843 -0
  50. package/src/styles/block-drag.css +118 -0
  51. package/src/styles/editor.css +112 -0
  52. package/src/transformers/code.ts +174 -0
  53. package/src/transformers/gfm.ts +6 -2
  54. package/src/transformers/registry.ts +27 -0
@@ -0,0 +1,843 @@
1
+ // Wikilink plugin — `[[target]]` / `[[target|alias]]` inline references.
2
+ //
3
+ // The ALIAS is what the reader sees; the TARGET is what the link points at.
4
+ // Semantics are lifted from lance's markdown-it inline rule: a wikilink opens
5
+ // with `[[`, closes at the FIRST `]]`, splits on the FIRST `|`, and is rejected
6
+ // when the inner content is empty or contains a nested `[[`.
7
+ //
8
+ // ── Why a custom TextNode and NOT `@lexical/link` ────────────────────────────
9
+ //
10
+ // Reusing the link infrastructure looks tempting (a wikilink IS a link) but is
11
+ // wrong here on four counts:
12
+ //
13
+ // 1. A `LinkNode` is an ELEMENT node: the display text lives in its children,
14
+ // the destination in `getURL()`. A wikilink's target is not a URL — it is
15
+ // a document name resolved by the host. Storing it in `url` means every
16
+ // wikilink flows through this package's link-security layer
17
+ // (`sanitizeLinkUrl` + the global `registerLinkSanitizer` node transform,
18
+ // see `../security.ts`), which exists precisely to rewrite/unwrap hrefs it
19
+ // does not recognize. A wikilink would have to be carved out of the one
20
+ // enforcement point the editor relies on — a security seam we refuse to cut.
21
+ // 2. Because the display text is a child text node, editing it desyncs the
22
+ // alias from the target silently: type inside `[[Page|alias]]` and you get
23
+ // a link whose text no longer matches anything the exporter can round-trip.
24
+ // A `TextNode` subclass in `token` mode is ATOMIC — it is selected, moved
25
+ // and deleted as one unit, so target/alias can never drift.
26
+ // 3. `$toggleLink`, the link dialog, the floating link toolbar and autolink
27
+ // would all treat a wikilink as an ordinary hyperlink and happily rewrite it.
28
+ // 4. Both directions of the markdown conversion then live in ONE text-match
29
+ // transformer over ONE node type, instead of being split across the LINK
30
+ // transformer with a sentinel-scheme escape hatch.
31
+ //
32
+ // A `DecoratorNode` (the `LLuiDecoratorNode` bridge used by math/mermaid) was
33
+ // the other candidate and is also wrong: a decorator is an opaque, non-editable
34
+ // island. A wikilink is inline prose — the caret must move through and around
35
+ // it naturally, and it must inherit the surrounding text format. `TextNode` is
36
+ // the node kind that gives that for free.
37
+ //
38
+ // ── Transformer ordering ─────────────────────────────────────────────────────
39
+ //
40
+ // `transformers/registry.ts` ranks by type (text-match last) and, within a rank,
41
+ // by plugin array order. `@lexical/markdown` resolves competing text-match
42
+ // transformers with `findOutermostTextMatchTransformer`, which prefers the
43
+ // EARLIEST/outermost match and falls back to array order on a tie.
44
+ //
45
+ // * Typing (`registerMarkdownShortcuts`) indexes transformers by their single
46
+ // `trigger` character. Ours is `]`, LINK's is `)`, so they can never collide.
47
+ // * On IMPORT, LINK's `importRegExp` requires a `](`, which `[[a]]` and
48
+ // `[[a|b]]` never contain — plain wikilinks are safe at any position.
49
+ // * The ambiguity is a wikilink on a line that also contains a `](` — either
50
+ // immediately (`[[a|b]](c)`) or LATER on the same line
51
+ // (`**[[A]]** and [b](url)`). LINK's import pattern has a LAZY label
52
+ // (`\[(.+?)\]\(…\)`), so it can match starting at the WIKILINK's own `[` and
53
+ // span across to that later `](`. Both rules then match at the same start
54
+ // index, and a tie is broken by array order.
55
+ //
56
+ // That made fidelity depend on plugin order, and the failure was silent: with
57
+ // `wikilinkPlugin()` listed after `corePlugin()`, LINK swallowed the `**`
58
+ // delimiters and re-exported them ESCAPED, losing the bold outright.
59
+ //
60
+ // This is now resolved STRUCTURALLY, not by documentation: the transformer
61
+ // declares `setTransformerPrecedence(WIKILINK_TRANSFORMER, -10)` (see the
62
+ // bottom of this file), so the registry orders it ahead of LINK in EITHER
63
+ // plugin order. `test/composition.test.ts` pins both orders. To get the
64
+ // CommonMark reading of `[[a|b]](c)` instead, omit `wikilinkPlugin()`.
65
+
66
+ import {
67
+ $applyNodeReplacement,
68
+ $createTextNode,
69
+ $getNearestNodeFromDOMNode,
70
+ $getSelection,
71
+ $isRangeSelection,
72
+ $isTextNode,
73
+ CLICK_COMMAND,
74
+ COMMAND_PRIORITY_HIGH,
75
+ COMMAND_PRIORITY_LOW,
76
+ KEY_ARROW_DOWN_COMMAND,
77
+ KEY_ARROW_UP_COMMAND,
78
+ KEY_ENTER_COMMAND,
79
+ KEY_ESCAPE_COMMAND,
80
+ TextNode,
81
+ type EditorConfig,
82
+ type LexicalEditor,
83
+ type LexicalNode,
84
+ type LexicalUpdateJSON,
85
+ type NodeKey,
86
+ type SerializedTextNode,
87
+ type Spread,
88
+ } from 'lexical'
89
+ import type { TextMatchTransformer } from '@lexical/markdown'
90
+ import { mergeRegister } from '@lexical/utils'
91
+ import { div, each, text, type Signal } from '@llui/dom'
92
+ import { setTransformerPrecedence } from '../transformers/registry.js'
93
+ import { definePluginUI } from './ui.js'
94
+ import { OVERLAY_Z, overlayRoot } from './overlay.js'
95
+ import type { CommandItem, MarkdownPlugin } from './types.js'
96
+
97
+ const PLUGIN = 'wikilink'
98
+
99
+ /** A parsed wikilink. `alias` is `null` when the target is shown verbatim. */
100
+ export interface WikiLink {
101
+ target: string
102
+ alias: string | null
103
+ }
104
+
105
+ // ── Parsing ──────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Matches `[[inner]]` where `inner` is non-empty and contains neither `[[` nor
109
+ * `]]`. Encoding both rejections IN the pattern (rather than validating inside
110
+ * `replace`) is what reproduces lance's scan-and-retry behaviour: markdown-it
111
+ * returns `false` at a bad `[[` and re-attempts one character later, so
112
+ * `[[a[[b]]` yields the INNER `[[b]]`. A regex that matched greedily at index 0
113
+ * and then bailed in `replace` would swallow the valid inner link instead.
114
+ */
115
+ const WIKILINK_RE_SOURCE = String.raw`\[\[((?:(?!\[\[|\]\])[\s\S])+)\]\]`
116
+
117
+ const WIKILINK_IMPORT_RE = new RegExp(WIKILINK_RE_SOURCE)
118
+ /** Same pattern anchored at the caret, for the live typing shortcut. */
119
+ const WIKILINK_TYPING_RE = new RegExp(`${WIKILINK_RE_SOURCE}$`)
120
+
121
+ /**
122
+ * Parse the content BETWEEN the brackets. Returns `null` when the content is not
123
+ * a valid wikilink body.
124
+ *
125
+ * Deliberate choices, each load-bearing for exact round-tripping:
126
+ * * split on the FIRST `|` only, so `[[a|b|c]]` has alias `b|c` and re-exports
127
+ * byte-identically;
128
+ * * an EMPTY alias (`[[a|]]`) normalizes to no alias — the alternative
129
+ * (keeping `alias: ''`) would render a zero-width, unclickable node;
130
+ * * NO trimming. `[[ a ]]` keeps its spaces, because trimming would make
131
+ * import→export lossy. Presentation trimming is the host's call in
132
+ * `onNavigate`/`resolve`, not the document's.
133
+ */
134
+ export function parseWikiLinkInner(inner: string): WikiLink | null {
135
+ if (inner.length === 0 || inner.includes('[[')) return null
136
+ const pipe = inner.indexOf('|')
137
+ const target = pipe >= 0 ? inner.slice(0, pipe) : inner
138
+ // A blank target is as unusable as an empty one: `[[ ]]` would build a
139
+ // token-mode node whose only glyph is a space — invisible, unclickable and
140
+ // (being a token) uneditable except by deleting it wholesale. Same rejection
141
+ // the empty case gets. Note this is a BLANK check, not a trim: a target that
142
+ // has any non-space content keeps its spaces verbatim, so `[[ a ]]` still
143
+ // round-trips byte-identically.
144
+ if (target.trim().length === 0) return null
145
+ const rawAlias = pipe >= 0 ? inner.slice(pipe + 1) : null
146
+ return { target, alias: rawAlias !== null && rawAlias.trim().length > 0 ? rawAlias : null }
147
+ }
148
+
149
+ /**
150
+ * Constrain a target/alias to the values the `[[…]]` syntax can actually
151
+ * express, so that {@link formatWikiLink} is INJECTIVE.
152
+ *
153
+ * Without this, `formatWikiLink` is not the inverse it claims to be: a target
154
+ * of `a|b` emits `[[a|b]]`, which reads back as target `a` with alias `b` — the
155
+ * link silently repoints itself and its visible text changes on the next load.
156
+ * A target of `a]]b` emits `[[a]]b]]`, which reads back as a wikilink to `a`
157
+ * followed by the literal text `b]]`.
158
+ *
159
+ * ── Why sanitize rather than introduce an escape dialect ─────────────────────
160
+ *
161
+ * Backslash-escaping `|`/`[`/`]` would make every value representable, but it
162
+ * forks the syntax away from the markdown-it/Obsidian rule this plugin exists
163
+ * to match: `[[a\|b]]` is not a wikilink to `a|b` anywhere else in the
164
+ * ecosystem, it is a wikilink to the literal `a\` with alias `b`. Emitting it
165
+ * would corrupt the document for every OTHER reader of the same file, which is
166
+ * strictly worse than declining to represent a target no wikilink dialect can
167
+ * express. Documents are shared; our in-memory model is not.
168
+ *
169
+ * So the unrepresentable characters are removed at the point a link is BUILT
170
+ * (`$createWikiLinkNode`, `setTarget`, `setAlias`, the insert command) — every
171
+ * route by which a value can enter the document. Everything downstream may then
172
+ * assume `parse(format(link)) === link`.
173
+ */
174
+ function sanitizePart(raw: string, stripPipe: boolean): string {
175
+ let out = raw
176
+ // Markdown is line-oriented: a newline inside `[[…]]` cannot survive an
177
+ // export/import cycle at all (neither half of the split matches).
178
+ .replace(/\s+/g, ' ')
179
+ // `[[` would open a nested link (which the import rule rejects outright);
180
+ // `]]` would close this one early and spill the remainder as literal text.
181
+ .replace(/\[\[+/g, '[')
182
+ .replace(/\]\]+/g, ']')
183
+ if (stripPipe) out = out.replace(/\|/g, '')
184
+ return out
185
+ }
186
+
187
+ /** Sanitize a target. Returns `null` when nothing usable survives. */
188
+ export function sanitizeWikiLinkTarget(raw: string): string | null {
189
+ const out = sanitizePart(raw, true)
190
+ return out.trim().length > 0 ? out : null
191
+ }
192
+
193
+ /** Sanitize an alias. Returns `null` when nothing usable survives. */
194
+ export function sanitizeWikiLinkAlias(raw: string | null): string | null {
195
+ if (raw === null) return null
196
+ // A pipe is legal in an alias: the parser splits on the FIRST pipe only, so
197
+ // `[[a|b|c]]` unambiguously means alias `b|c` and re-exports byte-identically.
198
+ const out = sanitizePart(raw, false)
199
+ return out.trim().length > 0 ? out : null
200
+ }
201
+
202
+ /**
203
+ * Serialize a wikilink back to markdown. Inverse of {@link parseWikiLinkInner}
204
+ * for every link built through this module's constructors — see
205
+ * {@link sanitizeWikiLinkTarget} for why that qualifier is load-bearing.
206
+ */
207
+ export function formatWikiLink(link: WikiLink): string {
208
+ return link.alias === null ? `[[${link.target}]]` : `[[${link.target}|${link.alias}]]`
209
+ }
210
+
211
+ /** The text a wikilink displays: the alias when present, else the target. */
212
+ function displayText(link: WikiLink): string {
213
+ return link.alias ?? link.target
214
+ }
215
+
216
+ /**
217
+ * What a screen reader announces. An aliased link must name BOTH halves: the
218
+ * visible text is the alias, so the destination is otherwise unhearable.
219
+ */
220
+ function ariaLabel(link: WikiLink): string {
221
+ return link.alias === null
222
+ ? `${link.target}, wiki link`
223
+ : `${link.alias}, wiki link to ${link.target}`
224
+ }
225
+
226
+ // ── Node ─────────────────────────────────────────────────────────────────────
227
+
228
+ export type SerializedWikiLinkNode = Spread<
229
+ { target: string; alias: string | null },
230
+ SerializedTextNode
231
+ >
232
+
233
+ /**
234
+ * An atomic inline wikilink. Extends `TextNode` so the caret, selection and
235
+ * text formats behave exactly as they do for prose, while `token` mode keeps it
236
+ * indivisible: the user can delete it or move past it, but never edit its
237
+ * interior into a state where the visible alias disagrees with `__target`.
238
+ */
239
+ export class WikiLinkNode extends TextNode {
240
+ __target: string
241
+ __alias: string | null
242
+
243
+ static getType(): string {
244
+ return 'wikilink'
245
+ }
246
+
247
+ static clone(node: WikiLinkNode): WikiLinkNode {
248
+ return new WikiLinkNode(node.__target, node.__alias, node.__text, node.__key)
249
+ }
250
+
251
+ constructor(target: string, alias: string | null, text?: string, key?: NodeKey) {
252
+ super(text ?? displayText({ target, alias }), key)
253
+ this.__target = target
254
+ this.__alias = alias
255
+ }
256
+
257
+ static importJSON(serializedNode: SerializedWikiLinkNode): WikiLinkNode {
258
+ return $createWikiLinkNode(serializedNode.target, serializedNode.alias).updateFromJSON(
259
+ serializedNode,
260
+ )
261
+ }
262
+
263
+ updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedWikiLinkNode>): this {
264
+ const self = super.updateFromJSON(serializedNode)
265
+ const writable = self.getWritable()
266
+ writable.__target = serializedNode.target
267
+ writable.__alias = serializedNode.alias
268
+ return writable
269
+ }
270
+
271
+ exportJSON(): SerializedWikiLinkNode {
272
+ return { ...super.exportJSON(), alias: this.getAlias(), target: this.getTarget() }
273
+ }
274
+
275
+ createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
276
+ const dom = super.createDOM(config, editor)
277
+ // `data-scope` / `data-part` is this package's styling contract (see the
278
+ // other plugins); `data-wikilink` additionally carries the target so a host
279
+ // stylesheet can react to it and the click handler can find the element.
280
+ dom.setAttribute('data-scope', 'md-wikilink')
281
+ dom.setAttribute('data-part', 'link')
282
+ dom.setAttribute('data-wikilink', this.__target)
283
+ if (this.__alias !== null) dom.setAttribute('data-wikilink-alias', this.__alias)
284
+ // Without a role, assistive tech reads a wikilink as ordinary prose, and an
285
+ // aliased one gives no way to hear where it points.
286
+ dom.setAttribute('role', 'link')
287
+ dom.setAttribute('aria-label', ariaLabel(this.getLink()))
288
+ return dom
289
+ }
290
+
291
+ updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
292
+ const recreated = super.updateDOM(prevNode, dom, config)
293
+ if (recreated) return true
294
+ if (prevNode.__target !== this.__target) dom.setAttribute('data-wikilink', this.__target)
295
+ if (prevNode.__alias !== this.__alias) {
296
+ if (this.__alias === null) dom.removeAttribute('data-wikilink-alias')
297
+ else dom.setAttribute('data-wikilink-alias', this.__alias)
298
+ }
299
+ if (prevNode.__target !== this.__target || prevNode.__alias !== this.__alias) {
300
+ dom.setAttribute('aria-label', ariaLabel(this.getLink()))
301
+ }
302
+ return false
303
+ }
304
+
305
+ getTarget(): string {
306
+ return this.getLatest().__target
307
+ }
308
+
309
+ /**
310
+ * Repoint the link. The display text follows only when there is no alias.
311
+ * The value is sanitized (see {@link sanitizeWikiLinkTarget}); a target with
312
+ * nothing representable in it leaves the link untouched rather than producing
313
+ * one that corrupts the document on export.
314
+ */
315
+ setTarget(target: string): this {
316
+ const clean = sanitizeWikiLinkTarget(target)
317
+ if (clean === null) return this.getWritable()
318
+ const writable = this.getWritable()
319
+ writable.__target = clean
320
+ if (writable.__alias === null) writable.setTextContent(clean)
321
+ return writable
322
+ }
323
+
324
+ getAlias(): string | null {
325
+ return this.getLatest().__alias
326
+ }
327
+
328
+ /** Set (or clear, with `null`) the alias; the display text follows. */
329
+ setAlias(alias: string | null): this {
330
+ const normalized = sanitizeWikiLinkAlias(alias)
331
+ const writable = this.getWritable()
332
+ writable.__alias = normalized
333
+ writable.setTextContent(displayText({ target: writable.__target, alias: normalized }))
334
+ return writable
335
+ }
336
+
337
+ getLink(): WikiLink {
338
+ const self = this.getLatest()
339
+ return { target: self.__target, alias: self.__alias }
340
+ }
341
+
342
+ // An atomic token: typing against either edge must produce a sibling text
343
+ // node, never extend the wikilink's text (which would desync it from target).
344
+ canInsertTextBefore(): boolean {
345
+ return false
346
+ }
347
+
348
+ canInsertTextAfter(): boolean {
349
+ return false
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Build a wikilink node. `target`/`alias` are sanitized to values the `[[…]]`
355
+ * syntax can express (see {@link sanitizeWikiLinkTarget}); a target with nothing
356
+ * usable left falls back to the literal text `Page` rather than yielding an
357
+ * invisible token.
358
+ */
359
+ export function $createWikiLinkNode(target: string, alias: string | null = null): WikiLinkNode {
360
+ const cleanTarget = sanitizeWikiLinkTarget(target) ?? 'Page'
361
+ return $applyNodeReplacement(
362
+ new WikiLinkNode(cleanTarget, sanitizeWikiLinkAlias(alias)).setMode('token'),
363
+ )
364
+ }
365
+
366
+ export function $isWikiLinkNode(node: LexicalNode | null | undefined): node is WikiLinkNode {
367
+ return node instanceof WikiLinkNode
368
+ }
369
+
370
+ // ── Transformer ──────────────────────────────────────────────────────────────
371
+
372
+ const WIKILINK_TRANSFORMER: TextMatchTransformer = {
373
+ dependencies: [WikiLinkNode],
374
+ // The THIRD parameter is `exportFormat`, not the `selection` that
375
+ // `ElementTransformer['export']` takes — see `TextMatchTransformer` in
376
+ // @lexical/markdown's MarkdownTransformers.ts, and the call site in
377
+ // MarkdownExport.ts `$exportChildren`, which supplies
378
+ // `(textNode, textContent) => exportTextFormat(...)`.
379
+ //
380
+ // Ignoring it exports a bold wikilink as a bare one, and — worse — TEARS any
381
+ // surrounding formatted run in two, because `$exportChildren` emits our
382
+ // unformatted string in the middle of it: `a **b [[P]] c** d` came back as
383
+ // `a **b** [[P]] **c** d`.
384
+ export: (node, _exportChildren, exportFormat) => {
385
+ if (!$isWikiLinkNode(node)) return null
386
+ const markdown = formatWikiLink(node.getLink())
387
+ // Only route through `exportFormat` when there IS a format to apply.
388
+ // `exportTextFormat` backslash-escapes `*_\`~\\` in its input, which for an
389
+ // unformatted link would rewrite targets containing those characters; the
390
+ // raw string keeps the byte-exact round-trip the plugin guarantees.
391
+ return node.getFormat() === 0 ? markdown : exportFormat(node, markdown)
392
+ },
393
+ importRegExp: WIKILINK_IMPORT_RE,
394
+ regExp: WIKILINK_TYPING_RE,
395
+ trigger: ']',
396
+ replace: (textNode, match) => {
397
+ const link = parseWikiLinkInner(match[1] ?? '')
398
+ if (!link) return
399
+ // `LexicalNode.replace()` does NOT carry `__format`/`__style` onto the
400
+ // replacement (verified in lexical@0.48.0 LexicalNode.ts) and
401
+ // `$createWikiLinkNode` builds a node with format 0, so without this the
402
+ // node silently loses bold/italic/strikethrough/underline — falsifying this
403
+ // module's own stated reason for subclassing TextNode ("it must inherit the
404
+ // surrounding text format").
405
+ const replacement = $createWikiLinkNode(link.target, link.alias)
406
+ replacement.setFormat(textNode.getFormat())
407
+ replacement.setStyle(textNode.getStyle())
408
+ textNode.replace(replacement)
409
+ // Returning nothing (rather than the new node) stops `importTextTransformers`
410
+ // from recursing INTO the wikilink: an alias is literal text, so `[[a|**b**]]`
411
+ // must display `**b**`, not bold `b`.
412
+ },
413
+ type: 'text-match',
414
+ }
415
+
416
+ // A wikilink must beat upstream's LINK when both match at the SAME index, in
417
+ // EITHER plugin order. LINK's import pattern is `\[(.+?)\]\(…\)` with a LAZY
418
+ // label, so on a line like `**[[A]]** and [b](url)` it matches starting at the
419
+ // wikilink's own `[`, spanning all the way to the later `](`. Tied start index →
420
+ // resolved by array order → listing `wikilinkPlugin()` after `corePlugin()`
421
+ // handed the span to LINK, which swallowed the `**` delimiters and re-exported
422
+ // them ESCAPED: the bold was silently lost.
423
+ //
424
+ // Declaring the precedence makes the wikilink reading structural rather than a
425
+ // documented ordering requirement a consumer can get wrong. The CommonMark
426
+ // reading of `[[a|b]](c)` is still reachable — omit `wikilinkPlugin()`.
427
+ setTransformerPrecedence(WIKILINK_TRANSFORMER, -10)
428
+
429
+ // ── Click → host notification ────────────────────────────────────────────────
430
+
431
+ /** A document the host offers as a link target while the user types `[[…`. */
432
+ export interface DocCandidate {
433
+ /** The link target written into the document (`[[target]]`). */
434
+ readonly target: string
435
+ /** Human-facing title shown in the results list (defaults to `target`). */
436
+ readonly title?: string
437
+ /** A short one-line snippet shown under the title. */
438
+ readonly snippet?: string
439
+ /** A longer content excerpt shown in the reference/preview pane. */
440
+ readonly preview?: string
441
+ }
442
+
443
+ /** A results row with the active flag the view renders from. */
444
+ interface DocRow {
445
+ target: string
446
+ title: string
447
+ snippet: string
448
+ preview: string
449
+ active: boolean
450
+ }
451
+
452
+ interface WikiLinkState {
453
+ /** The most recently activated link (JSON-serializable, replay-safe). */
454
+ last: WikiLink | null
455
+ /** Document-search panel: open while typing `[[…` with results to show. */
456
+ searchOpen: boolean
457
+ /** The current `[[` query text (what follows `[[`, before the caret). */
458
+ searchQuery: string
459
+ searchItems: DocRow[]
460
+ searchIndex: number
461
+ searchX: number
462
+ searchY: number
463
+ }
464
+
465
+ type WikiLinkMsg =
466
+ | { type: 'activate'; target: string; alias: string | null }
467
+ | { type: 'searchShow'; query: string; items: readonly DocCandidate[]; x: number; y: number }
468
+ | { type: 'searchHide' }
469
+ | { type: 'searchMove'; delta: number }
470
+ | { type: 'searchChoose' }
471
+ | { type: 'searchClick'; index: number }
472
+ | { type: 'searchHover'; index: number }
473
+
474
+ type WikiLinkEffect =
475
+ | { type: 'navigate'; link: WikiLink }
476
+ | { type: 'insert'; target: string; query: string }
477
+
478
+ const MAX_RESULTS = 8
479
+ /** Debounce before firing the host's (possibly async) `search` for a query. */
480
+ const SEARCH_DEBOUNCE_MS = 120
481
+
482
+ function toRows(items: readonly DocCandidate[], index: number): DocRow[] {
483
+ return items.map((c, i) => ({
484
+ target: c.target,
485
+ title: c.title ?? c.target,
486
+ snippet: c.snippet ?? '',
487
+ preview: c.preview ?? '',
488
+ active: i === index,
489
+ }))
490
+ }
491
+
492
+ /** The `[[` query immediately before a collapsed caret, or `null` when the caret
493
+ * is not inside an open `[[…` (no `[[`, or a `]`/`[`/newline already closed it). */
494
+ function $readWikiQuery(): string | null {
495
+ const selection = $getSelection()
496
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
497
+ const node = selection.anchor.getNode()
498
+ if (!$isTextNode(node)) return null
499
+ const before = node.getTextContent().slice(0, selection.anchor.offset)
500
+ const match = before.match(/\[\[([^[\]\n|]*)$/)
501
+ return match ? (match[1] ?? '') : null
502
+ }
503
+
504
+ /** Viewport point just below the caret, for anchoring the panel. */
505
+ function caretXY(): { x: number; y: number } {
506
+ if (typeof window === 'undefined') return { x: 0, y: 0 }
507
+ const sel = window.getSelection()
508
+ if (sel && sel.rangeCount > 0) {
509
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
510
+ return { x: rect.left, y: rect.bottom + 4 }
511
+ }
512
+ return { x: 0, y: 0 }
513
+ }
514
+
515
+ /** Resolve the wikilink a click landed on, if any. Must run in an editor scope. */
516
+ function $wikiLinkFromEvent(event: MouseEvent): WikiLinkNode | null {
517
+ const target = event.target
518
+ if (!(target instanceof Node)) return null
519
+ const direct = $getNearestNodeFromDOMNode(target)
520
+ if ($isWikiLinkNode(direct)) return direct
521
+ // A click can land on the inner text node of a formatted wikilink; climb to
522
+ // the nearest element carrying our marker attribute and map that back.
523
+ const element = target instanceof Element ? target : target.parentElement
524
+ const marked = element?.closest('[data-wikilink]')
525
+ if (!marked) return null
526
+ const node = $getNearestNodeFromDOMNode(marked)
527
+ return $isWikiLinkNode(node) ? node : null
528
+ }
529
+
530
+ /**
531
+ * The wikilink the caret is on or immediately beside, if any. Must run in an
532
+ * editor scope. A token-mode node is selected as a whole, so the anchor lands
533
+ * either ON it or on an adjacent sibling.
534
+ */
535
+ function $selectedWikiLink(): WikiLinkNode | null {
536
+ const selection = $getSelection()
537
+ if (!$isRangeSelection(selection)) return null
538
+ const anchor = selection.anchor.getNode()
539
+ if ($isWikiLinkNode(anchor)) return anchor
540
+ for (const candidate of [anchor.getPreviousSibling(), anchor.getNextSibling()]) {
541
+ if ($isWikiLinkNode(candidate)) return candidate
542
+ }
543
+ return null
544
+ }
545
+
546
+ export interface WikiLinkPluginOptions {
547
+ /**
548
+ * Called when the user activates a wikilink. This is the host's resolution
549
+ * seam: `@llui/markdown-editor` knows nothing about what a target names.
550
+ *
551
+ * The notification travels the same route as every other plugin event —
552
+ * `ctx.emit` → the editor's update loop → this plugin's reducer → an effect —
553
+ * rather than a raw DOM event, so an activation is an ordinary TEA message
554
+ * that shows up in devtools, replay and agent traces.
555
+ */
556
+ onNavigate?: (link: WikiLink) => void
557
+ /** Text used as the target when the insert command runs with no selection. */
558
+ placeholderTarget?: string
559
+ /**
560
+ * Document-search seam: as the user types `[[query`, resolve matching
561
+ * documents to offer as link targets, with an optional content preview shown in
562
+ * the panel's reference pane. Sync or async (async is debounced; a stale
563
+ * response for a superseded query is dropped). When omitted, the panel never
564
+ * opens and `[[target]]` still works by typing the closing `]]`.
565
+ */
566
+ search?: (query: string) => readonly DocCandidate[] | Promise<readonly DocCandidate[]>
567
+ }
568
+
569
+ export function wikilinkPlugin(opts: WikiLinkPluginOptions = {}): MarkdownPlugin {
570
+ const placeholderTarget = opts.placeholderTarget ?? 'Page'
571
+
572
+ const item: CommandItem = {
573
+ id: PLUGIN,
574
+ label: 'Document link',
575
+ icon: 'wikilink',
576
+ group: 'inline',
577
+ keywords: ['document', 'doc', 'link', 'wiki', 'wikilink', 'backlink', 'reference', '[['],
578
+ run: (editor) =>
579
+ editor.update(() => {
580
+ const selection = $getSelection()
581
+ if (!$isRangeSelection(selection)) return
582
+ // A range selection's text can span blocks (newlines) and can contain
583
+ // `|` or `]]` — none of which a `[[…]]` can carry. Sanitizing here, at
584
+ // the boundary where user input enters the document, is what stops the
585
+ // command from minting a link that destroys itself on the next reload.
586
+ const selected = sanitizeWikiLinkTarget(selection.getTextContent())
587
+ // Selected prose becomes the alias of a same-named target, so the
588
+ // sentence reads unchanged and the author only has to fix the target.
589
+ const node = $createWikiLinkNode(selected ?? placeholderTarget, null)
590
+ selection.insertNodes([node])
591
+ node.selectNext(0, 0)
592
+ }),
593
+ surfaces: ['toolbar', 'floating', 'slash', 'context'],
594
+ }
595
+
596
+ return {
597
+ name: PLUGIN,
598
+ nodes: [WikiLinkNode],
599
+ transformers: [WIKILINK_TRANSFORMER],
600
+ items: [item],
601
+ register: (editor, ctx) => {
602
+ const emit = (msg: WikiLinkMsg): void => ctx.emit({ type: 'plugin', name: PLUGIN, msg })
603
+ const activate = (node: WikiLinkNode): true => {
604
+ const { target, alias } = node.getLink()
605
+ emit({ type: 'activate', target, alias })
606
+ return true
607
+ }
608
+ const navigation = mergeRegister(
609
+ editor.registerCommand(
610
+ CLICK_COMMAND,
611
+ (event: MouseEvent) => {
612
+ const node = $wikiLinkFromEvent(event)
613
+ if (!node) return false
614
+ event.preventDefault()
615
+ return activate(node)
616
+ },
617
+ COMMAND_PRIORITY_LOW,
618
+ ),
619
+ // Keyboard parity. A wikilink is token-mode text, so the caret can rest
620
+ // on it, but CLICK_COMMAND is unreachable without a pointer — activation
621
+ // was mouse-only. Mod+Enter (rather than bare Enter) keeps the ordinary
622
+ // paragraph-splitting Enter intact while the caret sits beside a link.
623
+ editor.registerCommand(
624
+ KEY_ENTER_COMMAND,
625
+ (event: KeyboardEvent | null) => {
626
+ if (!event || !(event.metaKey || event.ctrlKey)) return false
627
+ const node = $selectedWikiLink()
628
+ if (!node) return false
629
+ event.preventDefault()
630
+ return activate(node)
631
+ },
632
+ COMMAND_PRIORITY_LOW,
633
+ ),
634
+ )
635
+
636
+ // Document-search typeahead is opt-in: with no `search` seam the panel never
637
+ // opens and `[[target]]` still works by typing the closing `]]`.
638
+ const search = opts.search
639
+ if (search === undefined) return navigation
640
+
641
+ let seq = 0
642
+ let timer: ReturnType<typeof setTimeout> | null = null
643
+ const clearTimer = (): void => {
644
+ if (timer !== null) {
645
+ clearTimeout(timer)
646
+ timer = null
647
+ }
648
+ }
649
+ const activeQuery = (): string | null => editor.getEditorState().read(() => $readWikiQuery())
650
+ const isActive = (): boolean => activeQuery() !== null
651
+
652
+ const refresh = (): void => {
653
+ const query = activeQuery()
654
+ if (query === null) {
655
+ clearTimer()
656
+ emit({ type: 'searchHide' })
657
+ return
658
+ }
659
+ clearTimer()
660
+ const mine = ++seq
661
+ timer = setTimeout(() => {
662
+ void Promise.resolve(search(query))
663
+ .then((results) => {
664
+ // Drop a superseded response (a later keystroke won) or one whose
665
+ // query the caret has since left/changed.
666
+ if (mine !== seq || activeQuery() !== query) return
667
+ const { x, y } = caretXY()
668
+ emit({ type: 'searchShow', query, items: results.slice(0, MAX_RESULTS), x, y })
669
+ })
670
+ .catch(() => {
671
+ /* a failed search simply shows nothing */
672
+ })
673
+ }, SEARCH_DEBOUNCE_MS)
674
+ }
675
+
676
+ const nav = (delta: number, e: KeyboardEvent | null): boolean => {
677
+ if (!isActive()) return false
678
+ e?.preventDefault()
679
+ emit({ type: 'searchMove', delta })
680
+ return true
681
+ }
682
+
683
+ return mergeRegister(
684
+ navigation,
685
+ editor.registerUpdateListener(() => refresh()),
686
+ editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => nav(1, e), COMMAND_PRIORITY_HIGH),
687
+ editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => nav(-1, e), COMMAND_PRIORITY_HIGH),
688
+ editor.registerCommand(
689
+ KEY_ENTER_COMMAND,
690
+ (e) => {
691
+ if (!isActive()) return false
692
+ e?.preventDefault()
693
+ emit({ type: 'searchChoose' })
694
+ return true
695
+ },
696
+ COMMAND_PRIORITY_HIGH,
697
+ ),
698
+ editor.registerCommand(
699
+ KEY_ESCAPE_COMMAND,
700
+ () => {
701
+ if (!isActive()) return false
702
+ emit({ type: 'searchHide' })
703
+ return true
704
+ },
705
+ COMMAND_PRIORITY_HIGH,
706
+ ),
707
+ clearTimer,
708
+ )
709
+ },
710
+ ui: definePluginUI<WikiLinkState, WikiLinkMsg, WikiLinkEffect>({
711
+ init: () => ({
712
+ last: null,
713
+ searchOpen: false,
714
+ searchQuery: '',
715
+ searchItems: [],
716
+ searchIndex: 0,
717
+ searchX: 0,
718
+ searchY: 0,
719
+ }),
720
+ update: (state, msg) => {
721
+ switch (msg.type) {
722
+ case 'activate': {
723
+ const link: WikiLink = { target: msg.target, alias: msg.alias }
724
+ return [{ ...state, last: link }, [{ type: 'navigate', link }]]
725
+ }
726
+ case 'searchShow':
727
+ return {
728
+ ...state,
729
+ searchOpen: msg.items.length > 0,
730
+ searchQuery: msg.query,
731
+ searchItems: toRows(msg.items, 0),
732
+ searchIndex: 0,
733
+ searchX: msg.x,
734
+ searchY: msg.y,
735
+ }
736
+ case 'searchHide':
737
+ return state.searchOpen ? { ...state, searchOpen: false } : state
738
+ case 'searchMove': {
739
+ if (!state.searchOpen || state.searchItems.length === 0) return state
740
+ const i =
741
+ (state.searchIndex + msg.delta + state.searchItems.length) % state.searchItems.length
742
+ return {
743
+ ...state,
744
+ searchIndex: i,
745
+ searchItems: state.searchItems.map((r, idx) => ({ ...r, active: idx === i })),
746
+ }
747
+ }
748
+ case 'searchHover': {
749
+ if (msg.index < 0 || msg.index >= state.searchItems.length) return state
750
+ return {
751
+ ...state,
752
+ searchIndex: msg.index,
753
+ searchItems: state.searchItems.map((r, idx) => ({ ...r, active: idx === msg.index })),
754
+ }
755
+ }
756
+ case 'searchChoose': {
757
+ const row = state.searchItems[state.searchIndex]
758
+ if (!state.searchOpen || !row) return { ...state, searchOpen: false }
759
+ return [
760
+ { ...state, searchOpen: false },
761
+ [{ type: 'insert', target: row.target, query: state.searchQuery }],
762
+ ]
763
+ }
764
+ case 'searchClick': {
765
+ const row = state.searchItems[msg.index]
766
+ if (!row) return state
767
+ return [
768
+ { ...state, searchOpen: false },
769
+ [{ type: 'insert', target: row.target, query: state.searchQuery }],
770
+ ]
771
+ }
772
+ }
773
+ },
774
+ onEffect: (effect, ctx) => {
775
+ if (effect.type === 'navigate') {
776
+ opts.onNavigate?.(effect.link)
777
+ return
778
+ }
779
+ // insert: replace the typed `[[query` with a wikilink node + a space.
780
+ const editor = ctx.editor()
781
+ if (!editor) return
782
+ editor.update(() => {
783
+ const selection = $getSelection()
784
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
785
+ const node = selection.anchor.getNode()
786
+ if (!$isTextNode(node)) return
787
+ const offset = selection.anchor.offset
788
+ // Remove the `[[` (2 chars) plus the query text.
789
+ const start = offset - effect.query.length - 2
790
+ if (start < 0) return
791
+ node.spliceText(start, effect.query.length + 2, '', true)
792
+ const sel = $getSelection()
793
+ if (!$isRangeSelection(sel)) return
794
+ sel.insertNodes([$createWikiLinkNode(effect.target, null), $createTextNode(' ')])
795
+ })
796
+ },
797
+ view: ({ state, send }) =>
798
+ overlayRoot({
799
+ open: state.at('searchOpen'),
800
+ x: state.at('searchX'),
801
+ y: state.at('searchY'),
802
+ zIndex: OVERLAY_Z.typeahead,
803
+ attrs: { 'data-scope': 'md-wikilink', 'data-part': 'panel-root' },
804
+ children: () => [
805
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'panel' }, [
806
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'results', role: 'listbox' }, [
807
+ each(state.at('searchItems') as Signal<DocRow[]>, {
808
+ key: (r) => r.target,
809
+ render: (item, index) => [
810
+ div(
811
+ {
812
+ 'data-scope': 'md-wikilink',
813
+ 'data-part': 'result',
814
+ role: 'option',
815
+ 'data-active': item.map((r) => (r.active ? '' : undefined)),
816
+ onMouseDown: (e: MouseEvent) => {
817
+ e.preventDefault()
818
+ send({ type: 'searchClick', index: index.peek() })
819
+ },
820
+ onMouseEnter: () => send({ type: 'searchHover', index: index.peek() }),
821
+ },
822
+ [
823
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'result-title' }, [
824
+ text(item.map((r) => r.title)),
825
+ ]),
826
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'result-snippet' }, [
827
+ text(item.map((r) => r.snippet)),
828
+ ]),
829
+ ],
830
+ ),
831
+ ],
832
+ }),
833
+ ]),
834
+ // The reference pane: the active candidate's content preview.
835
+ div({ 'data-scope': 'md-wikilink', 'data-part': 'preview' }, [
836
+ text(state.map((s) => s.searchItems[s.searchIndex]?.preview ?? '')),
837
+ ]),
838
+ ]),
839
+ ],
840
+ }),
841
+ }),
842
+ }
843
+ }