@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.
Files changed (83) hide show
  1. package/dist/editor.d.ts +7 -0
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +66 -11
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +5 -4
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +11 -6
  8. package/dist/effects.js.map +1 -1
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/paste.d.ts.map +1 -1
  15. package/dist/paste.js +5 -0
  16. package/dist/paste.js.map +1 -1
  17. package/dist/plugins/_preview.js.map +1 -1
  18. package/dist/plugins/callout.d.ts.map +1 -1
  19. package/dist/plugins/callout.js +37 -45
  20. package/dist/plugins/callout.js.map +1 -1
  21. package/dist/plugins/context-menu.js.map +1 -1
  22. package/dist/plugins/core.js.map +1 -1
  23. package/dist/plugins/emoji.js.map +1 -1
  24. package/dist/plugins/floating-toolbar.js.map +1 -1
  25. package/dist/plugins/hr.d.ts.map +1 -1
  26. package/dist/plugins/hr.js +4 -7
  27. package/dist/plugins/hr.js.map +1 -1
  28. package/dist/plugins/image.d.ts.map +1 -1
  29. package/dist/plugins/image.js +28 -20
  30. package/dist/plugins/image.js.map +1 -1
  31. package/dist/plugins/inline.js.map +1 -1
  32. package/dist/plugins/link.d.ts.map +1 -1
  33. package/dist/plugins/link.js +16 -9
  34. package/dist/plugins/link.js.map +1 -1
  35. package/dist/plugins/math.d.ts.map +1 -1
  36. package/dist/plugins/math.js +25 -34
  37. package/dist/plugins/math.js.map +1 -1
  38. package/dist/plugins/mention.js.map +1 -1
  39. package/dist/plugins/mermaid.d.ts.map +1 -1
  40. package/dist/plugins/mermaid.js +25 -34
  41. package/dist/plugins/mermaid.js.map +1 -1
  42. package/dist/plugins/overlay.js.map +1 -1
  43. package/dist/plugins/single-block.js.map +1 -1
  44. package/dist/plugins/slash.js.map +1 -1
  45. package/dist/plugins/table.d.ts.map +1 -1
  46. package/dist/plugins/table.js +84 -19
  47. package/dist/plugins/table.js.map +1 -1
  48. package/dist/plugins/types.js.map +1 -1
  49. package/dist/plugins/ui.js.map +1 -1
  50. package/dist/security.d.ts +26 -0
  51. package/dist/security.d.ts.map +1 -0
  52. package/dist/security.js +87 -0
  53. package/dist/security.js.map +1 -0
  54. package/dist/state.js.map +1 -1
  55. package/dist/styles/editor.css +0 -6
  56. package/dist/surfaces/link-dialog.js.map +1 -1
  57. package/dist/surfaces/toolbar.js.map +1 -1
  58. package/dist/theme.d.ts +2 -5
  59. package/dist/theme.d.ts.map +1 -1
  60. package/dist/theme.js +15 -11
  61. package/dist/theme.js.map +1 -1
  62. package/dist/transformers/gfm.d.ts +9 -1
  63. package/dist/transformers/gfm.d.ts.map +1 -1
  64. package/dist/transformers/gfm.js +9 -2
  65. package/dist/transformers/gfm.js.map +1 -1
  66. package/dist/transformers/registry.js.map +1 -1
  67. package/package.json +10 -8
  68. package/src/editor.ts +92 -11
  69. package/src/effects.ts +11 -7
  70. package/src/index.ts +3 -8
  71. package/src/paste.ts +5 -0
  72. package/src/plugins/callout.ts +44 -59
  73. package/src/plugins/hr.ts +4 -14
  74. package/src/plugins/image.ts +32 -29
  75. package/src/plugins/link.ts +20 -10
  76. package/src/plugins/math.ts +27 -41
  77. package/src/plugins/mermaid.ts +26 -45
  78. package/src/plugins/table.ts +94 -17
  79. package/src/security.ts +89 -0
  80. package/src/styles/editor.css +0 -6
  81. package/src/theme.ts +15 -11
  82. package/src/transformers/gfm.ts +10 -2
  83. package/dist/__llui_deps.json +0 -274
@@ -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],
@@ -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],
@@ -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 type { MultilineElementTransformer } from '@lexical/markdown'
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
- return line
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
- .split('|')
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
- return !!line && /^\s*\|.*\|\s*$/.test(line.trim())
130
+ if (line === undefined) return false
131
+ const trimmed = line.trim()
132
+ return trimmed !== '' && /(?<!\\)\|/.test(trimmed)
109
133
  }
110
134
 
111
- function makeCell(textValue: string, header: boolean): TableCellNode {
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.append($createParagraphNode().append($createTextNode(textValue)))
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(header: string[], body: string[][]): TableNode {
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
- for (const h of header) headerRow.append(makeCell(h, true))
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++) tr.append(makeCell(row[i] ?? '', false))
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
- const texts = cells.map((c) => c.getTextContent().replace(/\|/g, '\\|').replace(/\n/g, ' '))
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) lines.push('| ' + texts.map(() => '---').join(' | ') + ' |')
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
- regExpStart: /^\s*\|(.+)\|\s*$/,
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
  ),
@@ -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
+ }
@@ -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` (and `underline`) render as a bare <span> whose ONLY styling
6
- // hook is a `theme.text.<format>` class. With no theme the format is applied to
7
- // the model but is visually invisible. This default theme supplies those class
8
- // names; the bundled `styles/editor.css` gives them their `text-decoration`.
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>. (`underlineStrikethrough` is Lexical's special composite key for
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 `underline`.
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
@@ -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. */