@byline/richtext-lexical 3.4.1 → 3.5.1

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 (32) hide show
  1. package/dist/field/extensions/admonition/admonition-commands.d.ts +25 -0
  2. package/dist/field/extensions/admonition/admonition-commands.js +4 -0
  3. package/dist/field/extensions/admonition/admonition-extension.d.ts +2 -3
  4. package/dist/field/extensions/admonition/admonition-extension.js +138 -24
  5. package/dist/field/extensions/admonition/admonition-node.css +112 -0
  6. package/dist/field/extensions/admonition/admonition-node.d.ts +26 -14
  7. package/dist/field/extensions/admonition/admonition-node.js +121 -72
  8. package/dist/field/extensions/admonition/index.js +1 -0
  9. package/dist/field/extensions/admonition/node-types.d.ts +10 -4
  10. package/dist/field/extensions/floating-text-format/index.d.ts +8 -1
  11. package/dist/field/extensions/floating-text-format/index.js +6 -4
  12. package/dist/field/markdown/transformers.js +11 -14
  13. package/package.json +5 -5
  14. package/src/field/extensions/admonition/admonition-commands.ts +31 -0
  15. package/src/field/extensions/admonition/admonition-extension.tsx +248 -37
  16. package/src/field/extensions/admonition/admonition-node.css +113 -0
  17. package/src/field/extensions/admonition/admonition-node.tsx +172 -93
  18. package/src/field/extensions/admonition/index.ts +4 -8
  19. package/src/field/extensions/admonition/node-types.ts +10 -10
  20. package/src/field/extensions/floating-text-format/index.tsx +19 -3
  21. package/src/field/markdown/admonition-roundtrip.test.node.ts +110 -0
  22. package/src/field/markdown/transformers.ts +45 -24
  23. package/dist/field/extensions/admonition/admonition-node-component.css +0 -119
  24. package/dist/field/extensions/admonition/admonition-node-component.d.ts +0 -17
  25. package/dist/field/extensions/admonition/admonition-node-component.js +0 -196
  26. package/dist/field/extensions/admonition/icons/danger-icon.d.ts +0 -7
  27. package/dist/field/extensions/admonition/icons/index.d.ts +0 -4
  28. package/dist/field/extensions/admonition/icons/note-icon.d.ts +0 -7
  29. package/dist/field/extensions/admonition/icons/tip-icon.d.ts +0 -7
  30. package/dist/field/extensions/admonition/icons/warning-icon.d.ts +0 -7
  31. package/src/field/extensions/admonition/admonition-node-component.css +0 -115
  32. package/src/field/extensions/admonition/admonition-node-component.tsx +0 -257
@@ -8,180 +8,259 @@
8
8
  * Copyright (c) Infonomic Company Limited
9
9
  */
10
10
 
11
- import * as React from 'react'
12
-
13
11
  import type {
14
12
  DOMConversionMap,
15
13
  DOMConversionOutput,
16
14
  DOMExportOutput,
17
15
  EditorConfig,
16
+ ElementDOMSlot,
18
17
  LexicalEditor,
19
18
  LexicalNode,
19
+ LexicalUpdateJSON,
20
20
  NodeKey,
21
21
  } from 'lexical'
22
- import { $applyNodeReplacement, createEditor, DecoratorNode } from 'lexical'
22
+ import { $applyNodeReplacement, ElementNode } from 'lexical'
23
23
 
24
+ import { OPEN_ADMONITION_MODAL_COMMAND } from './admonition-commands'
24
25
  import type { AdmonitionAttributes, AdmonitionType, SerializedAdmonitionNode } from './node-types'
25
26
 
26
- const AdmonitionNodeComponent = React.lazy(async () => await import('./admonition-node-component'))
27
+ import './admonition-node.css'
28
+
29
+ const ADMONITION_TYPES: ReadonlySet<string> = new Set(['note', 'tip', 'warning', 'danger'])
30
+
31
+ function isAdmonitionType(value: unknown): value is AdmonitionType {
32
+ return typeof value === 'string' && ADMONITION_TYPES.has(value)
33
+ }
34
+
35
+ // Inline SVG markup for the header icon, keyed by type. Mirrors the React
36
+ // icon components in `./icons` — the ElementNode chrome is plain DOM, so the
37
+ // markup is inlined rather than rendered through React.
38
+ const ICON_SVG: Record<AdmonitionType, string> = {
39
+ note: '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"></path></svg>',
40
+ tip: '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4C12.76,4 13.5,4.11 14.2,4.31L15.77,2.74C14.61,2.26 13.34,2 12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"></path></svg>',
41
+ warning:
42
+ '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z"></path></svg>',
43
+ danger:
44
+ '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"></path></svg>',
45
+ }
27
46
 
28
47
  function convertAdmonitionElement(domNode: Node): null | DOMConversionOutput {
29
- if (domNode instanceof HTMLDivElement) {
30
- const type = domNode.dataset.type as AdmonitionType
31
- const title = domNode.dataset.title as string
32
- const node = $createAdmonitionNode({ admonitionType: type, title })
33
- return { node }
48
+ if (domNode instanceof HTMLDivElement && domNode.dataset.type != null) {
49
+ const type = domNode.dataset.type
50
+ if (!isAdmonitionType(type)) {
51
+ return null
52
+ }
53
+ const title = domNode.dataset.title ?? ''
54
+ return { node: $createAdmonitionNode({ admonitionType: type, title }) }
34
55
  }
35
56
  return null
36
57
  }
37
58
 
38
- export class AdmonitionNode extends DecoratorNode<React.JSX.Element> {
59
+ /**
60
+ * The admonition (callout) block. An `ElementNode` whose body is real
61
+ * children in the main editor tree — see the module note in
62
+ * `../../markdown/transformers.ts` for why this beats a nested editor for
63
+ * markdown round-tripping. `__admonitionType` / `__title` are attributes set
64
+ * from the Insert/Edit modal and rendered as non-editable chrome; the body
65
+ * (paragraphs + inline content) renders into the slot returned by
66
+ * `getDOMSlot`.
67
+ */
68
+ export class AdmonitionNode extends ElementNode {
39
69
  __admonitionType: AdmonitionType
40
70
  __title: string
41
- __content: LexicalEditor
42
71
 
43
72
  static getType(): string {
44
73
  return 'admonition'
45
74
  }
46
75
 
47
76
  static clone(node: AdmonitionNode): AdmonitionNode {
48
- return new AdmonitionNode(node.__admonitionType, node.__title, node.__content, node.__key)
77
+ return new AdmonitionNode(node.__admonitionType, node.__title, node.__key)
49
78
  }
50
79
 
51
80
  static importJSON(serializedNode: SerializedAdmonitionNode): AdmonitionNode {
52
- const { admonitionType, title, content } = serializedNode
53
- const node = $createAdmonitionNode({
54
- admonitionType,
55
- title,
56
- })
57
- const nestedEditor = node.__content
58
- const editorState = nestedEditor.parseEditorState(content.editorState)
59
- if (!editorState.isEmpty()) {
60
- nestedEditor.setEditorState(editorState)
61
- }
62
- return node
81
+ return $createAdmonitionNode({
82
+ admonitionType: serializedNode.admonitionType,
83
+ title: serializedNode.title,
84
+ }).updateFromJSON(serializedNode)
63
85
  }
64
86
 
65
87
  static importDOM(): DOMConversionMap | null {
66
88
  return {
67
- div: (_node: Node) => ({
68
- conversion: convertAdmonitionElement,
69
- priority: 0,
70
- }),
89
+ div: (node: HTMLElement) => {
90
+ if (node.dataset.type == null || !isAdmonitionType(node.dataset.type)) {
91
+ return null
92
+ }
93
+ return { conversion: convertAdmonitionElement, priority: 1 }
94
+ },
71
95
  }
72
96
  }
73
97
 
74
- constructor(
75
- admonitionType: AdmonitionType,
76
- title: string,
77
- content?: LexicalEditor,
78
- key?: NodeKey
79
- ) {
98
+ constructor(admonitionType: AdmonitionType, title: string, key?: NodeKey) {
80
99
  super(key)
81
100
  this.__admonitionType = admonitionType
82
101
  this.__title = title
83
- this.__content = content ?? createEditor()
84
102
  }
85
103
 
86
- exportDOM(): DOMExportOutput {
87
- const element = document.createElement('div')
88
- element.setAttribute('data-type', this.__admonitionType)
89
- element.setAttribute('data-title', this.__title)
90
- return { element }
104
+ updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedAdmonitionNode>): this {
105
+ return super
106
+ .updateFromJSON(serializedNode)
107
+ .setAdmonitionType(serializedNode.admonitionType)
108
+ .setTitle(serializedNode.title)
91
109
  }
92
110
 
93
111
  exportJSON(): SerializedAdmonitionNode {
94
112
  return {
113
+ ...super.exportJSON(),
95
114
  admonitionType: this.__admonitionType,
96
115
  title: this.__title,
97
- content: this.__content.toJSON(),
98
- type: 'admonition',
99
- version: 1,
100
116
  }
101
117
  }
102
118
 
103
- isInline(): false {
104
- return false
119
+ exportDOM(): DOMExportOutput {
120
+ const element = document.createElement('div')
121
+ element.setAttribute('data-type', this.__admonitionType)
122
+ element.setAttribute('data-title', this.__title)
123
+ element.className = `admonition admonition-${this.__admonitionType}`
124
+ return { element }
105
125
  }
106
126
 
107
- getTitle(): string {
108
- return this.__title
109
- }
127
+ // Structure / behaviour ----------------------------------------------------
110
128
 
111
- setTitle(title: string): void {
112
- const writable = this.getWritable()
113
- writable.__title = title
129
+ isShadowRoot(): boolean {
130
+ return true
114
131
  }
115
132
 
116
- getAdmonitionType(): AdmonitionType {
117
- return this.__admonitionType
133
+ isInline(): false {
134
+ return false
118
135
  }
119
136
 
120
- setAdmonitionType(admonitionType: AdmonitionType): void {
121
- const writable = this.getWritable()
122
- writable.__admonitionType = admonitionType
137
+ canBeEmpty(): boolean {
138
+ // Kept true so an in-flight empty admonition isn't auto-removed mid-edit;
139
+ // the extension's structure transform re-seeds an empty paragraph.
140
+ return true
123
141
  }
124
142
 
125
- update(payload: AdmonitionAttributes): void {
126
- const writable = this.getWritable()
127
- const { admonitionType, title } = payload
128
- if (admonitionType != null) {
129
- writable.__admonitionType = admonitionType
130
- }
131
- if (title != null) {
132
- writable.__title = title
133
- }
143
+ canIndent(): false {
144
+ return false
134
145
  }
135
146
 
136
- // View
147
+ // View ---------------------------------------------------------------------
148
+
149
+ createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
150
+ const key = this.getKey()
151
+ const dom = document.createElement('div')
152
+ dom.setAttribute('data-type', this.__admonitionType)
153
+ dom.setAttribute('data-title', this.__title)
154
+ dom.className = `${config.theme.admonition ?? ''} admonition-${this.__admonitionType}`.trim()
155
+
156
+ // Non-editable header chrome: icon + title + edit affordance. Marked
157
+ // contenteditable=false so the caret never lands in it and Lexical's
158
+ // reconciler leaves it alone (managed children go into the slot below).
159
+ const header = document.createElement('div')
160
+ header.className = 'AdmonitionNode__header'
161
+ header.contentEditable = 'false'
162
+ header.setAttribute('data-lexical-admonition-chrome', 'true')
163
+
164
+ const icon = document.createElement('span')
165
+ icon.className = 'AdmonitionNode__icon'
166
+ icon.innerHTML = ICON_SVG[this.__admonitionType]
167
+
168
+ const titleEl = document.createElement('span')
169
+ titleEl.className = 'AdmonitionNode__title'
170
+ titleEl.textContent = this.__title
171
+
172
+ const editButton = document.createElement('button')
173
+ editButton.type = 'button'
174
+ editButton.className = 'AdmonitionNode__edit-button'
175
+ editButton.textContent = 'Edit'
176
+ editButton.addEventListener('click', (event) => {
177
+ event.preventDefault()
178
+ event.stopPropagation()
179
+ editor.dispatchCommand(OPEN_ADMONITION_MODAL_COMMAND, { nodeKey: key })
180
+ })
181
+
182
+ header.appendChild(icon)
183
+ header.appendChild(titleEl)
184
+ header.appendChild(editButton)
185
+
186
+ const content = document.createElement('div')
187
+ content.className = 'AdmonitionNode__content'
188
+
189
+ dom.appendChild(header)
190
+ dom.appendChild(content)
191
+ return dom
192
+ }
137
193
 
138
- createDOM(config: EditorConfig): HTMLElement {
139
- const div = document.createElement('div')
140
- div.setAttribute('data-type', this.__admonitionType)
141
- div.setAttribute('data-title', this.__title)
142
- const className = `${config.theme.admonition} admonition-${this.__admonitionType}`
143
- if (className !== undefined) {
144
- div.className = className
194
+ getDOMSlot(element: HTMLElement): ElementDOMSlot<HTMLElement> {
195
+ const content = element.querySelector(':scope > .AdmonitionNode__content')
196
+ if (!(content instanceof HTMLElement)) {
197
+ throw new Error('AdmonitionNode: expected a .AdmonitionNode__content slot element')
145
198
  }
146
- return div
199
+ return super.getDOMSlot(element).withElement(content)
147
200
  }
148
201
 
149
- updateDOM(prevNode: AdmonitionNode, dom: HTMLElement, config: EditorConfig): boolean {
150
- const admonitionType = this.__admonitionType
151
- if (admonitionType !== prevNode.__admonitionType) {
152
- const className = `${config.theme.admonition} admonition-${admonitionType}`
153
- if (className !== undefined) {
154
- dom.className = className
202
+ updateDOM(prevNode: AdmonitionNode, dom: HTMLElement): boolean {
203
+ const type = this.__admonitionType
204
+ if (type !== prevNode.__admonitionType) {
205
+ dom.className =
206
+ `${dom.className.replace(/admonition-\w+/, '').trim()} admonition-${type}`.trim()
207
+ dom.setAttribute('data-type', type)
208
+ const icon = dom.querySelector(':scope > .AdmonitionNode__header > .AdmonitionNode__icon')
209
+ if (icon != null) {
210
+ icon.innerHTML = ICON_SVG[type]
155
211
  }
156
- dom.setAttribute('data-type', admonitionType)
157
- return true
158
212
  }
159
213
  if (this.__title !== prevNode.__title) {
160
214
  dom.setAttribute('data-title', this.__title)
161
- return true
215
+ const titleEl = dom.querySelector(':scope > .AdmonitionNode__header > .AdmonitionNode__title')
216
+ if (titleEl != null) {
217
+ titleEl.textContent = this.__title
218
+ }
162
219
  }
220
+ // Never recreate the DOM — that would drop the header chrome and its
221
+ // click handler. Mutations above are applied in place.
163
222
  return false
164
223
  }
165
224
 
166
- decorate(): React.JSX.Element {
167
- return (
168
- <AdmonitionNodeComponent
169
- admonitionType={this.__admonitionType}
170
- title={this.__title}
171
- content={this.__content}
172
- nodeKey={this.getKey()}
173
- />
174
- )
225
+ // Accessors ----------------------------------------------------------------
226
+
227
+ getTitle(): string {
228
+ return this.getLatest().__title
229
+ }
230
+
231
+ setTitle(title: string): this {
232
+ const writable = this.getWritable()
233
+ writable.__title = title
234
+ return writable
235
+ }
236
+
237
+ getAdmonitionType(): AdmonitionType {
238
+ return this.getLatest().__admonitionType
239
+ }
240
+
241
+ setAdmonitionType(admonitionType: AdmonitionType): this {
242
+ const writable = this.getWritable()
243
+ writable.__admonitionType = admonitionType
244
+ return writable
245
+ }
246
+
247
+ update(payload: Partial<Pick<AdmonitionAttributes, 'admonitionType' | 'title'>>): void {
248
+ const writable = this.getWritable()
249
+ if (payload.admonitionType != null) {
250
+ writable.__admonitionType = payload.admonitionType
251
+ }
252
+ if (payload.title != null) {
253
+ writable.__title = payload.title
254
+ }
175
255
  }
176
256
  }
177
257
 
178
258
  export function $createAdmonitionNode({
179
259
  admonitionType,
180
260
  title,
181
- content,
182
261
  key,
183
262
  }: AdmonitionAttributes): AdmonitionNode {
184
- return $applyNodeReplacement(new AdmonitionNode(admonitionType, title, content, key))
263
+ return $applyNodeReplacement(new AdmonitionNode(admonitionType, title, key))
185
264
  }
186
265
 
187
266
  export function $isAdmonitionNode(node: LexicalNode | null | undefined): node is AdmonitionNode {
@@ -6,13 +6,9 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ export * from './admonition-commands'
9
10
  export * from './admonition-node'
10
- // Intentionally NOT re-exported: `admonition-node-component` is loaded
11
- // lazily via `React.lazy` inside `admonition-node.tsx` so it can land in
12
- // its own chunk. Re-exporting it here would static-import it back into
13
- // any consumer of this barrel and defeat the split.
14
- //
15
- // Likewise NOT re-exported: `admonition-extension.tsx` and its dependencies
16
- // (`admonition-modal.tsx`, `fields.ts`, `types.ts`) — those pull React UI
17
- // and would bloat consumers that only want the node class.
11
+ // Intentionally NOT re-exported: `admonition-extension.tsx` and its
12
+ // dependencies (`admonition-modal.tsx`, `fields.ts`, `types.ts`) those pull
13
+ // React UI and would bloat consumers that only want the node class.
18
14
  export * from './node-types'
@@ -6,28 +6,28 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import type {
10
- LexicalEditor,
11
- NodeKey,
12
- SerializedEditor,
13
- SerializedLexicalNode,
14
- Spread,
15
- } from 'lexical'
9
+ import type { NodeKey, SerializedElementNode, Spread } from 'lexical'
16
10
 
17
11
  export type AdmonitionType = 'note' | 'tip' | 'warning' | 'danger'
18
12
 
19
13
  export interface AdmonitionAttributes {
20
14
  admonitionType: AdmonitionType
21
15
  title: string
22
- content?: LexicalEditor
23
16
  key?: NodeKey
24
17
  }
25
18
 
19
+ /**
20
+ * The admonition is an `ElementNode` — its body lives as real children in
21
+ * the main editor tree (paragraphs + inline content), so the serialized
22
+ * shape extends `SerializedElementNode` and carries `children`. `type` and
23
+ * `title` are node-level attributes set from the Insert/Edit modal; they
24
+ * ride the opening Docusaurus fence (`:::type[title]`) on markdown export,
25
+ * not the body.
26
+ */
26
27
  export type SerializedAdmonitionNode = Spread<
27
28
  {
28
29
  admonitionType: AdmonitionType
29
30
  title: string
30
- content: SerializedEditor
31
31
  },
32
- SerializedLexicalNode
32
+ SerializedElementNode
33
33
  >
@@ -279,7 +279,8 @@ function TextFormatFloatingToolbar({
279
279
 
280
280
  function useFloatingTextFormatToolbar(
281
281
  editor: LexicalEditor,
282
- anchorElem: HTMLElement
282
+ anchorElem: HTMLElement,
283
+ shouldShow?: () => boolean
283
284
  ): React.JSX.Element | null {
284
285
  const [isText, setIsText] = useState(false)
285
286
  const [isLink, setIsLink] = useState(false)
@@ -315,6 +316,13 @@ function useFloatingTextFormatToolbar(
315
316
  return
316
317
  }
317
318
 
319
+ // Caller-supplied gate (e.g. "only inside an admonition body"). Runs in
320
+ // this read context so it can inspect the current selection's ancestry.
321
+ if (shouldShow != null && !shouldShow()) {
322
+ setIsText(false)
323
+ return
324
+ }
325
+
318
326
  const node = getSelectedNode(selection)
319
327
 
320
328
  // Update text format
@@ -345,7 +353,7 @@ function useFloatingTextFormatToolbar(
345
353
  setIsText(false)
346
354
  }
347
355
  })
348
- }, [editor])
356
+ }, [editor, shouldShow])
349
357
 
350
358
  useEffect(() => {
351
359
  document.addEventListener('selectionchange', updatePopup)
@@ -390,9 +398,17 @@ function useFloatingTextFormatToolbar(
390
398
 
391
399
  export function FloatingTextFormatToolbarPlugin({
392
400
  anchorElem = document.body,
401
+ shouldShow,
393
402
  }: {
394
403
  anchorElem?: HTMLElement
404
+ /**
405
+ * Optional gate evaluated inside an editor read context. Return `false`
406
+ * to suppress the popover for the current selection — used to scope the
407
+ * toolbar to admonition bodies on editors where the global
408
+ * `FloatingTextFormatExtension` is removed.
409
+ */
410
+ shouldShow?: () => boolean
395
411
  }): React.JSX.Element | null {
396
412
  const [editor] = useLexicalComposerContext()
397
- return useFloatingTextFormatToolbar(editor, anchorElem)
413
+ return useFloatingTextFormatToolbar(editor, anchorElem, shouldShow)
398
414
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Round-trip coverage for the admonition markdown transformer. The
11
+ * `ElementNode` body (paragraphs + inline content) is converted with the same
12
+ * engine as the rest of the document, so a markdown → Lexical → markdown
13
+ * cycle must be stable. These run headless (no DOM / React).
14
+ */
15
+
16
+ import { CodeNode } from '@lexical/code'
17
+ import { createHeadlessEditor } from '@lexical/headless'
18
+ import { LinkNode } from '@lexical/link'
19
+ import { ListItemNode, ListNode } from '@lexical/list'
20
+ import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
21
+ import { HeadingNode, QuoteNode } from '@lexical/rich-text'
22
+ import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
23
+ import { $getRoot, type Klass, type LexicalNode } from 'lexical'
24
+ import { describe, expect, it } from 'vitest'
25
+
26
+ import { $isAdmonitionNode, AdmonitionNode } from '../extensions/admonition/admonition-node'
27
+ import { BYLINE_TRANSFORMERS } from './transformers'
28
+
29
+ // Explicit node list rather than the `../nodes` barrel — that barrel pulls in
30
+ // JSX-bearing decorator nodes (inline-image, embeds) which node-mode Vitest
31
+ // won't parse. The new AdmonitionNode is pure-DOM, so it imports cleanly here.
32
+ const Nodes: Array<Klass<LexicalNode>> = [
33
+ AdmonitionNode,
34
+ LinkNode,
35
+ HeadingNode,
36
+ QuoteNode,
37
+ ListNode,
38
+ ListItemNode,
39
+ CodeNode,
40
+ TableNode,
41
+ TableRowNode,
42
+ TableCellNode,
43
+ ]
44
+
45
+ function roundTrip(markdown: string): string {
46
+ const editor = createHeadlessEditor({
47
+ namespace: 'test',
48
+ nodes: Nodes,
49
+ onError: (e) => {
50
+ throw e
51
+ },
52
+ })
53
+ let output = ''
54
+ editor.update(
55
+ () => {
56
+ $convertFromMarkdownString(markdown, BYLINE_TRANSFORMERS)
57
+ },
58
+ { discrete: true }
59
+ )
60
+ editor.read(() => {
61
+ output = $convertToMarkdownString(BYLINE_TRANSFORMERS)
62
+ })
63
+ return output
64
+ }
65
+
66
+ describe('admonition markdown round-trip', () => {
67
+ it('round-trips a titled warning with inline formatting + a link', () => {
68
+ const md = [
69
+ ':::warning[Heads up]',
70
+ 'This is **bold** and a [link](https://example.com).',
71
+ ':::',
72
+ ].join('\n')
73
+ expect(roundTrip(md)).toBe(md)
74
+ })
75
+
76
+ it('round-trips a multi-paragraph body', () => {
77
+ const md = [':::note[Notes]', 'First paragraph.', '', 'Second paragraph.', ':::'].join('\n')
78
+ expect(roundTrip(md)).toBe(md)
79
+ })
80
+
81
+ it('round-trips an admonition with no title', () => {
82
+ const md = [':::tip', 'A quick tip.', ':::'].join('\n')
83
+ expect(roundTrip(md)).toBe(md)
84
+ })
85
+
86
+ it('builds an AdmonitionNode with the right type + title on import', () => {
87
+ const editor = createHeadlessEditor({
88
+ namespace: 'test',
89
+ nodes: Nodes,
90
+ onError: (e) => {
91
+ throw e
92
+ },
93
+ })
94
+ editor.update(
95
+ () => {
96
+ $convertFromMarkdownString(':::danger[Stop]\nBody text.\n:::', BYLINE_TRANSFORMERS)
97
+ },
98
+ { discrete: true }
99
+ )
100
+ editor.read(() => {
101
+ const node = $getRoot().getFirstChild()
102
+ expect($isAdmonitionNode(node)).toBe(true)
103
+ if ($isAdmonitionNode(node)) {
104
+ expect(node.getAdmonitionType()).toBe('danger')
105
+ expect(node.getTitle()).toBe('Stop')
106
+ expect(node.getTextContent()).toContain('Body text.')
107
+ }
108
+ })
109
+ })
110
+ })