@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
@@ -15,7 +15,7 @@ import {
15
15
  LLuiDecoratorNode,
16
16
  decoratorBridge,
17
17
  } from '@llui/lexical'
18
- import { component, div, text, type Mountable, type Signal } from '@llui/dom'
18
+ import { div, text, type Mountable, type Signal } from '@llui/dom'
19
19
  import type { MarkdownPlugin } from './types.js'
20
20
  import { renderedPreview, type PreviewRender } from './_preview.js'
21
21
 
@@ -31,8 +31,6 @@ function isMermaidData(value: unknown): value is MermaidData {
31
31
  )
32
32
  }
33
33
 
34
- type MermaidMsg = { type: 'commit'; code: string }
35
-
36
34
  const stop = (e: Event): void => e.stopPropagation()
37
35
 
38
36
  export interface MermaidPluginOptions {
@@ -46,49 +44,32 @@ export interface MermaidPluginOptions {
46
44
  }
47
45
 
48
46
  export function mermaidPlugin(opts: MermaidPluginOptions = {}): MarkdownPlugin {
49
- const bridge = decoratorBridge<MermaidData, MermaidData, MermaidMsg, never>(
50
- BRIDGE_TYPE,
51
- (data, api) =>
52
- component<MermaidData, MermaidMsg, never>({
53
- name: 'Mermaid',
54
- init: () => ({ code: data.code }),
55
- update: (state, msg) => {
56
- if (msg.type === 'commit') {
57
- if (msg.code === state.code) return state
58
- api.update({ code: msg.code })
59
- return { code: msg.code }
60
- }
61
- return state
47
+ const bridge = decoratorBridge<MermaidData>(BRIDGE_TYPE, (data, api) => {
48
+ const children: Mountable[] = [
49
+ div(
50
+ {
51
+ 'data-part': 'source',
52
+ contenteditable: 'true',
53
+ role: 'textbox',
54
+ 'aria-label': 'Mermaid source',
55
+ onKeyDown: stop,
56
+ onBeforeInput: stop,
57
+ onPaste: stop,
58
+ onBlur: (e: FocusEvent) => {
59
+ const code = (e.target as HTMLElement).textContent ?? ''
60
+ if (code !== data.peek().code) api.update({ code })
61
+ },
62
62
  },
63
- view: ({ state, send }) => {
64
- const children: Mountable[] = [
65
- div(
66
- {
67
- 'data-part': 'source',
68
- contenteditable: 'true',
69
- role: 'textbox',
70
- 'aria-label': 'Mermaid source',
71
- onKeyDown: stop,
72
- onBeforeInput: stop,
73
- onPaste: stop,
74
- onBlur: (e: FocusEvent) =>
75
- send({ type: 'commit', code: (e.target as HTMLElement).textContent ?? '' }),
76
- },
77
- [text(state.at('code') as Signal<string>)],
78
- ),
79
- ]
80
- if (opts.render) {
81
- children.push(renderedPreview(state.at('code') as Signal<string>, opts.render))
82
- }
83
- return [
84
- div(
85
- { 'data-scope': 'md-mermaid', 'data-part': 'root', contenteditable: 'false' },
86
- children,
87
- ),
88
- ]
89
- },
90
- }),
91
- )
63
+ [text(data.at('code') as Signal<string>)],
64
+ ),
65
+ ]
66
+ if (opts.render) {
67
+ children.push(renderedPreview(data.at('code') as Signal<string>, opts.render))
68
+ }
69
+ return [
70
+ div({ 'data-scope': 'md-mermaid', 'data-part': 'root', contenteditable: 'false' }, children),
71
+ ]
72
+ })
92
73
 
93
74
  const transformer: MultilineElementTransformer = {
94
75
  dependencies: [LLuiDecoratorNode],
@@ -92,12 +92,15 @@ function $runTableOp(id: string): void {
92
92
  }
93
93
 
94
94
  function splitRow(line: string): string[] {
95
- return line
95
+ // Strip the OUTER table pipes (always unescaped delimiters), then split on
96
+ // unescaped `|` only — an escaped `\|` is literal cell content and must NOT
97
+ // split the cell (export writes `\|` for a pipe in a cell; see TABLE_TRANSFORMER
98
+ // export). Finally unescape `\|` → `|` so the round-trip is lossless.
99
+ const inner = line
96
100
  .trim()
97
101
  .replace(/^\|/, '')
98
- .replace(/\|$/, '')
99
- .split('|')
100
- .map((c) => c.trim().replace(/\\\|/g, '|'))
102
+ .replace(/(?<!\\)\|$/, '')
103
+ return inner.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'))
101
104
  }
102
105
 
103
106
  function isSeparator(line: string | undefined): boolean {
@@ -0,0 +1,89 @@
1
+ // URL-scheme policy for the editor.
2
+ //
3
+ // The read-only renderer (`@llui/markdown`) already refuses dangerous link/image
4
+ // schemes (`javascript:`, `vbscript:`, `data:text/html`, …) via `sanitizeUrl`.
5
+ // The EDITOR has additional ingress points that must enforce the SAME policy or
6
+ // an attacker-authored document becomes live/clickable inside it:
7
+ //
8
+ // 1. link commit — `$toggleLink` on a raw, user-typed URL,
9
+ // 2. image src — an `![alt](src)` inserted or imported,
10
+ // 3. markdown paste/import — `$convertFromMarkdownString` builds real
11
+ // `LinkNode`s (and image decorator nodes) from untrusted text,
12
+ // 4. the typed `[x](url)` markdown shortcut — builds a `LinkNode` live.
13
+ //
14
+ // Links are additionally guarded by a global `LinkNode` node transform
15
+ // ({@link registerLinkSanitizer}) so #1/#3/#4 all funnel through one enforcement
16
+ // point. Rather than hand-roll (and drift from) a second allowlist, this module
17
+ // reuses `@llui/markdown`'s exact `sanitizeUrl` and only differs in the
18
+ // allowed-scheme SET per surface — which is data, not a divergent implementation.
19
+
20
+ import { sanitizeUrl, defaultAllowedProtocols } from '@llui/markdown/security'
21
+ import { $isLinkNode, LinkNode } from '@lexical/link'
22
+ import { $isElementNode, type LexicalEditor, type LexicalNode } from 'lexical'
23
+
24
+ /** Schemes allowed for a hyperlink href. A click navigates, so this is the strict
25
+ * set — identical to the renderer's default (`http`/`https`/`mailto`/`tel`). */
26
+ export const LINK_PROTOCOLS: readonly string[] = defaultAllowedProtocols
27
+
28
+ /** Schemes allowed for an image `src`. Adds `data:` on top of the link set: a
29
+ * base64 image never executes when loaded through `<img>` (unlike a `data:`
30
+ * hyperlink, which can carry `text/html`), and inline data images are a
31
+ * first-class editor feature (paste / upload). */
32
+ export const IMAGE_PROTOCOLS: readonly string[] = [...defaultAllowedProtocols, 'data']
33
+
34
+ /** Sanitize a hyperlink href. Returns the safe URL, or `null` when the scheme is
35
+ * not on {@link LINK_PROTOCOLS} (the link must then be dropped/unwrapped). */
36
+ export function sanitizeLinkUrl(url: string): string | null {
37
+ return sanitizeUrl(url, LINK_PROTOCOLS)
38
+ }
39
+
40
+ /** Sanitize an image `src`. Returns the safe URL, or `null` when the scheme is
41
+ * not on {@link IMAGE_PROTOCOLS} (the image must then be dropped). */
42
+ export function sanitizeImageUrl(url: string): string | null {
43
+ return sanitizeUrl(url, IMAGE_PROTOCOLS)
44
+ }
45
+
46
+ /** Neutralize one `LinkNode` in place (must run inside `editor.update`): rewrite a
47
+ * merely-normalized href, or — when the scheme is disallowed — UNWRAP the link so
48
+ * its visible text survives but the clickable `javascript:`/`data:` payload is
49
+ * gone. Returns true if the node was unwrapped/removed. */
50
+ function neutralizeLink(node: LinkNode): boolean {
51
+ const url = node.getURL()
52
+ const safe = sanitizeLinkUrl(url)
53
+ if (safe === null) {
54
+ for (const child of node.getChildren()) node.insertBefore(child)
55
+ node.remove()
56
+ return true
57
+ }
58
+ if (safe !== url) node.setURL(safe)
59
+ return false
60
+ }
61
+
62
+ /** Walk `node` (depth-first) neutralizing every unsafe `LinkNode` beneath it. Must
63
+ * run inside an `editor.update`. Use right after importing untrusted markdown
64
+ * (e.g. paste) into a detached scratch tree, before it reaches the live document. */
65
+ export function $sanitizeLinkNodes(node: LexicalNode): void {
66
+ if ($isLinkNode(node)) {
67
+ // Snapshot children first: unwrapping lifts them out of the (removed) link,
68
+ // so recurse the snapshot to catch a defensively-nested inner link either way.
69
+ const children = node.getChildren()
70
+ neutralizeLink(node)
71
+ for (const child of children) $sanitizeLinkNodes(child)
72
+ return
73
+ }
74
+ if ($isElementNode(node)) {
75
+ for (const child of node.getChildren()) $sanitizeLinkNodes(child)
76
+ }
77
+ }
78
+
79
+ /** Register a global `LinkNode` transform that keeps every link's href on the
80
+ * allowlist — the single choke point covering the link dialog, pasted/imported
81
+ * markdown, AND the typed `[text](url)` shortcut. Returns a disposer. A no-op when
82
+ * `LinkNode` isn't registered on the editor (no links are possible then, e.g. the
83
+ * single-block preset), since `registerNodeTransform` requires a registered node. */
84
+ export function registerLinkSanitizer(editor: LexicalEditor): () => void {
85
+ if (!editor.hasNodes([LinkNode])) return () => {}
86
+ return editor.registerNodeTransform(LinkNode, (node) => {
87
+ neutralizeLink(node)
88
+ })
89
+ }