@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.
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +34 -8
- package/dist/editor.js.map +1 -1
- package/dist/effects.d.ts +5 -3
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +7 -5
- package/dist/effects.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paste.d.ts.map +1 -1
- package/dist/paste.js +5 -0
- package/dist/paste.js.map +1 -1
- package/dist/plugins/_preview.js.map +1 -1
- package/dist/plugins/callout.d.ts.map +1 -1
- package/dist/plugins/callout.js +37 -45
- package/dist/plugins/callout.js.map +1 -1
- package/dist/plugins/context-menu.js.map +1 -1
- package/dist/plugins/core.js.map +1 -1
- package/dist/plugins/emoji.js.map +1 -1
- package/dist/plugins/floating-toolbar.js.map +1 -1
- package/dist/plugins/hr.d.ts.map +1 -1
- package/dist/plugins/hr.js +4 -7
- package/dist/plugins/hr.js.map +1 -1
- package/dist/plugins/image.d.ts.map +1 -1
- package/dist/plugins/image.js +28 -20
- package/dist/plugins/image.js.map +1 -1
- package/dist/plugins/inline.js.map +1 -1
- package/dist/plugins/link.d.ts.map +1 -1
- package/dist/plugins/link.js +16 -9
- package/dist/plugins/link.js.map +1 -1
- package/dist/plugins/math.d.ts.map +1 -1
- package/dist/plugins/math.js +25 -34
- package/dist/plugins/math.js.map +1 -1
- package/dist/plugins/mention.js.map +1 -1
- package/dist/plugins/mermaid.d.ts.map +1 -1
- package/dist/plugins/mermaid.js +25 -34
- package/dist/plugins/mermaid.js.map +1 -1
- package/dist/plugins/overlay.js.map +1 -1
- package/dist/plugins/single-block.js.map +1 -1
- package/dist/plugins/slash.js.map +1 -1
- package/dist/plugins/table.d.ts.map +1 -1
- package/dist/plugins/table.js +7 -4
- package/dist/plugins/table.js.map +1 -1
- package/dist/plugins/types.js.map +1 -1
- package/dist/plugins/ui.js.map +1 -1
- package/dist/security.d.ts +26 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +87 -0
- package/dist/security.js.map +1 -0
- package/dist/state.js.map +1 -1
- package/dist/surfaces/link-dialog.js.map +1 -1
- package/dist/surfaces/toolbar.js.map +1 -1
- package/dist/theme.js.map +1 -1
- package/dist/transformers/gfm.js.map +1 -1
- package/dist/transformers/registry.js.map +1 -1
- package/package.json +10 -8
- package/src/editor.ts +44 -8
- package/src/effects.ts +7 -5
- package/src/paste.ts +5 -0
- package/src/plugins/callout.ts +44 -59
- package/src/plugins/hr.ts +4 -14
- package/src/plugins/image.ts +32 -29
- package/src/plugins/link.ts +20 -10
- package/src/plugins/math.ts +27 -41
- package/src/plugins/mermaid.ts +26 -45
- package/src/plugins/table.ts +7 -4
- package/src/security.ts +89 -0
package/src/plugins/mermaid.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
LLuiDecoratorNode,
|
|
16
16
|
decoratorBridge,
|
|
17
17
|
} from '@llui/lexical'
|
|
18
|
-
import {
|
|
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,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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],
|
package/src/plugins/table.ts
CHANGED
|
@@ -92,12 +92,15 @@ function $runTableOp(id: string): void {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
function splitRow(line: string): string[] {
|
|
95
|
-
|
|
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
|
-
|
|
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 {
|
package/src/security.ts
ADDED
|
@@ -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 `` 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
|
+
}
|