@llui/markdown-editor 0.2.13 → 0.3.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.
- package/dist/editor.d.ts +7 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +66 -11
- package/dist/editor.js.map +1 -1
- package/dist/effects.d.ts +5 -4
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +11 -6
- package/dist/effects.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- 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 +84 -19
- 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/styles/editor.css +0 -6
- package/dist/surfaces/link-dialog.js.map +1 -1
- package/dist/surfaces/toolbar.js.map +1 -1
- package/dist/theme.d.ts +2 -5
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +15 -11
- package/dist/theme.js.map +1 -1
- package/dist/transformers/gfm.d.ts +9 -1
- package/dist/transformers/gfm.d.ts.map +1 -1
- package/dist/transformers/gfm.js +9 -2
- 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 +92 -11
- package/src/effects.ts +11 -7
- package/src/index.ts +3 -8
- 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 +94 -17
- package/src/security.ts +89 -0
- package/src/styles/editor.css +0 -6
- package/src/theme.ts +15 -11
- package/src/transformers/gfm.ts +10 -2
- package/dist/__llui_deps.json +0 -274
package/src/plugins/math.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
LLuiDecoratorNode,
|
|
12
12
|
decoratorBridge,
|
|
13
13
|
} from '@llui/lexical'
|
|
14
|
-
import {
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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],
|
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
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
$createParagraphNode,
|
|
10
|
-
$createTextNode,
|
|
11
10
|
$getSelection,
|
|
12
11
|
$isRangeSelection,
|
|
12
|
+
type ElementFormatType,
|
|
13
|
+
type ElementNode,
|
|
13
14
|
type LexicalNode,
|
|
14
15
|
} from 'lexical'
|
|
15
16
|
import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
|
|
@@ -30,12 +31,26 @@ import {
|
|
|
30
31
|
TableNode,
|
|
31
32
|
TableRowNode,
|
|
32
33
|
} from '@lexical/table'
|
|
33
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
$convertFromMarkdownString,
|
|
36
|
+
LINK,
|
|
37
|
+
type MultilineElementTransformer,
|
|
38
|
+
type Transformer,
|
|
39
|
+
} from '@lexical/markdown'
|
|
34
40
|
import { button, text } from '@llui/dom'
|
|
35
41
|
import { definePluginUI } from './ui.js'
|
|
36
42
|
import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
|
|
43
|
+
import { INLINE_TEXT_TRANSFORMERS } from '../transformers/gfm.js'
|
|
37
44
|
import type { MarkdownPlugin } from './types.js'
|
|
38
45
|
|
|
46
|
+
/** Transformers used to parse/serialize a single table cell's INLINE content
|
|
47
|
+
* (bold/italic/strikethrough/inline-code and links). No block/element or
|
|
48
|
+
* multiline transformers, so a cell's markdown is always a single inline run —
|
|
49
|
+
* this is what makes bold/link cells survive an export∘import round-trip instead
|
|
50
|
+
* of being flattened to bare text. `LINK` needs `LinkNode`, which the core plugin
|
|
51
|
+
* registers (tables are used alongside it). */
|
|
52
|
+
const CELL_TRANSFORMERS: readonly Transformer[] = [...INLINE_TEXT_TRANSFORMERS, LINK]
|
|
53
|
+
|
|
39
54
|
interface TableToolsState {
|
|
40
55
|
open: boolean
|
|
41
56
|
x: number
|
|
@@ -92,38 +107,86 @@ function $runTableOp(id: string): void {
|
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
function splitRow(line: string): string[] {
|
|
95
|
-
|
|
110
|
+
// Strip the OUTER table pipes (always unescaped delimiters), then split on
|
|
111
|
+
// unescaped `|` only — an escaped `\|` is literal cell content and must NOT
|
|
112
|
+
// split the cell (export writes `\|` for a pipe in a cell; see TABLE_TRANSFORMER
|
|
113
|
+
// export). Finally unescape `\|` → `|` so the round-trip is lossless.
|
|
114
|
+
const inner = line
|
|
96
115
|
.trim()
|
|
97
116
|
.replace(/^\|/, '')
|
|
98
|
-
.replace(
|
|
99
|
-
|
|
100
|
-
.map((c) => c.trim().replace(/\\\|/g, '|'))
|
|
117
|
+
.replace(/(?<!\\)\|$/, '')
|
|
118
|
+
return inner.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'))
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
function isSeparator(line: string | undefined): boolean {
|
|
104
122
|
return !!line && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes('-')
|
|
105
123
|
}
|
|
106
124
|
|
|
125
|
+
/** Is `line` a GFM table row? GFM makes the leading and trailing pipes OPTIONAL
|
|
126
|
+
* (`a | b` is as valid as `| a | b |`); the only requirement is at least one
|
|
127
|
+
* unescaped cell delimiter. The old `\|.*\|` test rejected valid pipe-less-edge
|
|
128
|
+
* rows, truncating imported tables. */
|
|
107
129
|
function isRow(line: string | undefined): boolean {
|
|
108
|
-
|
|
130
|
+
if (line === undefined) return false
|
|
131
|
+
const trimmed = line.trim()
|
|
132
|
+
return trimmed !== '' && /(?<!\\)\|/.test(trimmed)
|
|
109
133
|
}
|
|
110
134
|
|
|
111
|
-
|
|
135
|
+
/** Map a GFM separator cell (`:---`, `:---:`, `---:`, `---`) to a cell alignment. */
|
|
136
|
+
function parseAlignment(separatorCell: string): ElementFormatType {
|
|
137
|
+
const t = separatorCell.trim()
|
|
138
|
+
const left = t.startsWith(':')
|
|
139
|
+
const right = t.endsWith(':')
|
|
140
|
+
if (left && right) return 'center'
|
|
141
|
+
if (right) return 'right'
|
|
142
|
+
if (left) return 'left'
|
|
143
|
+
return ''
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Map a cell's alignment back to its GFM separator token. */
|
|
147
|
+
function alignmentSeparator(format: ElementFormatType): string {
|
|
148
|
+
switch (format) {
|
|
149
|
+
case 'center':
|
|
150
|
+
return ':---:'
|
|
151
|
+
case 'right':
|
|
152
|
+
case 'end':
|
|
153
|
+
return '---:'
|
|
154
|
+
case 'left':
|
|
155
|
+
case 'start':
|
|
156
|
+
return ':---'
|
|
157
|
+
default:
|
|
158
|
+
return '---'
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Build a cell from its inline markdown, parsing bold/italic/link/etc. through
|
|
163
|
+
* {@link CELL_TRANSFORMERS} (not a bare text node) and applying its column's
|
|
164
|
+
* alignment. */
|
|
165
|
+
function makeCell(cellMarkdown: string, header: boolean, align: ElementFormatType): TableCellNode {
|
|
112
166
|
const cell = $createTableCellNode(
|
|
113
167
|
header ? TableCellHeaderStates.COLUMN : TableCellHeaderStates.NO_STATUS,
|
|
114
168
|
)
|
|
115
|
-
cell.
|
|
169
|
+
// Parse the cell's inline markdown into a paragraph of formatted text nodes.
|
|
170
|
+
$convertFromMarkdownString(cellMarkdown, [...CELL_TRANSFORMERS], cell)
|
|
171
|
+
// A blank cell yields no paragraph — keep the cell a well-formed single-paragraph.
|
|
172
|
+
if (cell.getChildrenSize() === 0) cell.append($createParagraphNode())
|
|
173
|
+
if (align !== '') cell.setFormat(align)
|
|
116
174
|
return cell
|
|
117
175
|
}
|
|
118
176
|
|
|
119
|
-
function buildTable(
|
|
177
|
+
function buildTable(
|
|
178
|
+
header: string[],
|
|
179
|
+
body: string[][],
|
|
180
|
+
aligns: readonly ElementFormatType[],
|
|
181
|
+
): TableNode {
|
|
120
182
|
const table = $createTableNode()
|
|
121
183
|
const headerRow = $createTableRowNode()
|
|
122
|
-
|
|
184
|
+
header.forEach((h, i) => headerRow.append(makeCell(h, true, aligns[i] ?? '')))
|
|
123
185
|
table.append(headerRow)
|
|
124
186
|
for (const row of body) {
|
|
125
187
|
const tr = $createTableRowNode()
|
|
126
|
-
for (let i = 0; i < header.length; i++)
|
|
188
|
+
for (let i = 0; i < header.length; i++)
|
|
189
|
+
tr.append(makeCell(row[i] ?? '', false, aligns[i] ?? ''))
|
|
127
190
|
table.append(tr)
|
|
128
191
|
}
|
|
129
192
|
return table
|
|
@@ -131,29 +194,42 @@ function buildTable(header: string[], body: string[][]): TableNode {
|
|
|
131
194
|
|
|
132
195
|
const TABLE_TRANSFORMER: MultilineElementTransformer = {
|
|
133
196
|
dependencies: [TableNode, TableRowNode, TableCellNode],
|
|
134
|
-
export: (node: LexicalNode): string | null => {
|
|
197
|
+
export: (node: LexicalNode, traverseChildren: (node: ElementNode) => string): string | null => {
|
|
135
198
|
if (!$isTableNode(node)) return null
|
|
136
199
|
const rows = node.getChildren() as TableRowNode[]
|
|
137
200
|
if (rows.length === 0) return null
|
|
138
201
|
const lines: string[] = []
|
|
139
202
|
rows.forEach((row, ri) => {
|
|
140
203
|
const cells = row.getChildren() as TableCellNode[]
|
|
141
|
-
|
|
204
|
+
// Serialize each cell's INLINE content through the full transformer set
|
|
205
|
+
// (so bold/italic/links survive), then escape `|` and flatten newlines so
|
|
206
|
+
// the cell can't break the row.
|
|
207
|
+
const texts = cells.map((c) =>
|
|
208
|
+
traverseChildren(c).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim(),
|
|
209
|
+
)
|
|
142
210
|
lines.push('| ' + texts.join(' | ') + ' |')
|
|
143
|
-
if (ri === 0)
|
|
211
|
+
if (ri === 0) {
|
|
212
|
+
// Reconstruct the alignment row from the header cells' alignments.
|
|
213
|
+
const seps = cells.map((c) => alignmentSeparator(c.getFormatType()))
|
|
214
|
+
lines.push('| ' + seps.join(' | ') + ' |')
|
|
215
|
+
}
|
|
144
216
|
})
|
|
145
217
|
return lines.join('\n')
|
|
146
218
|
},
|
|
147
|
-
|
|
219
|
+
// Leading/trailing pipes are optional in GFM; require only one internal pipe.
|
|
220
|
+
// A false positive is harmless — the header is only accepted when the NEXT line
|
|
221
|
+
// is a delimiter row (checked below).
|
|
222
|
+
regExpStart: /^\s*\|?.*(?<!\\)\|.*\|?\s*$/,
|
|
148
223
|
handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {
|
|
149
224
|
if (!isSeparator(lines[startLineIndex + 1])) return null // header must be followed by a separator
|
|
225
|
+
const aligns = splitRow(lines[startLineIndex + 1]!).map(parseAlignment)
|
|
150
226
|
let end = startLineIndex + 2
|
|
151
227
|
const body: string[][] = []
|
|
152
228
|
while (end < lines.length && isRow(lines[end]) && !isSeparator(lines[end])) {
|
|
153
229
|
body.push(splitRow(lines[end]!))
|
|
154
230
|
end++
|
|
155
231
|
}
|
|
156
|
-
rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body))
|
|
232
|
+
rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body, aligns))
|
|
157
233
|
return [true, end - 1]
|
|
158
234
|
},
|
|
159
235
|
// Import is handled manually above; a multiline transformer still needs replace.
|
|
@@ -271,6 +347,7 @@ export function tablePlugin(): MarkdownPlugin {
|
|
|
271
347
|
['', ''],
|
|
272
348
|
['', ''],
|
|
273
349
|
],
|
|
350
|
+
['', ''],
|
|
274
351
|
),
|
|
275
352
|
),
|
|
276
353
|
),
|
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
|
+
}
|
package/src/styles/editor.css
CHANGED
|
@@ -83,12 +83,6 @@
|
|
|
83
83
|
[data-lexical-editor] .md-strikethrough {
|
|
84
84
|
text-decoration: line-through;
|
|
85
85
|
}
|
|
86
|
-
[data-lexical-editor] .md-underline {
|
|
87
|
-
text-decoration: underline;
|
|
88
|
-
}
|
|
89
|
-
[data-lexical-editor] .md-underline-strikethrough {
|
|
90
|
-
text-decoration: underline line-through;
|
|
91
|
-
}
|
|
92
86
|
|
|
93
87
|
/* Task-list items (li[role="checkbox"]) with a clickable checkbox. */
|
|
94
88
|
[data-lexical-editor] li[role='checkbox'] {
|
package/src/theme.ts
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Lexical styles most inline formats through semantic tags that carry
|
|
4
4
|
// browser-default styling — bold→<strong>, italic→<em>, code→<code>. But
|
|
5
|
-
// `strikethrough`
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// `strikethrough` renders as a bare <span> whose ONLY styling hook is a
|
|
6
|
+
// `theme.text.strikethrough` class. With no theme the format is applied to the
|
|
7
|
+
// model but is visually invisible. This default theme supplies that class name;
|
|
8
|
+
// the bundled `styles/editor.css` gives it its `text-decoration`.
|
|
9
|
+
//
|
|
10
|
+
// `underline` is deliberately NOT themed here: the GFM markdown dialect this
|
|
11
|
+
// editor serializes has no underline representation (Lexical's text-format
|
|
12
|
+
// transformers require a SYMMETRIC delimiter, and there is no standard symmetric
|
|
13
|
+
// underline syntax), so an applied underline would be silently stripped on save.
|
|
14
|
+
// To keep the WYSIWYG surface and the serialized dialect in lock-step, the
|
|
15
|
+
// underline command is also intercepted at the editor seam (see editor.ts) — so
|
|
16
|
+
// underline can be neither applied nor lost. Add it back (theme + transformer +
|
|
17
|
+
// command) only alongside a dialect that can round-trip it.
|
|
9
18
|
//
|
|
10
19
|
// Consumers can override any entry via `markdownEditor({ theme })` — the user's
|
|
11
20
|
// theme is merged over this default (see `mergeTheme`).
|
|
@@ -13,22 +22,17 @@
|
|
|
13
22
|
import type { EditorThemeClasses } from 'lexical'
|
|
14
23
|
|
|
15
24
|
export const STRIKETHROUGH_CLASS = 'md-strikethrough'
|
|
16
|
-
export const UNDERLINE_CLASS = 'md-underline'
|
|
17
|
-
export const UNDERLINE_STRIKETHROUGH_CLASS = 'md-underline-strikethrough'
|
|
18
25
|
|
|
19
26
|
/** The class hooks Lexical needs for text-decoration formats it renders as a
|
|
20
|
-
* plain <span>.
|
|
21
|
-
* the case where both apply — both want `text-decoration`.) */
|
|
27
|
+
* plain <span>. */
|
|
22
28
|
export const defaultTheme: EditorThemeClasses = {
|
|
23
29
|
text: {
|
|
24
30
|
strikethrough: STRIKETHROUGH_CLASS,
|
|
25
|
-
underline: UNDERLINE_CLASS,
|
|
26
|
-
underlineStrikethrough: UNDERLINE_STRIKETHROUGH_CLASS,
|
|
27
31
|
},
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** Merge a consumer theme over the default. `text` is merged per-key so a
|
|
31
|
-
* consumer overriding (say) `strikethrough` keeps the default
|
|
35
|
+
* consumer overriding (say) `strikethrough` keeps the other default entries.
|
|
32
36
|
*
|
|
33
37
|
* Always returns a FRESH theme (never the shared `defaultTheme` singleton):
|
|
34
38
|
* Lexical caches resolved class arrays by MUTATING the `text` object it is
|
package/src/transformers/gfm.ts
CHANGED
|
@@ -39,9 +39,18 @@ export const GFM_NODES: ReadonlyArray<Klass<LexicalNode>> = [
|
|
|
39
39
|
LinkNode,
|
|
40
40
|
]
|
|
41
41
|
|
|
42
|
+
/** The `==highlight==` transformer. NOT part of the default GFM set: `==..==` is
|
|
43
|
+
* not GFM, so exporting it produces non-standard markdown other renderers won't
|
|
44
|
+
* understand. Offered as an opt-in a consumer can add to a plugin's transformers. */
|
|
45
|
+
export const HIGHLIGHT_TRANSFORMER: Transformer = HIGHLIGHT
|
|
46
|
+
|
|
42
47
|
/** Inline text-format transformers (no block nodes, no node registration). These
|
|
43
48
|
* are the only transformers a single-block / inline-only editor needs; `LINK` is
|
|
44
|
-
* kept separate since it requires `LinkNode` to be registered.
|
|
49
|
+
* kept separate since it requires `LinkNode` to be registered.
|
|
50
|
+
*
|
|
51
|
+
* `HIGHLIGHT` is deliberately excluded: it round-trips as the non-GFM `==..==`
|
|
52
|
+
* syntax, so it would silently emit markdown outside the editor's stated dialect.
|
|
53
|
+
* Opt in with {@link HIGHLIGHT_TRANSFORMER}. */
|
|
45
54
|
export const INLINE_TEXT_TRANSFORMERS: readonly Transformer[] = [
|
|
46
55
|
BOLD_ITALIC_STAR,
|
|
47
56
|
BOLD_ITALIC_UNDERSCORE,
|
|
@@ -51,7 +60,6 @@ export const INLINE_TEXT_TRANSFORMERS: readonly Transformer[] = [
|
|
|
51
60
|
ITALIC_UNDERSCORE,
|
|
52
61
|
STRIKETHROUGH,
|
|
53
62
|
INLINE_CODE,
|
|
54
|
-
HIGHLIGHT,
|
|
55
63
|
]
|
|
56
64
|
|
|
57
65
|
/** Markdown ↔ node transformers for the GFM superset. */
|