@llui/markdown-editor 0.2.13 → 0.2.14

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 (67) hide show
  1. package/dist/editor.d.ts.map +1 -1
  2. package/dist/editor.js +34 -8
  3. package/dist/editor.js.map +1 -1
  4. package/dist/effects.d.ts +5 -3
  5. package/dist/effects.d.ts.map +1 -1
  6. package/dist/effects.js +7 -5
  7. package/dist/effects.js.map +1 -1
  8. package/dist/format.js.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/paste.d.ts.map +1 -1
  11. package/dist/paste.js +5 -0
  12. package/dist/paste.js.map +1 -1
  13. package/dist/plugins/_preview.js.map +1 -1
  14. package/dist/plugins/callout.d.ts.map +1 -1
  15. package/dist/plugins/callout.js +37 -45
  16. package/dist/plugins/callout.js.map +1 -1
  17. package/dist/plugins/context-menu.js.map +1 -1
  18. package/dist/plugins/core.js.map +1 -1
  19. package/dist/plugins/emoji.js.map +1 -1
  20. package/dist/plugins/floating-toolbar.js.map +1 -1
  21. package/dist/plugins/hr.d.ts.map +1 -1
  22. package/dist/plugins/hr.js +4 -7
  23. package/dist/plugins/hr.js.map +1 -1
  24. package/dist/plugins/image.d.ts.map +1 -1
  25. package/dist/plugins/image.js +28 -20
  26. package/dist/plugins/image.js.map +1 -1
  27. package/dist/plugins/inline.js.map +1 -1
  28. package/dist/plugins/link.d.ts.map +1 -1
  29. package/dist/plugins/link.js +16 -9
  30. package/dist/plugins/link.js.map +1 -1
  31. package/dist/plugins/math.d.ts.map +1 -1
  32. package/dist/plugins/math.js +25 -34
  33. package/dist/plugins/math.js.map +1 -1
  34. package/dist/plugins/mention.js.map +1 -1
  35. package/dist/plugins/mermaid.d.ts.map +1 -1
  36. package/dist/plugins/mermaid.js +25 -34
  37. package/dist/plugins/mermaid.js.map +1 -1
  38. package/dist/plugins/overlay.js.map +1 -1
  39. package/dist/plugins/single-block.js.map +1 -1
  40. package/dist/plugins/slash.js.map +1 -1
  41. package/dist/plugins/table.d.ts.map +1 -1
  42. package/dist/plugins/table.js +7 -4
  43. package/dist/plugins/table.js.map +1 -1
  44. package/dist/plugins/types.js.map +1 -1
  45. package/dist/plugins/ui.js.map +1 -1
  46. package/dist/security.d.ts +26 -0
  47. package/dist/security.d.ts.map +1 -0
  48. package/dist/security.js +87 -0
  49. package/dist/security.js.map +1 -0
  50. package/dist/state.js.map +1 -1
  51. package/dist/surfaces/link-dialog.js.map +1 -1
  52. package/dist/surfaces/toolbar.js.map +1 -1
  53. package/dist/theme.js.map +1 -1
  54. package/dist/transformers/gfm.js.map +1 -1
  55. package/dist/transformers/registry.js.map +1 -1
  56. package/package.json +10 -8
  57. package/src/editor.ts +44 -8
  58. package/src/effects.ts +7 -5
  59. package/src/paste.ts +5 -0
  60. package/src/plugins/callout.ts +44 -59
  61. package/src/plugins/hr.ts +4 -14
  62. package/src/plugins/image.ts +32 -29
  63. package/src/plugins/link.ts +20 -10
  64. package/src/plugins/math.ts +27 -41
  65. package/src/plugins/mermaid.ts +26 -45
  66. package/src/plugins/table.ts +7 -4
  67. package/src/security.ts +89 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llui/markdown-editor",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -24,9 +24,6 @@
24
24
  "./styles/editor.css": "./dist/styles/editor.css"
25
25
  },
26
26
  "peerDependencies": {
27
- "@llui/dom": "^0.11.5",
28
- "@llui/lexical": "^0.2.8",
29
- "@llui/components": "^0.12.2",
30
27
  "lexical": "^0.46.0",
31
28
  "@lexical/markdown": "^0.46.0",
32
29
  "@lexical/rich-text": "^0.46.0",
@@ -35,7 +32,11 @@
35
32
  "@lexical/table": "^0.46.0",
36
33
  "@lexical/selection": "^0.46.0",
37
34
  "@lexical/utils": "^0.46.0",
38
- "@lexical/code-core": "^0.46.0"
35
+ "@lexical/code-core": "^0.46.0",
36
+ "@llui/dom": "^0.11.8",
37
+ "@llui/lexical": "^0.2.10",
38
+ "@llui/markdown": "^0.11.3",
39
+ "@llui/components": "^0.12.4"
39
40
  },
40
41
  "devDependencies": {
41
42
  "lexical": "^0.46.0",
@@ -50,9 +51,10 @@
50
51
  "typescript": "^6.0.0",
51
52
  "vitest": "^4.1.2",
52
53
  "@lexical/code-core": "^0.46.0",
53
- "@llui/dom": "0.11.7",
54
- "@llui/lexical": "0.2.9",
55
- "@llui/components": "0.12.3"
54
+ "@llui/dom": "0.11.8",
55
+ "@llui/markdown": "0.11.3",
56
+ "@llui/components": "0.12.4",
57
+ "@llui/lexical": "0.2.10"
56
58
  },
57
59
  "sideEffects": [
58
60
  "./dist/styles/editor.css"
package/src/editor.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import { corePlugin } from './plugins/core.js'
21
21
  import { linkPlugin } from './plugins/link.js'
22
22
  import { registerMarkdownPaste } from './paste.js'
23
+ import { registerLinkSanitizer } from './security.js'
23
24
  import { toolbar as renderToolbar } from './surfaces/toolbar.js'
24
25
  import type { CommandItem, MarkdownPlugin } from './plugins/types.js'
25
26
  import type { PluginUI } from './plugins/ui.js'
@@ -108,6 +109,15 @@ function defaultPlugins(): MarkdownPlugin[] {
108
109
  return [corePlugin(), linkPlugin()]
109
110
  }
110
111
 
112
+ /** The per-mount `send` closure — the WeakMap key that ties a live editor to the
113
+ * one mount it belongs to. */
114
+ type EditorSend = (msg: EditorMsg) => void
115
+
116
+ /** Per-mount live state: the Lexical editor captured by that mount's `onReady`. */
117
+ interface MountContext {
118
+ editor: LexicalEditor | null
119
+ }
120
+
111
121
  /**
112
122
  * Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`
113
123
  * or compose it inside a larger component.
@@ -128,11 +138,23 @@ export function markdownEditor(
128
138
  .map((p) => ({ name: p.name, ui: p.ui }))
129
139
  const pluginUIByName = new Map(pluginUIs.map((p) => [p.name, p.ui]))
130
140
 
131
- // The live editor, captured at mount; effects dispatch through it.
132
- let editorRef: LexicalEditor | null = null
133
- const getEditor = (): LexicalEditor | null => editorRef
141
+ // Per-MOUNT live-editor context, keyed by the mount's stable `send` closure
142
+ // the SAME object the runtime hands to both `view` and `onEffect`. A single
143
+ // definition can be mounted more than once (or re-mounted); each mount gets its
144
+ // own editor reference here, so effects and plugin handlers dispatch to their
145
+ // own editor and unmount clears only that mount's reference (no cross-wiring,
146
+ // no leaked disposed editor). Entries are collected with their `send` key.
147
+ const mountContexts = new WeakMap<EditorSend, MountContext>()
148
+ const contextFor = (send: EditorSend): MountContext => {
149
+ let ctx = mountContexts.get(send)
150
+ if (!ctx) {
151
+ ctx = { editor: null }
152
+ mountContexts.set(send, ctx)
153
+ }
154
+ return ctx
155
+ }
134
156
 
135
- const baseOnEffect = makeOnEffect(getEditor, itemsById, {
157
+ const baseOnEffect = makeOnEffect((api) => contextFor(api.send).editor, itemsById, {
136
158
  onChange: config.onChange,
137
159
  onFormatChange: config.onFormatChange,
138
160
  applyValue: (editor, value) =>
@@ -192,9 +214,10 @@ export function markdownEditor(
192
214
  api: { send: (msg: EditorMsg) => void; state: Signal<EditorState> },
193
215
  ): void => {
194
216
  if (effect.type === 'pluginEffect') {
217
+ const mount = contextFor(api.send)
195
218
  const ui = pluginUIByName.get(effect.name)
196
219
  ui?.onEffect?.(effect.effect, {
197
- editor: getEditor,
220
+ editor: () => mount.editor,
198
221
  send: (msg) => api.send({ type: 'plugin', name: effect.name, msg }),
199
222
  emit: (msg) => api.send(msg as EditorMsg),
200
223
  })
@@ -210,6 +233,10 @@ export function markdownEditor(
210
233
  state: Signal<EditorState>
211
234
  send: (msg: EditorMsg) => void
212
235
  }): Renderable => {
236
+ // This mount's live-editor slot (keyed by this mount's `send`). `onReady`
237
+ // fills it; effects/plugins read it through `contextFor(api.send)`.
238
+ const mount = contextFor(send)
239
+
213
240
  // Build the collab binding (once, at mount) from the consumer's factory,
214
241
  // injecting the markdown seed + status sinks that mirror into `state.collab`.
215
242
  const collabBinding: CollabBinding | null = config.collab
@@ -245,17 +272,26 @@ export function markdownEditor(
245
272
  ? { changeDebounceMs: config.changeDebounceMs }
246
273
  : {}),
247
274
  register: (editor) => {
248
- const disposers = [registerMarkdownShortcuts(editor, transformers)]
275
+ const disposers = [
276
+ registerMarkdownShortcuts(editor, transformers),
277
+ // Global scheme allowlist for links: the single backstop covering the
278
+ // link dialog, pasted/imported markdown, and the typed `[x](url)`
279
+ // shortcut. Image src is enforced in the image transformer.
280
+ registerLinkSanitizer(editor),
281
+ ]
249
282
  if (config.pasteMarkdown !== false)
250
283
  disposers.push(registerMarkdownPaste(editor, transformers))
251
284
  if (decorators.length > 0) disposers.push(registerDecoratorBridges(editor, decorators))
252
285
  if (collabBinding) disposers.push(collabBinding.register(editor))
253
286
  return () => {
254
287
  for (const dispose of disposers) dispose()
288
+ // Release this mount's editor reference so a disposed editor never
289
+ // leaks and stale effects can't reach a torn-down editor.
290
+ mount.editor = null
255
291
  }
256
292
  },
257
293
  onReady: (editor) => {
258
- editorRef = editor
294
+ mount.editor = editor
259
295
  if (config.placeholder) {
260
296
  editor.getRootElement()?.setAttribute('data-placeholder', config.placeholder)
261
297
  }
@@ -278,7 +314,7 @@ export function markdownEditor(
278
314
  const rendered = ui.view({
279
315
  state: state.at(`plugins.${name}`),
280
316
  send: (msg) => send({ type: 'plugin', name, msg }),
281
- editor: getEditor,
317
+ editor: () => mount.editor,
282
318
  })
283
319
  return Array.isArray(rendered) ? rendered : [rendered]
284
320
  })
package/src/effects.ts CHANGED
@@ -18,23 +18,25 @@ export interface EffectConfig {
18
18
  applyValue: (editor: LexicalEditor, value: string) => void
19
19
  }
20
20
 
21
- /** Build the component's `onEffect`. `getEditor` returns the live editor (set at
22
- * mount via the foreign `onReady`); `items` is the merged id command map. */
21
+ /** Build the component's `onEffect`. `resolveEditor` maps the per-mount effect
22
+ * `api` (whose `send` identifies the mount) to that mount's live editor — so two
23
+ * mounts of one definition dispatch to their own editors; `items` is the merged
24
+ * id → command map. */
23
25
  export function makeOnEffect(
24
- getEditor: () => LexicalEditor | null,
26
+ resolveEditor: (api: EffectApi) => LexicalEditor | null,
25
27
  items: ReadonlyMap<string, CommandItem>,
26
28
  config: EffectConfig,
27
29
  ): (effect: EditorEffect, api: EffectApi) => void {
28
30
  return (effect, api) => {
29
31
  switch (effect.type) {
30
32
  case 'execCommand': {
31
- const editor = getEditor()
33
+ const editor = resolveEditor(api)
32
34
  const item = items.get(effect.id)
33
35
  if (editor && item) item.run(editor, { send: api.send })
34
36
  return
35
37
  }
36
38
  case 'applyValue': {
37
- const editor = getEditor()
39
+ const editor = resolveEditor(api)
38
40
  if (editor) config.applyValue(editor, effect.value)
39
41
  return
40
42
  }
package/src/paste.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type LexicalEditor,
18
18
  } from 'lexical'
19
19
  import { $convertFromMarkdownString, type Transformer } from '@lexical/markdown'
20
+ import { $sanitizeLinkNodes } from './security.js'
20
21
 
21
22
  /**
22
23
  * Parse `markdown` with `transformers` and insert the produced nodes at the
@@ -40,6 +41,10 @@ export function $insertMarkdownAtSelection(
40
41
  // touches the scratch node, not the live document.
41
42
  const scratch = $createParagraphNode()
42
43
  $convertFromMarkdownString(markdown, transformers, scratch)
44
+ // Untrusted source: neutralize any `javascript:`/`data:` link the markdown
45
+ // carried BEFORE the nodes are spliced into the live document. (Image src is
46
+ // enforced in the image transformer; links go through here.)
47
+ $sanitizeLinkNodes(scratch)
43
48
  const nodes = scratch.getChildren()
44
49
  if (nodes.length === 0) return false
45
50
 
@@ -12,7 +12,7 @@ import {
12
12
  LLuiDecoratorNode,
13
13
  decoratorBridge,
14
14
  } from '@llui/lexical'
15
- import { button, component, div, span, text, type Signal } from '@llui/dom'
15
+ import { button, div, span, text, type Signal } from '@llui/dom'
16
16
  import type { MarkdownPlugin } from './types.js'
17
17
 
18
18
  export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'
@@ -46,71 +46,56 @@ function isCalloutData(value: unknown): value is CalloutData {
46
46
  )
47
47
  }
48
48
 
49
- type CalloutMsg = { type: 'cycle' } | { type: 'commitText'; text: string }
50
-
51
49
  // Keep keyboard/input/paste events from bubbling to the outer Lexical editor so
52
50
  // the nested editable text island edits natively without Lexical intercepting.
53
51
  const stop = (e: Event): void => e.stopPropagation()
54
52
 
55
53
  /** The LLui sub-view rendered inside a callout DecoratorNode. The badge cycles
56
54
  * the kind; the text is an editable island that persists into the Lexical node
57
- * on blur (both round-trip to markdown). */
58
- const calloutBridge = decoratorBridge<CalloutData, CalloutData, CalloutMsg, never>(
59
- BRIDGE_TYPE,
60
- (data, api) =>
61
- component<CalloutData, CalloutMsg, never>({
62
- name: 'Callout',
63
- init: () => ({ kind: data.kind, text: data.text }),
64
- update: (state, msg) => {
65
- if (msg.type === 'cycle') {
66
- const kind = nextKind(state.kind)
67
- api.update({ kind, text: state.text })
68
- return { ...state, kind }
69
- }
70
- if (msg.type === 'commitText') {
71
- if (msg.text === state.text) return state
72
- api.update({ kind: state.kind, text: msg.text })
73
- return { ...state, text: msg.text }
74
- }
75
- return state
76
- },
77
- view: ({ state, send }) => [
78
- div(
79
- {
80
- 'data-scope': 'md-callout',
81
- 'data-part': 'root',
82
- 'data-kind': state.at('kind') as Signal<string>,
83
- contenteditable: 'false',
84
- onKeyDown: stop,
85
- onBeforeInput: stop,
86
- onPaste: stop,
55
+ * on blur (both round-trip to markdown). `data` is a reactive signal fed by the
56
+ * bridge, so a data commit updates the view IN PLACE — the editable island keeps
57
+ * its focus/caret instead of the whole sub-app remounting. */
58
+ const calloutBridge = decoratorBridge<CalloutData>(BRIDGE_TYPE, (data, api) => [
59
+ div(
60
+ {
61
+ 'data-scope': 'md-callout',
62
+ 'data-part': 'root',
63
+ 'data-kind': data.at('kind') as Signal<string>,
64
+ contenteditable: 'false',
65
+ onKeyDown: stop,
66
+ onBeforeInput: stop,
67
+ onPaste: stop,
68
+ },
69
+ [
70
+ button(
71
+ {
72
+ type: 'button',
73
+ 'data-part': 'badge',
74
+ 'aria-label': 'Change callout kind',
75
+ onClick: () => {
76
+ const cur = data.peek()
77
+ api.update({ kind: nextKind(cur.kind), text: cur.text })
78
+ },
79
+ },
80
+ [text(data.at('kind').map((k) => KIND_LABEL[k]))],
81
+ ),
82
+ span(
83
+ {
84
+ 'data-part': 'text',
85
+ contenteditable: 'true',
86
+ role: 'textbox',
87
+ 'aria-label': 'Callout text',
88
+ onBlur: (e: FocusEvent) => {
89
+ const cur = data.peek()
90
+ const value = (e.target as HTMLElement).textContent ?? ''
91
+ if (value !== cur.text) api.update({ kind: cur.kind, text: value })
87
92
  },
88
- [
89
- button(
90
- {
91
- type: 'button',
92
- 'data-part': 'badge',
93
- 'aria-label': 'Change callout kind',
94
- onClick: () => send({ type: 'cycle' }),
95
- },
96
- [text(state.at('kind').map((k) => KIND_LABEL[k]))],
97
- ),
98
- span(
99
- {
100
- 'data-part': 'text',
101
- contenteditable: 'true',
102
- role: 'textbox',
103
- 'aria-label': 'Callout text',
104
- onBlur: (e: FocusEvent) =>
105
- send({ type: 'commitText', text: (e.target as HTMLElement).textContent ?? '' }),
106
- },
107
- [text(state.at('text') as Signal<string>)],
108
- ),
109
- ],
110
- ),
111
- ],
112
- }),
113
- )
93
+ },
94
+ [text(data.at('text') as Signal<string>)],
95
+ ),
96
+ ],
97
+ ),
98
+ ])
114
99
 
115
100
  /** `:::kind text` element transformer (single-line admonition). */
116
101
  const CALLOUT_TRANSFORMER: ElementTransformer = {
package/src/plugins/hr.ts CHANGED
@@ -10,24 +10,14 @@ import {
10
10
  LLuiDecoratorNode,
11
11
  decoratorBridge,
12
12
  } from '@llui/lexical'
13
- import { component, hr } from '@llui/dom'
13
+ import { hr } from '@llui/dom'
14
14
  import type { MarkdownPlugin } from './types.js'
15
15
 
16
16
  const BRIDGE_TYPE = 'hr'
17
17
 
18
- const hrBridge = decoratorBridge<
19
- Record<string, never>,
20
- Record<string, never>,
21
- { type: 'noop' },
22
- never
23
- >(BRIDGE_TYPE, () =>
24
- component<Record<string, never>, { type: 'noop' }, never>({
25
- name: 'HorizontalRule',
26
- init: () => ({}),
27
- update: (state) => state,
28
- view: () => [hr({ 'data-md-hr': '', contenteditable: 'false' })],
29
- }),
30
- )
18
+ const hrBridge = decoratorBridge<Record<string, never>>(BRIDGE_TYPE, () => [
19
+ hr({ 'data-md-hr': '', contenteditable: 'false' }),
20
+ ])
31
21
 
32
22
  const HR_TRANSFORMER: ElementTransformer = {
33
23
  dependencies: [LLuiDecoratorNode],
@@ -8,6 +8,7 @@ import {
8
8
  $setSelection,
9
9
  type BaseSelection,
10
10
  type ElementNode,
11
+ type LexicalEditor,
11
12
  type LexicalNode,
12
13
  } from 'lexical'
13
14
  import { $insertNodeToNearestRoot } from '@lexical/utils'
@@ -18,12 +19,13 @@ import {
18
19
  LLuiDecoratorNode,
19
20
  decoratorBridge,
20
21
  } from '@llui/lexical'
21
- import { button, component, div, img, input, text, type Signal } from '@llui/dom'
22
+ import { button, div, img, input, text, type Signal } from '@llui/dom'
22
23
  import {
23
24
  connect as connectDialog,
24
25
  overlay as overlayDialog,
25
26
  type DialogMsg,
26
27
  } from '@llui/components/dialog'
28
+ import { sanitizeImageUrl } from '../security.js'
27
29
  import { definePluginUI } from './ui.js'
28
30
  import type { MarkdownPlugin } from './types.js'
29
31
 
@@ -43,20 +45,11 @@ function isImageData(value: unknown): value is ImageData {
43
45
  )
44
46
  }
45
47
 
46
- const imageBridge = decoratorBridge<ImageData, ImageData, { type: 'noop' }, never>(
47
- BRIDGE_TYPE,
48
- (data) =>
49
- component<ImageData, { type: 'noop' }, never>({
50
- name: 'Image',
51
- init: () => ({ src: data.src, alt: data.alt }),
52
- update: (state) => state,
53
- view: ({ state }) => [
54
- div({ 'data-scope': 'md-image', 'data-part': 'root', contenteditable: 'false' }, [
55
- img({ src: state.at('src') as Signal<string>, alt: state.at('alt') as Signal<string> }),
56
- ]),
57
- ],
58
- }),
59
- )
48
+ const imageBridge = decoratorBridge<ImageData>(BRIDGE_TYPE, (data) => [
49
+ div({ 'data-scope': 'md-image', 'data-part': 'root', contenteditable: 'false' }, [
50
+ img({ src: data.at('src') as Signal<string>, alt: data.at('alt') as Signal<string> }),
51
+ ]),
52
+ ])
60
53
 
61
54
  const IMAGE_TRANSFORMER: ElementTransformer = {
62
55
  dependencies: [LLuiDecoratorNode],
@@ -67,9 +60,12 @@ const IMAGE_TRANSFORMER: ElementTransformer = {
67
60
  },
68
61
  regExp: /^!\[([^\]]*)\]\(([^)]+)\)$/,
69
62
  replace: (parentNode: ElementNode, _children, match): void => {
70
- parentNode.replace(
71
- $createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src: match[2] ?? '' }),
72
- )
63
+ // Enforce the image-src allowlist on import/paste: a disallowed scheme
64
+ // (e.g. `javascript:`) sanitizes to null drop the image entirely rather
65
+ // than materialize a decorator node bound to an unsafe src.
66
+ const src = sanitizeImageUrl(match[2] ?? '')
67
+ if (src === null) return
68
+ parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src }))
73
69
  },
74
70
  type: 'element',
75
71
  }
@@ -112,7 +108,9 @@ export interface ImagePluginOptions {
112
108
  }
113
109
 
114
110
  export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
115
- let savedSelection: BaseSelection | null = null
111
+ // Keyed by the per-mount editor so two mounts never cross-wire the selection
112
+ // saved while the insert dialog is open.
113
+ const savedSelection = new WeakMap<LexicalEditor, BaseSelection | null>()
116
114
 
117
115
  return {
118
116
  name: 'image',
@@ -217,20 +215,25 @@ export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
217
215
  const editor = ctx.editor()
218
216
  if (!editor) return
219
217
  if (effect.type === 'begin') {
220
- savedSelection = editor.getEditorState().read(() => {
221
- const selection = $getSelection()
222
- return selection ? selection.clone() : null
223
- })
218
+ savedSelection.set(
219
+ editor,
220
+ editor.getEditorState().read(() => {
221
+ const selection = $getSelection()
222
+ return selection ? selection.clone() : null
223
+ }),
224
+ )
224
225
  return
225
226
  }
226
- if (effect.src.trim() === '') return
227
+ // Enforce the image-src allowlist at insert: a disallowed scheme drops
228
+ // the insertion rather than binding the decorator to an unsafe src.
229
+ const src = sanitizeImageUrl(effect.src.trim())
230
+ if (src === null) return
231
+ const saved = savedSelection.get(editor) ?? null
227
232
  editor.update(() => {
228
- if (savedSelection) $setSelection(savedSelection.clone())
229
- $insertNodeToNearestRoot(
230
- $createLLuiDecoratorNode(BRIDGE_TYPE, { src: effect.src.trim(), alt: effect.alt }),
231
- )
233
+ if (saved) $setSelection(saved.clone())
234
+ $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { src, alt: effect.alt }))
232
235
  })
233
- savedSelection = null
236
+ savedSelection.delete(editor)
234
237
  },
235
238
  }),
236
239
  }
@@ -14,6 +14,7 @@ import { $findMatchingParent } from '@lexical/utils'
14
14
  import { $isLinkNode, $toggleLink } from '@lexical/link'
15
15
  import type { DialogMsg, DialogState } from '@llui/components/dialog'
16
16
  import { linkDialog } from '../surfaces/link-dialog.js'
17
+ import { sanitizeLinkUrl } from '../security.js'
17
18
  import { definePluginUI } from './ui.js'
18
19
  import type { CommandItem, MarkdownPlugin } from './types.js'
19
20
 
@@ -66,8 +67,9 @@ export interface LinkPluginOptions {
66
67
 
67
68
  export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
68
69
  // Selection saved when the dialog opens (the modal steals focus/selection),
69
- // restored on commit. Encapsulated in the plugin instance not the core editor.
70
- let savedSelection: BaseSelection | null = null
70
+ // restored on commit. Keyed by the per-mount editor so two mounts of the same
71
+ // plugin instance never cross-wire their saved selection.
72
+ const savedSelection = new WeakMap<LexicalEditor, BaseSelection | null>()
71
73
 
72
74
  const item: CommandItem = {
73
75
  id: 'link',
@@ -114,19 +116,27 @@ export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
114
116
  const editor = ctx.editor()
115
117
  if (!editor) return
116
118
  if (effect.type === 'begin') {
117
- savedSelection = editor.getEditorState().read(() => {
118
- const selection = $getSelection()
119
- return selection ? selection.clone() : null
120
- })
119
+ savedSelection.set(
120
+ editor,
121
+ editor.getEditorState().read(() => {
122
+ const selection = $getSelection()
123
+ return selection ? selection.clone() : null
124
+ }),
125
+ )
121
126
  ctx.send({ type: 'show', url: readLinkUrl(editor) })
122
127
  return
123
128
  }
124
- const url = effect.url.trim()
129
+ // Enforce the URL-scheme allowlist at commit: a `javascript:`/`data:`
130
+ // href sanitizes to null → no link is created (unlink). The global
131
+ // LinkNode transform is the backstop, but blocking here avoids ever
132
+ // materializing the unsafe link.
133
+ const safe = sanitizeLinkUrl(effect.url.trim())
134
+ const saved = savedSelection.get(editor) ?? null
125
135
  editor.update(() => {
126
- if (savedSelection) $setSelection(savedSelection.clone())
127
- $toggleLink(url === '' ? null : url)
136
+ if (saved) $setSelection(saved.clone())
137
+ $toggleLink(safe === null || safe === '' ? null : safe)
128
138
  })
129
- savedSelection = null
139
+ savedSelection.delete(editor)
130
140
  },
131
141
  }),
132
142
  }
@@ -11,7 +11,7 @@ import {
11
11
  LLuiDecoratorNode,
12
12
  decoratorBridge,
13
13
  } from '@llui/lexical'
14
- import { component, div, span, text, type Mountable, type Signal } from '@llui/dom'
14
+ import { div, span, text, type Mountable, type Signal } from '@llui/dom'
15
15
  import type { MarkdownPlugin } from './types.js'
16
16
  import { renderedPreview, type PreviewRender } from './_preview.js'
17
17
 
@@ -25,8 +25,6 @@ function isMathData(value: unknown): value is MathData {
25
25
  return typeof value === 'object' && value !== null && typeof (value as MathData).tex === 'string'
26
26
  }
27
27
 
28
- type MathMsg = { type: 'commit'; tex: string }
29
-
30
28
  const stop = (e: Event): void => e.stopPropagation()
31
29
 
32
30
  export interface MathPluginOptions {
@@ -40,44 +38,32 @@ export interface MathPluginOptions {
40
38
  }
41
39
 
42
40
  export function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin {
43
- const bridge = decoratorBridge<MathData, MathData, MathMsg, never>(BRIDGE_TYPE, (data, api) =>
44
- component<MathData, MathMsg, never>({
45
- name: 'Math',
46
- init: () => ({ tex: data.tex }),
47
- update: (state, msg) => {
48
- if (msg.type === 'commit') {
49
- if (msg.tex === state.tex) return state
50
- api.update({ tex: msg.tex })
51
- return { tex: msg.tex }
52
- }
53
- return state
54
- },
55
- view: ({ state, send }) => {
56
- const children: Mountable[] = [
57
- span(
58
- {
59
- 'data-part': 'source',
60
- contenteditable: 'true',
61
- role: 'textbox',
62
- 'aria-label': 'TeX source',
63
- onKeyDown: stop,
64
- onBeforeInput: stop,
65
- onPaste: stop,
66
- onBlur: (e: FocusEvent) =>
67
- send({ type: 'commit', tex: (e.target as HTMLElement).textContent ?? '' }),
68
- },
69
- [text(state.at('tex') as Signal<string>)],
70
- ),
71
- ]
72
- if (opts.render) {
73
- children.push(renderedPreview(state.at('tex') as Signal<string>, opts.render, 'span'))
74
- }
75
- return [
76
- div({ 'data-scope': 'md-math', 'data-part': 'root', contenteditable: 'false' }, children),
77
- ]
78
- },
79
- }),
80
- )
41
+ const bridge = decoratorBridge<MathData>(BRIDGE_TYPE, (data, api) => {
42
+ const children: Mountable[] = [
43
+ span(
44
+ {
45
+ 'data-part': 'source',
46
+ contenteditable: 'true',
47
+ role: 'textbox',
48
+ 'aria-label': 'TeX source',
49
+ onKeyDown: stop,
50
+ onBeforeInput: stop,
51
+ onPaste: stop,
52
+ onBlur: (e: FocusEvent) => {
53
+ const tex = (e.target as HTMLElement).textContent ?? ''
54
+ if (tex !== data.peek().tex) api.update({ tex })
55
+ },
56
+ },
57
+ [text(data.at('tex') as Signal<string>)],
58
+ ),
59
+ ]
60
+ if (opts.render) {
61
+ children.push(renderedPreview(data.at('tex') as Signal<string>, opts.render, 'span'))
62
+ }
63
+ return [
64
+ div({ 'data-scope': 'md-math', 'data-part': 'root', contenteditable: 'false' }, children),
65
+ ]
66
+ })
81
67
 
82
68
  const transformer: ElementTransformer = {
83
69
  dependencies: [LLuiDecoratorNode],