diff --git a/packages/richtext-lexical/package.json b/packages/richtext-lexical/package.json
index adae1f27b..40e279285 100644
--- a/packages/richtext-lexical/package.json
+++ b/packages/richtext-lexical/package.json
@@ -340,7 +340,6 @@
"@lexical/link": "0.20.0",
"@lexical/list": "0.20.0",
"@lexical/mark": "0.20.0",
- "@lexical/markdown": "0.20.0",
"@lexical/react": "0.20.0",
"@lexical/rich-text": "0.20.0",
"@lexical/selection": "0.20.0",
@@ -388,7 +387,6 @@
"@lexical/link": "0.20.0",
"@lexical/list": "0.20.0",
"@lexical/mark": "0.20.0",
- "@lexical/markdown": "0.20.0",
"@lexical/react": "0.20.0",
"@lexical/rich-text": "0.20.0",
"@lexical/selection": "0.20.0",
diff --git a/packages/richtext-lexical/src/features/blockquote/markdownTransformer.ts b/packages/richtext-lexical/src/features/blockquote/markdownTransformer.ts
index be9f3e2f1..262f0da04 100644
--- a/packages/richtext-lexical/src/features/blockquote/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/blockquote/markdownTransformer.ts
@@ -1,8 +1,8 @@
-import type { ElementTransformer } from '@lexical/markdown'
-
import { $createQuoteNode, $isQuoteNode, QuoteNode } from '@lexical/rich-text'
import { $createLineBreakNode } from 'lexical'
+import type { ElementTransformer } from '../../packages/@lexical/markdown/index.js'
+
export const MarkdownTransformer: ElementTransformer = {
type: 'element',
dependencies: [QuoteNode],
diff --git a/packages/richtext-lexical/src/features/blocks/client/markdownTransformer.ts b/packages/richtext-lexical/src/features/blocks/client/markdownTransformer.ts
index 3f10fb470..7bae3ca5b 100644
--- a/packages/richtext-lexical/src/features/blocks/client/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/blocks/client/markdownTransformer.ts
@@ -1,12 +1,15 @@
-import type { MultilineElementTransformer, Transformer } from '@lexical/markdown'
import type { Klass, LexicalNode, LexicalNodeReplacement, SerializedEditorState } from 'lexical'
import type { ClientBlock } from 'payload'
import { createHeadlessEditor } from '@lexical/headless'
-import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
+import type { Transformer } from '../../../packages/@lexical/markdown/index.js'
+import type { MultilineElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
+
+import { $convertToMarkdownString } from '../../../packages/@lexical/markdown/index.js'
import { extractPropsFromJSXPropsString } from '../../../utilities/jsx/extractPropsFromJSXPropsString.js'
import { propsToJSXString } from '../../../utilities/jsx/jsx.js'
+import { $convertFromMarkdownString } from '../../../utilities/jsx/lexicalMarkdownCopy.js'
import { $createBlockNode, $isBlockNode, BlockNode } from './nodes/BlocksNode.js'
function createTagRegexes(tagName: string) {
diff --git a/packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts b/packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts
index 61d22dd5c..ca5d07c34 100644
--- a/packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts
@@ -1,18 +1,18 @@
-import type {
- MultilineElementTransformer,
- TextMatchTransformer,
- Transformer,
-} from '@lexical/markdown'
import type { ElementNode, SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { Block } from 'payload'
import { createHeadlessEditor } from '@lexical/headless'
-import { $convertToMarkdownString } from '@lexical/markdown'
import { $parseSerializedNode } from 'lexical'
import type { NodeWithHooks } from '../../typesServer.js'
import { getEnabledNodesFromServerNodes } from '../../../lexical/nodes/index.js'
+import {
+ $convertToMarkdownString,
+ type MultilineElementTransformer,
+ type TextMatchTransformer,
+ type Transformer,
+} from '../../../packages/@lexical/markdown/index.js'
import { extractPropsFromJSXPropsString } from '../../../utilities/jsx/extractPropsFromJSXPropsString.js'
import { propsToJSXString } from '../../../utilities/jsx/jsx.js'
import { $convertFromMarkdownString } from '../../../utilities/jsx/lexicalMarkdownCopy.js'
diff --git a/packages/richtext-lexical/src/features/experimental_table/markdownTransformer.ts b/packages/richtext-lexical/src/features/experimental_table/markdownTransformer.ts
index baa83f05d..4b7836a24 100644
--- a/packages/richtext-lexical/src/features/experimental_table/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/experimental_table/markdownTransformer.ts
@@ -1,7 +1,5 @@
-import type { ElementTransformer, Transformer } from '@lexical/markdown'
import type { LexicalNode } from 'lexical'
-import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
import {
$createTableCellNode,
$createTableNode,
@@ -16,6 +14,13 @@ import {
} from '@lexical/table'
import { $isParagraphNode, $isTextNode } from 'lexical'
+import {
+ $convertToMarkdownString,
+ type ElementTransformer,
+ type Transformer,
+} from '../../packages/@lexical/markdown/index.js'
+import { $convertFromMarkdownString } from '../../utilities/jsx/lexicalMarkdownCopy.js'
+
// Very primitive table setup
const TABLE_ROW_REG_EXP = /^\|(.+)\|\s?$/
// eslint-disable-next-line regexp/no-unused-capturing-group
diff --git a/packages/richtext-lexical/src/features/format/bold/markdownTransformers.ts b/packages/richtext-lexical/src/features/format/bold/markdownTransformers.ts
index dca723087..8502bc9d2 100644
--- a/packages/richtext-lexical/src/features/format/bold/markdownTransformers.ts
+++ b/packages/richtext-lexical/src/features/format/bold/markdownTransformers.ts
@@ -1,4 +1,4 @@
-import type { TextFormatTransformer } from '@lexical/markdown'
+import type { TextFormatTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
export const BOLD_ITALIC_STAR: TextFormatTransformer = {
type: 'text-format',
diff --git a/packages/richtext-lexical/src/features/format/inlineCode/markdownTransformers.ts b/packages/richtext-lexical/src/features/format/inlineCode/markdownTransformers.ts
index c31e9582d..00b25f56b 100644
--- a/packages/richtext-lexical/src/features/format/inlineCode/markdownTransformers.ts
+++ b/packages/richtext-lexical/src/features/format/inlineCode/markdownTransformers.ts
@@ -1,4 +1,4 @@
-import type { TextFormatTransformer } from '@lexical/markdown'
+import type { TextFormatTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
export const INLINE_CODE: TextFormatTransformer = {
type: 'text-format',
diff --git a/packages/richtext-lexical/src/features/format/italic/markdownTransformers.ts b/packages/richtext-lexical/src/features/format/italic/markdownTransformers.ts
index 5698005ff..5029f80d0 100644
--- a/packages/richtext-lexical/src/features/format/italic/markdownTransformers.ts
+++ b/packages/richtext-lexical/src/features/format/italic/markdownTransformers.ts
@@ -1,4 +1,4 @@
-import type { TextFormatTransformer } from '@lexical/markdown'
+import type { TextFormatTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
export const ITALIC_STAR: TextFormatTransformer = {
type: 'text-format',
diff --git a/packages/richtext-lexical/src/features/format/strikethrough/markdownTransformers.ts b/packages/richtext-lexical/src/features/format/strikethrough/markdownTransformers.ts
index 01a88390c..05072cc85 100644
--- a/packages/richtext-lexical/src/features/format/strikethrough/markdownTransformers.ts
+++ b/packages/richtext-lexical/src/features/format/strikethrough/markdownTransformers.ts
@@ -1,4 +1,4 @@
-import type { TextFormatTransformer } from '@lexical/markdown'
+import type { TextFormatTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
export const STRIKETHROUGH: TextFormatTransformer = {
type: 'text-format',
diff --git a/packages/richtext-lexical/src/features/heading/markdownTransformer.ts b/packages/richtext-lexical/src/features/heading/markdownTransformer.ts
index 9ae14e78c..b157d855a 100644
--- a/packages/richtext-lexical/src/features/heading/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/heading/markdownTransformer.ts
@@ -1,8 +1,9 @@
-import type { ElementTransformer } from '@lexical/markdown'
import type { HeadingTagType } from '@lexical/rich-text'
import { $createHeadingNode, $isHeadingNode, HeadingNode } from '@lexical/rich-text'
+import type { ElementTransformer } from '../../packages/@lexical/markdown/MarkdownTransformers.js'
+
import { createBlockNode } from '../../lexical/utils/markdown/createBlockNode.js'
export const MarkdownTransformer: (enabledHeadingSizes: HeadingTagType[]) => ElementTransformer = (
diff --git a/packages/richtext-lexical/src/features/horizontalRule/client/markdownTransformer.ts b/packages/richtext-lexical/src/features/horizontalRule/client/markdownTransformer.ts
index dfc94ddc1..fdcb5658d 100644
--- a/packages/richtext-lexical/src/features/horizontalRule/client/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/horizontalRule/client/markdownTransformer.ts
@@ -1,4 +1,4 @@
-import type { ElementTransformer } from '@lexical/markdown'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
import {
$createHorizontalRuleNode,
diff --git a/packages/richtext-lexical/src/features/horizontalRule/server/markdownTransformer.ts b/packages/richtext-lexical/src/features/horizontalRule/server/markdownTransformer.ts
index b5f4b07dc..d0b33f812 100644
--- a/packages/richtext-lexical/src/features/horizontalRule/server/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/horizontalRule/server/markdownTransformer.ts
@@ -1,4 +1,4 @@
-import type { ElementTransformer } from '@lexical/markdown'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
import {
$createHorizontalRuleServerNode,
diff --git a/packages/richtext-lexical/src/features/link/markdownTransformer.ts b/packages/richtext-lexical/src/features/link/markdownTransformer.ts
index de55ad7ae..19a153992 100644
--- a/packages/richtext-lexical/src/features/link/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/link/markdownTransformer.ts
@@ -6,10 +6,10 @@
//
// - code should go first as it prevents any transformations inside
-import type { TextMatchTransformer } from '@lexical/markdown'
-
import { $createTextNode, $isTextNode } from 'lexical'
+import type { TextMatchTransformer } from '../../packages/@lexical/markdown/MarkdownTransformers.js'
+
import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js'
// - then longer tags match (e.g. ** or __ should go before * or _)
diff --git a/packages/richtext-lexical/src/features/lists/checklist/markdownTransformers.ts b/packages/richtext-lexical/src/features/lists/checklist/markdownTransformers.ts
index eb96d99ce..b87b8d728 100644
--- a/packages/richtext-lexical/src/features/lists/checklist/markdownTransformers.ts
+++ b/packages/richtext-lexical/src/features/lists/checklist/markdownTransformers.ts
@@ -1,7 +1,7 @@
-import type { ElementTransformer } from '@lexical/markdown'
-
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
+
import { listExport, listReplace } from '../shared/markdown.js'
export const CHECK_LIST: ElementTransformer = {
diff --git a/packages/richtext-lexical/src/features/lists/orderedList/markdownTransformer.ts b/packages/richtext-lexical/src/features/lists/orderedList/markdownTransformer.ts
index cd4545746..f17509976 100644
--- a/packages/richtext-lexical/src/features/lists/orderedList/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/lists/orderedList/markdownTransformer.ts
@@ -1,7 +1,7 @@
-import type { ElementTransformer } from '@lexical/markdown'
-
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
+
import { listExport, listReplace } from '../shared/markdown.js'
export const ORDERED_LIST: ElementTransformer = {
diff --git a/packages/richtext-lexical/src/features/lists/shared/markdown.ts b/packages/richtext-lexical/src/features/lists/shared/markdown.ts
index 8393adc32..2044d3fe6 100644
--- a/packages/richtext-lexical/src/features/lists/shared/markdown.ts
+++ b/packages/richtext-lexical/src/features/lists/shared/markdown.ts
@@ -1,11 +1,12 @@
// Copied from https://github.com/facebook/lexical/blob/176b8cf16ecb332ee5efe2c75219e223b7b019f2/packages/lexical-markdown/src/MarkdownTransformers.ts#L97C1-L172C1
import type { ListNode, ListType } from '@lexical/list'
-import type { ElementTransformer } from '@lexical/markdown'
import type { ElementNode } from 'lexical'
import { $createListItemNode, $createListNode, $isListItemNode, $isListNode } from '@lexical/list'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
+
// Amount of spaces that define indentation level
const LIST_INDENT_SIZE = 4
diff --git a/packages/richtext-lexical/src/features/lists/unorderedList/markdownTransformer.ts b/packages/richtext-lexical/src/features/lists/unorderedList/markdownTransformer.ts
index 773d327cd..e87f43094 100644
--- a/packages/richtext-lexical/src/features/lists/unorderedList/markdownTransformer.ts
+++ b/packages/richtext-lexical/src/features/lists/unorderedList/markdownTransformer.ts
@@ -1,7 +1,7 @@
-import type { ElementTransformer } from '@lexical/markdown'
-
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
+
import { listExport, listReplace } from '../shared/markdown.js'
export const UNORDERED_LIST: ElementTransformer = {
diff --git a/packages/richtext-lexical/src/features/typesClient.ts b/packages/richtext-lexical/src/features/typesClient.ts
index f7b3bb35e..e329c2fba 100644
--- a/packages/richtext-lexical/src/features/typesClient.ts
+++ b/packages/richtext-lexical/src/features/typesClient.ts
@@ -1,10 +1,10 @@
-import type { Transformer } from '@lexical/markdown'
import type { Klass, LexicalEditor, LexicalNode, LexicalNodeReplacement } from 'lexical'
import type { RichTextFieldClient } from 'payload'
import type React from 'react'
import type { ClientEditorConfig } from '../lexical/config/types.js'
import type { SlashMenuGroup } from '../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
+import type { Transformer } from '../packages/@lexical/markdown/index.js'
import type { FeatureClientSchemaMap } from '../types.js'
import type { ToolbarGroup } from './toolbars/types.js'
diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts
index 6684ba9d8..5cc53c6f2 100644
--- a/packages/richtext-lexical/src/features/typesServer.ts
+++ b/packages/richtext-lexical/src/features/typesServer.ts
@@ -1,4 +1,3 @@
-import type { Transformer } from '@lexical/markdown'
import type { GenericLanguages, I18nClient } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'
import type {
@@ -27,6 +26,7 @@ import type {
} from 'payload'
import type { ServerEditorConfig } from '../lexical/config/types.js'
+import type { Transformer } from '../packages/@lexical/markdown/index.js'
import type { AdapterProps } from '../types.js'
import type { HTMLConverter } from './converters/html/converter/types.js'
import type { BaseClientFeatureProps } from './typesClient.js'
diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts
index 0f0034b20..578327900 100644
--- a/packages/richtext-lexical/src/index.ts
+++ b/packages/richtext-lexical/src/index.ts
@@ -55,7 +55,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
'@lexical/link',
'@lexical/list',
'@lexical/mark',
- '@lexical/markdown',
'@lexical/react',
'@lexical/rich-text',
'@lexical/selection',
diff --git a/packages/richtext-lexical/src/lexical-proxy/@lexical-markdown.ts b/packages/richtext-lexical/src/lexical-proxy/@lexical-markdown.ts
index b3d7458a1..3e49960d1 100644
--- a/packages/richtext-lexical/src/lexical-proxy/@lexical-markdown.ts
+++ b/packages/richtext-lexical/src/lexical-proxy/@lexical-markdown.ts
@@ -1 +1 @@
-export * from '@lexical/markdown'
+export * from '../packages/@lexical/markdown/index.js'
diff --git a/packages/richtext-lexical/src/lexical/plugins/MarkdownShortcut/index.tsx b/packages/richtext-lexical/src/lexical/plugins/MarkdownShortcut/index.tsx
index a25f86ead..7c28355f8 100644
--- a/packages/richtext-lexical/src/lexical/plugins/MarkdownShortcut/index.tsx
+++ b/packages/richtext-lexical/src/lexical/plugins/MarkdownShortcut/index.tsx
@@ -1,12 +1,18 @@
'use client'
-import { MarkdownShortcutPlugin as LexicalMarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin.js'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import * as React from 'react'
+import { registerMarkdownShortcuts } from '../../../packages/@lexical/markdown/MarkdownShortcuts.js'
import { useEditorConfigContext } from '../../config/client/EditorConfigProvider.js'
export const MarkdownShortcutPlugin: React.FC = () => {
const { editorConfig } = useEditorConfigContext()
+ const [editor] = useLexicalComposerContext()
- return
+ React.useEffect(() => {
+ return registerMarkdownShortcuts(editor, editorConfig.features.markdownTransformers ?? [])
+ }, [editor, editorConfig.features.markdownTransformers])
+
+ return null
}
diff --git a/packages/richtext-lexical/src/lexical/utils/markdown/createBlockNode.ts b/packages/richtext-lexical/src/lexical/utils/markdown/createBlockNode.ts
index cc707cf32..fa9008599 100644
--- a/packages/richtext-lexical/src/lexical/utils/markdown/createBlockNode.ts
+++ b/packages/richtext-lexical/src/lexical/utils/markdown/createBlockNode.ts
@@ -1,6 +1,7 @@
-import type { ElementTransformer } from '@lexical/markdown'
import type { ElementNode } from 'lexical'
+import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
+
export const createBlockNode = (
createNode: (match: Array) => ElementNode,
): ElementTransformer['replace'] => {
diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/LICENSE b/packages/richtext-lexical/src/packages/@lexical/markdown/LICENSE
new file mode 100644
index 000000000..b93be9051
--- /dev/null
+++ b/packages/richtext-lexical/src/packages/@lexical/markdown/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Meta Platforms, Inc. and affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts
new file mode 100644
index 000000000..e5448f403
--- /dev/null
+++ b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownExport.ts
@@ -0,0 +1,223 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ElementNode, LexicalNode, TextFormatType, TextNode } from 'lexical'
+
+import { $getRoot, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isTextNode } from 'lexical'
+
+import type {
+ ElementTransformer,
+ MultilineElementTransformer,
+ TextFormatTransformer,
+ TextMatchTransformer,
+ Transformer,
+} from './MarkdownTransformers.js'
+
+import { isEmptyParagraph, transformersByType } from './utils.js'
+
+/**
+ * Renders string from markdown. The selection is moved to the start after the operation.
+ */
+export function createMarkdownExport(
+ transformers: Array,
+ shouldPreserveNewLines: boolean = false,
+): (node?: ElementNode) => string {
+ const byType = transformersByType(transformers)
+ const elementTransformers = [...byType.multilineElement, ...byType.element]
+ const isNewlineDelimited = !shouldPreserveNewLines
+
+ // Export only uses text formats that are responsible for single format
+ // e.g. it will filter out *** (bold, italic) and instead use separate ** and *
+ const textFormatTransformers = byType.textFormat.filter(
+ (transformer) => transformer.format.length === 1,
+ )
+
+ return (node) => {
+ const output: string[] = []
+ const children = (node || $getRoot()).getChildren()
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]
+ const result = exportTopLevelElements(
+ child,
+ elementTransformers,
+ textFormatTransformers,
+ byType.textMatch,
+ )
+
+ if (result != null) {
+ output.push(
+ // separate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"]
+ isNewlineDelimited &&
+ i > 0 &&
+ !isEmptyParagraph(child) &&
+ !isEmptyParagraph(children[i - 1])
+ ? '\n'.concat(result)
+ : result,
+ )
+ }
+ }
+ // Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline.
+ // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld"
+ return output.join('\n')
+ }
+}
+
+function exportTopLevelElements(
+ node: LexicalNode,
+ elementTransformers: Array,
+ textTransformersIndex: Array,
+ textMatchTransformers: Array,
+): null | string {
+ for (const transformer of elementTransformers) {
+ if (!transformer.export) {
+ continue
+ }
+ const result = transformer.export(node, (_node) =>
+ exportChildren(_node, textTransformersIndex, textMatchTransformers),
+ )
+
+ if (result != null) {
+ return result
+ }
+ }
+
+ if ($isElementNode(node)) {
+ return exportChildren(node, textTransformersIndex, textMatchTransformers)
+ } else if ($isDecoratorNode(node)) {
+ return node.getTextContent()
+ } else {
+ return null
+ }
+}
+
+function exportChildren(
+ node: ElementNode,
+ textTransformersIndex: Array,
+ textMatchTransformers: Array,
+): string {
+ const output: string[] = []
+ const children = node.getChildren()
+
+ mainLoop: for (const child of children) {
+ for (const transformer of textMatchTransformers) {
+ if (!transformer.export) {
+ continue
+ }
+
+ const result = transformer.export(
+ child,
+ (parentNode) => exportChildren(parentNode, textTransformersIndex, textMatchTransformers),
+ (textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex),
+ )
+
+ if (result != null) {
+ output.push(result)
+ continue mainLoop
+ }
+ }
+
+ if ($isLineBreakNode(child)) {
+ output.push('\n')
+ } else if ($isTextNode(child)) {
+ output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex))
+ } else if ($isElementNode(child)) {
+ // empty paragraph returns ""
+ output.push(exportChildren(child, textTransformersIndex, textMatchTransformers))
+ } else if ($isDecoratorNode(child)) {
+ output.push(child.getTextContent())
+ }
+ }
+
+ return output.join('')
+}
+
+function exportTextFormat(
+ node: TextNode,
+ textContent: string,
+ textTransformers: Array,
+): string {
+ // This function handles the case of a string looking like this: " foo "
+ // Where it would be invalid markdown to generate: "** foo **"
+ // We instead want to trim the whitespace out, apply formatting, and then
+ // bring the whitespace back. So our returned string looks like this: " **foo** "
+ const frozenString = textContent.trim()
+ let output = frozenString
+
+ const applied = new Set()
+
+ for (const transformer of textTransformers) {
+ const format = transformer.format[0]
+ const tag = transformer.tag
+
+ if (hasFormat(node, format) && !applied.has(format)) {
+ // Multiple tags might be used for the same format (*, _)
+ applied.add(format)
+ // Prevent adding opening tag is already opened by the previous sibling
+ const previousNode = getTextSibling(node, true)
+
+ if (!hasFormat(previousNode, format)) {
+ output = tag + output
+ }
+
+ // Prevent adding closing tag if next sibling will do it
+ const nextNode = getTextSibling(node, false)
+
+ if (!hasFormat(nextNode, format)) {
+ output += tag
+ }
+ }
+ }
+
+ // Replace trimmed version of textContent ensuring surrounding whitespace is not modified
+ return textContent.replace(frozenString, () => output)
+}
+
+// Get next or previous text sibling a text node, including cases
+// when it's a child of inline element (e.g. link)
+function getTextSibling(node: TextNode, backward: boolean): null | TextNode {
+ let sibling = backward ? node.getPreviousSibling() : node.getNextSibling()
+
+ if (!sibling) {
+ const parent = node.getParentOrThrow()
+
+ if (parent.isInline()) {
+ sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling()
+ }
+ }
+
+ while (sibling) {
+ if ($isElementNode(sibling)) {
+ if (!sibling.isInline()) {
+ break
+ }
+
+ const descendant = backward ? sibling.getLastDescendant() : sibling.getFirstDescendant()
+
+ if ($isTextNode(descendant)) {
+ return descendant
+ } else {
+ sibling = backward ? sibling.getPreviousSibling() : sibling.getNextSibling()
+ }
+ }
+
+ if ($isTextNode(sibling)) {
+ return sibling
+ }
+
+ if (!$isElementNode(sibling)) {
+ return null
+ }
+ }
+
+ return null
+}
+
+function hasFormat(node: LexicalNode | null | undefined, format: TextFormatType): boolean {
+ return $isTextNode(node) && node.hasFormat(format)
+}
diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts
new file mode 100644
index 000000000..05ac87136
--- /dev/null
+++ b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownImport.ts
@@ -0,0 +1,436 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ListItemNode } from '@lexical/list'
+import type { ElementNode, TextNode } from 'lexical'
+
+import { $isListItemNode, $isListNode } from '@lexical/list'
+import { $isQuoteNode } from '@lexical/rich-text'
+import { $findMatchingParent } from '@lexical/utils'
+import {
+ $createLineBreakNode,
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isParagraphNode,
+} from 'lexical'
+
+import type {
+ ElementTransformer,
+ MultilineElementTransformer,
+ TextFormatTransformer,
+ TextMatchTransformer,
+ Transformer,
+} from './MarkdownTransformers.js'
+
+import { IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI } from '../../../lexical/utils/environment.js'
+import { isEmptyParagraph, PUNCTUATION_OR_SPACE, transformersByType } from './utils.js'
+
+type TextFormatTransformersIndex = Readonly<{
+ fullMatchRegExpByTag: Readonly>
+ openTagsRegExp: RegExp
+ transformersByTag: Readonly>
+}>
+
+/**
+ * Renders markdown from a string. The selection is moved to the start after the operation.
+ */
+export function createMarkdownImport(
+ transformers: Array,
+ shouldPreserveNewLines = false,
+): (markdownString: string, node?: ElementNode) => void {
+ const byType = transformersByType(transformers)
+ const textFormatTransformersIndex = createTextFormatTransformersIndex(byType.textFormat)
+
+ return (markdownString, node) => {
+ const lines = markdownString.split('\n')
+ const linesLength = lines.length
+ const root = node || $getRoot()
+ root.clear()
+
+ for (let i = 0; i < linesLength; i++) {
+ const lineText = lines[i]
+
+ const [imported, shiftedIndex] = $importMultiline(lines, i, byType.multilineElement, root)
+
+ if (imported) {
+ // If a multiline markdown element was imported, we don't want to process the lines that were part of it anymore.
+ // There could be other sub-markdown elements (both multiline and normal ones) matching within this matched multiline element's children.
+ // However, it would be the responsibility of the matched multiline transformer to decide how it wants to handle them.
+ // We cannot handle those, as there is no way for us to know how to maintain the correct order of generated lexical nodes for possible children.
+ i = shiftedIndex // Next loop will start from the line after the last line of the multiline element
+ continue
+ }
+
+ $importBlocks(lineText, root, byType.element, textFormatTransformersIndex, byType.textMatch)
+ }
+
+ // By default, removing empty paragraphs as md does not really
+ // allow empty lines and uses them as delimiter.
+ // If you need empty lines set shouldPreserveNewLines = true.
+ const children = root.getChildren()
+ for (const child of children) {
+ if (!shouldPreserveNewLines && isEmptyParagraph(child) && root.getChildrenSize() > 1) {
+ child.remove()
+ }
+ }
+
+ if ($getSelection() !== null) {
+ root.selectStart()
+ }
+ }
+}
+
+/**
+ *
+ * @returns first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed.
+ */
+function $importMultiline(
+ lines: Array,
+ startLineIndex: number,
+ multilineElementTransformers: Array,
+ rootNode: ElementNode,
+): [boolean, number] {
+ for (const transformer of multilineElementTransformers) {
+ const { handleImportAfterStartMatch, regExpEnd, regExpStart, replace } = transformer
+
+ const startMatch = lines[startLineIndex].match(regExpStart)
+ if (!startMatch) {
+ continue // Try next transformer
+ }
+
+ if (handleImportAfterStartMatch) {
+ const result = handleImportAfterStartMatch({
+ lines,
+ rootNode,
+ startLineIndex,
+ startMatch,
+ transformer,
+ })
+ if (result === null) {
+ continue
+ } else if (result) {
+ return result
+ }
+ }
+
+ const regexpEndRegex: RegExp | undefined =
+ typeof regExpEnd === 'object' && 'regExp' in regExpEnd ? regExpEnd.regExp : regExpEnd
+
+ const isEndOptional =
+ regExpEnd && typeof regExpEnd === 'object' && 'optional' in regExpEnd
+ ? regExpEnd.optional
+ : !regExpEnd
+
+ let endLineIndex = startLineIndex
+ const linesLength = lines.length
+
+ // check every single line for the closing match. It could also be on the same line as the opening match.
+ while (endLineIndex < linesLength) {
+ const endMatch = regexpEndRegex ? lines[endLineIndex].match(regexpEndRegex) : null
+ if (!endMatch) {
+ if (
+ !isEndOptional ||
+ (isEndOptional && endLineIndex < linesLength - 1) // Optional end, but didn't reach the end of the document yet => continue searching for potential closing match
+ ) {
+ endLineIndex++
+ continue // Search next line for closing match
+ }
+ }
+
+ // Now, check if the closing match matched is the same as the opening match.
+ // If it is, we need to continue searching for the actual closing match.
+ if (endMatch && startLineIndex === endLineIndex && endMatch.index === startMatch.index) {
+ endLineIndex++
+ continue // Search next line for closing match
+ }
+
+ // At this point, we have found the closing match. Next: calculate the lines in between open and closing match
+ // This should not include the matches themselves, and be split up by lines
+ const linesInBetween: string[] = []
+
+ if (endMatch && startLineIndex === endLineIndex) {
+ linesInBetween.push(lines[startLineIndex].slice(startMatch[0].length, -endMatch[0].length))
+ } else {
+ for (let i = startLineIndex; i <= endLineIndex; i++) {
+ if (i === startLineIndex) {
+ const text = lines[i].slice(startMatch[0].length)
+ linesInBetween.push(text) // Also include empty text
+ } else if (i === endLineIndex && endMatch) {
+ const text = lines[i].slice(0, -endMatch[0].length)
+ linesInBetween.push(text) // Also include empty text
+ } else {
+ linesInBetween.push(lines[i])
+ }
+ }
+ }
+
+ if (replace(rootNode, null, startMatch, endMatch, linesInBetween, true) !== false) {
+ // Return here. This $importMultiline function is run line by line and should only process a single multiline element at a time.
+ return [true, endLineIndex]
+ }
+
+ // The replace function returned false, despite finding the matching open and close tags => this transformer does not want to handle it.
+ // Thus, we continue letting the remaining transformers handle the passed lines of text from the beginning
+ break
+ }
+ }
+
+ // No multiline transformer handled this line successfully
+ return [false, startLineIndex]
+}
+
+function $importBlocks(
+ lineText: string,
+ rootNode: ElementNode,
+ elementTransformers: Array,
+ textFormatTransformersIndex: TextFormatTransformersIndex,
+ textMatchTransformers: Array,
+) {
+ const textNode = $createTextNode(lineText)
+ const elementNode = $createParagraphNode()
+ elementNode.append(textNode)
+ rootNode.append(elementNode)
+
+ for (const { regExp, replace } of elementTransformers) {
+ const match = lineText.match(regExp)
+
+ if (match) {
+ textNode.setTextContent(lineText.slice(match[0].length))
+ if (replace(elementNode, [textNode], match, true) !== false) {
+ break
+ }
+ }
+ }
+
+ importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers)
+
+ // If no transformer found and we left with original paragraph node
+ // can check if its content can be appended to the previous node
+ // if it's a paragraph, quote or list
+ if (elementNode.isAttached() && lineText.length > 0) {
+ const previousNode = elementNode.getPreviousSibling()
+ if ($isParagraphNode(previousNode) || $isQuoteNode(previousNode) || $isListNode(previousNode)) {
+ let targetNode: ListItemNode | null | typeof previousNode = previousNode
+
+ if ($isListNode(previousNode)) {
+ const lastDescendant = previousNode.getLastDescendant()
+ if (lastDescendant == null) {
+ targetNode = null
+ } else {
+ targetNode = $findMatchingParent(lastDescendant, $isListItemNode)
+ }
+ }
+
+ if (targetNode != null && targetNode.getTextContentSize() > 0) {
+ targetNode.splice(targetNode.getChildrenSize(), 0, [
+ $createLineBreakNode(),
+ ...elementNode.getChildren(),
+ ])
+ elementNode.remove()
+ }
+ }
+ }
+}
+
+// Processing text content and replaces text format tags.
+// It takes outermost tag match and its content, creates text node with
+// format based on tag and then recursively executed over node's content
+//
+// E.g. for "*Hello **world**!*" string it will create text node with
+// "Hello **world**!" content and italic format and run recursively over
+// its content to transform "**world**" part
+function importTextFormatTransformers(
+ textNode: TextNode,
+ textFormatTransformersIndex: TextFormatTransformersIndex,
+ textMatchTransformers: Array,
+) {
+ const textContent = textNode.getTextContent()
+ const match = findOutermostMatch(textContent, textFormatTransformersIndex)
+
+ if (!match) {
+ // Once text format processing is done run text match transformers, as it
+ // only can span within single text node (unline formats that can cover multiple nodes)
+ importTextMatchTransformers(textNode, textMatchTransformers)
+ return
+ }
+
+ let currentNode, leadingNode, remainderNode
+
+ // If matching full content there's no need to run splitText and can reuse existing textNode
+ // to update its content and apply format. E.g. for **_Hello_** string after applying bold
+ // format (**) it will reuse the same text node to apply italic (_)
+ if (match[0] === textContent) {
+ currentNode = textNode
+ } else {
+ const startIndex = match.index || 0
+ const endIndex = startIndex + match[0].length
+
+ if (startIndex === 0) {
+ ;[currentNode, remainderNode] = textNode.splitText(endIndex)
+ } else {
+ ;[leadingNode, currentNode, remainderNode] = textNode.splitText(startIndex, endIndex)
+ }
+ }
+
+ currentNode.setTextContent(match[2])
+ const transformer = textFormatTransformersIndex.transformersByTag[match[1]]
+
+ if (transformer) {
+ for (const format of transformer.format) {
+ if (!currentNode.hasFormat(format)) {
+ currentNode.toggleFormat(format)
+ }
+ }
+ }
+
+ // Recursively run over inner text if it's not inline code
+ if (!currentNode.hasFormat('code')) {
+ importTextFormatTransformers(currentNode, textFormatTransformersIndex, textMatchTransformers)
+ }
+
+ // Run over leading/remaining text if any
+ if (leadingNode) {
+ importTextFormatTransformers(leadingNode, textFormatTransformersIndex, textMatchTransformers)
+ }
+
+ if (remainderNode) {
+ importTextFormatTransformers(remainderNode, textFormatTransformersIndex, textMatchTransformers)
+ }
+}
+
+function importTextMatchTransformers(
+ textNode_: TextNode,
+ textMatchTransformers: Array,
+) {
+ let textNode = textNode_
+
+ mainLoop: while (textNode) {
+ for (const transformer of textMatchTransformers) {
+ if (!transformer.replace || !transformer.importRegExp) {
+ continue
+ }
+ const match = textNode.getTextContent().match(transformer.importRegExp)
+
+ if (!match) {
+ continue
+ }
+
+ const startIndex = match.index || 0
+ const endIndex = transformer.getEndIndex
+ ? transformer.getEndIndex(textNode, match)
+ : startIndex + match[0].length
+
+ if (endIndex === false) {
+ continue
+ }
+
+ let newTextNode, replaceNode
+
+ if (startIndex === 0) {
+ ;[replaceNode, textNode] = textNode.splitText(endIndex)
+ } else {
+ ;[, replaceNode, newTextNode] = textNode.splitText(startIndex, endIndex)
+ }
+
+ if (newTextNode) {
+ importTextMatchTransformers(newTextNode, textMatchTransformers)
+ }
+ transformer.replace(replaceNode, match)
+ continue mainLoop
+ }
+
+ break
+ }
+}
+
+// Finds first "content" match that is not nested into another tag
+function findOutermostMatch(
+ textContent: string,
+ textTransformersIndex: TextFormatTransformersIndex,
+): null | RegExpMatchArray {
+ const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp)
+
+ if (openTagsMatch == null) {
+ return null
+ }
+
+ for (const match of openTagsMatch) {
+ // Open tags reg exp might capture leading space so removing it
+ // before using match to find transformer
+ const tag = match.replace(/^\s/, '')
+ const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[tag]
+ if (fullMatchRegExp == null) {
+ continue
+ }
+
+ const fullMatch = textContent.match(fullMatchRegExp)
+ const transformer = textTransformersIndex.transformersByTag[tag]
+ if (fullMatch != null && transformer != null) {
+ if (transformer.intraword !== false) {
+ return fullMatch
+ }
+
+ // For non-intraword transformers checking if it's within a word
+ // or surrounded with space/punctuation/newline
+ const { index = 0 } = fullMatch
+ const beforeChar = textContent[index - 1]
+ const afterChar = textContent[index + fullMatch[0].length]
+
+ if (
+ (!beforeChar || PUNCTUATION_OR_SPACE.test(beforeChar)) &&
+ (!afterChar || PUNCTUATION_OR_SPACE.test(afterChar))
+ ) {
+ return fullMatch
+ }
+ }
+ }
+
+ return null
+}
+
+function createTextFormatTransformersIndex(
+ textTransformers: Array,
+): TextFormatTransformersIndex {
+ const transformersByTag: Record = {}
+ const fullMatchRegExpByTag: Record = {}
+ const openTagsRegExp: string[] = []
+ const escapeRegExp = `(?,
+): boolean {
+ const grandParentNode = parentNode.getParent()
+
+ if (!$isRootOrShadowRoot(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
+ return false
+ }
+
+ const textContent = anchorNode.getTextContent()
+
+ // Checking for anchorOffset position to prevent any checks for cases when caret is too far
+ // from a line start to be a part of block-level markdown trigger.
+ //
+ // TODO:
+ // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
+ // since otherwise it won't be a markdown shortcut, but tables are exception
+ if (textContent[anchorOffset - 1] !== ' ') {
+ return false
+ }
+
+ for (const { regExp, replace } of elementTransformers) {
+ const match = textContent.match(regExp)
+
+ if (match && match[0].length === (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)) {
+ const nextSiblings = anchorNode.getNextSiblings()
+ const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset)
+ leadingNode.remove()
+ const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings
+ if (replace(parentNode, siblings, match, false) !== false) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+function runMultilineElementTransformers(
+ parentNode: ElementNode,
+ anchorNode: TextNode,
+ anchorOffset: number,
+ elementTransformers: ReadonlyArray,
+): boolean {
+ const grandParentNode = parentNode.getParent()
+
+ if (!$isRootOrShadowRoot(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
+ return false
+ }
+
+ const textContent = anchorNode.getTextContent()
+
+ // Checking for anchorOffset position to prevent any checks for cases when caret is too far
+ // from a line start to be a part of block-level markdown trigger.
+ //
+ // TODO:
+ // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
+ // since otherwise it won't be a markdown shortcut, but tables are exception
+ if (textContent[anchorOffset - 1] !== ' ') {
+ return false
+ }
+
+ for (const { regExpEnd, regExpStart, replace } of elementTransformers) {
+ if (
+ (regExpEnd && !('optional' in regExpEnd)) ||
+ (regExpEnd && 'optional' in regExpEnd && !regExpEnd.optional)
+ ) {
+ continue
+ }
+
+ const match = textContent.match(regExpStart)
+
+ if (match && match[0].length === (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1)) {
+ const nextSiblings = anchorNode.getNextSiblings()
+ const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset)
+ leadingNode.remove()
+ const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings
+
+ if (replace(parentNode, siblings, match, null, null, false) !== false) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+function runTextMatchTransformers(
+ anchorNode: TextNode,
+ anchorOffset: number,
+ transformersByTrigger: Readonly>>,
+): boolean {
+ let textContent = anchorNode.getTextContent()
+ const lastChar = textContent[anchorOffset - 1]
+ const transformers = transformersByTrigger[lastChar]
+
+ if (transformers == null) {
+ return false
+ }
+
+ // If typing in the middle of content, remove the tail to do
+ // reg exp match up to a string end (caret position)
+ if (anchorOffset < textContent.length) {
+ textContent = textContent.slice(0, anchorOffset)
+ }
+
+ for (const transformer of transformers) {
+ if (!transformer.replace || !transformer.regExp) {
+ continue
+ }
+ const match = textContent.match(transformer.regExp)
+
+ if (match === null) {
+ continue
+ }
+
+ const startIndex = match.index || 0
+ const endIndex = startIndex + match[0].length
+ let replaceNode
+
+ if (startIndex === 0) {
+ ;[replaceNode] = anchorNode.splitText(endIndex)
+ } else {
+ ;[, replaceNode] = anchorNode.splitText(startIndex, endIndex)
+ }
+
+ replaceNode.selectNext(0, 0)
+ transformer.replace(replaceNode, match)
+ return true
+ }
+
+ return false
+}
+
+function $runTextFormatTransformers(
+ anchorNode: TextNode,
+ anchorOffset: number,
+ textFormatTransformers: Readonly>>,
+): boolean {
+ const textContent = anchorNode.getTextContent()
+ const closeTagEndIndex = anchorOffset - 1
+ const closeChar = textContent[closeTagEndIndex]
+ // Quick check if we're possibly at the end of inline markdown style
+ const matchers = textFormatTransformers[closeChar]
+
+ if (!matchers) {
+ return false
+ }
+
+ for (const matcher of matchers) {
+ const { tag } = matcher
+ const tagLength = tag.length
+ const closeTagStartIndex = closeTagEndIndex - tagLength + 1
+
+ // If tag is not single char check if rest of it matches with text content
+ if (tagLength > 1) {
+ if (!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)) {
+ continue
+ }
+ }
+
+ // Space before closing tag cancels inline markdown
+ if (textContent[closeTagStartIndex - 1] === ' ') {
+ continue
+ }
+
+ // Some tags can not be used within words, hence should have newline/space/punctuation after it
+ const afterCloseTagChar = textContent[closeTagEndIndex + 1]
+
+ if (
+ matcher.intraword === false &&
+ afterCloseTagChar &&
+ !PUNCTUATION_OR_SPACE.test(afterCloseTagChar)
+ ) {
+ continue
+ }
+
+ const closeNode = anchorNode
+ let openNode = closeNode
+ let openTagStartIndex = getOpenTagStartIndex(textContent, closeTagStartIndex, tag)
+
+ // Go through text node siblings and search for opening tag
+ // if haven't found it within the same text node as closing tag
+ let sibling: null | TextNode = openNode
+
+ while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) {
+ if ($isLineBreakNode(sibling)) {
+ break
+ }
+
+ if ($isTextNode(sibling)) {
+ const siblingTextContent = sibling.getTextContent()
+ openNode = sibling
+ openTagStartIndex = getOpenTagStartIndex(siblingTextContent, siblingTextContent.length, tag)
+ }
+ }
+
+ // Opening tag is not found
+ if (openTagStartIndex < 0) {
+ continue
+ }
+
+ // No content between opening and closing tag
+ if (openNode === closeNode && openTagStartIndex + tagLength === closeTagStartIndex) {
+ continue
+ }
+
+ // Checking longer tags for repeating chars (e.g. *** vs **)
+ const prevOpenNodeText = openNode.getTextContent()
+
+ if (openTagStartIndex > 0 && prevOpenNodeText[openTagStartIndex - 1] === closeChar) {
+ continue
+ }
+
+ // Some tags can not be used within words, hence should have newline/space/punctuation before it
+ const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1]
+
+ if (
+ matcher.intraword === false &&
+ beforeOpenTagChar &&
+ !PUNCTUATION_OR_SPACE.test(beforeOpenTagChar)
+ ) {
+ continue
+ }
+
+ // Clean text from opening and closing tags (starting from closing tag
+ // to prevent any offset shifts if we start from opening one)
+ const prevCloseNodeText = closeNode.getTextContent()
+ const closeNodeText =
+ prevCloseNodeText.slice(0, closeTagStartIndex) + prevCloseNodeText.slice(closeTagEndIndex + 1)
+ closeNode.setTextContent(closeNodeText)
+ const openNodeText = openNode === closeNode ? closeNodeText : prevOpenNodeText
+ openNode.setTextContent(
+ openNodeText.slice(0, openTagStartIndex) + openNodeText.slice(openTagStartIndex + tagLength),
+ )
+ const selection = $getSelection()
+ const nextSelection = $createRangeSelection()
+ $setSelection(nextSelection)
+ // Adjust offset based on deleted chars
+ const newOffset = closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1
+ nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text')
+ nextSelection.focus.set(closeNode.__key, newOffset, 'text')
+
+ // Apply formatting to selected text
+ for (const format of matcher.format) {
+ if (!nextSelection.hasFormat(format)) {
+ nextSelection.formatText(format)
+ }
+ }
+
+ // Collapse selection up to the focus point
+ nextSelection.anchor.set(
+ nextSelection.focus.key,
+ nextSelection.focus.offset,
+ nextSelection.focus.type,
+ )
+
+ // Remove formatting from collapsed selection
+ for (const format of matcher.format) {
+ if (nextSelection.hasFormat(format)) {
+ nextSelection.toggleFormat(format)
+ }
+ }
+
+ if ($isRangeSelection(selection)) {
+ nextSelection.format = selection.format
+ }
+
+ return true
+ }
+
+ return false
+}
+
+function getOpenTagStartIndex(string: string, maxIndex: number, tag: string): number {
+ const tagLength = tag.length
+
+ for (let i = maxIndex; i >= tagLength; i--) {
+ const startIndex = i - tagLength
+
+ if (
+ isEqualSubString(string, startIndex, tag, 0, tagLength) && // Space after opening tag cancels transformation
+ string[startIndex + tagLength] !== ' '
+ ) {
+ return startIndex
+ }
+ }
+
+ return -1
+}
+
+function isEqualSubString(
+ stringA: string,
+ aStart: number,
+ stringB: string,
+ bStart: number,
+ length: number,
+): boolean {
+ for (let i = 0; i < length; i++) {
+ if (stringA[aStart + i] !== stringB[bStart + i]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+export function registerMarkdownShortcuts(
+ editor: LexicalEditor,
+ transformers: Array = TRANSFORMERS,
+): () => void {
+ const byType = transformersByType(transformers)
+ const textFormatTransformersByTrigger = indexBy(
+ byType.textFormat,
+ ({ tag }) => tag[tag.length - 1],
+ )
+ const textMatchTransformersByTrigger = indexBy(byType.textMatch, ({ trigger }) => trigger)
+
+ for (const transformer of transformers) {
+ const type = transformer.type
+ if (type === 'element' || type === 'text-match' || type === 'multiline-element') {
+ const dependencies = transformer.dependencies
+ for (const node of dependencies) {
+ if (!editor.hasNode(node)) {
+ throw new Error(
+ 'MarkdownShortcuts: missing dependency %s for transformer. Ensure node dependency is included in editor initial config.' +
+ node.getType(),
+ )
+ }
+ }
+ }
+ }
+
+ const $transform = (parentNode: ElementNode, anchorNode: TextNode, anchorOffset: number) => {
+ if (runElementTransformers(parentNode, anchorNode, anchorOffset, byType.element)) {
+ return
+ }
+
+ if (
+ runMultilineElementTransformers(parentNode, anchorNode, anchorOffset, byType.multilineElement)
+ ) {
+ return
+ }
+
+ if (runTextMatchTransformers(anchorNode, anchorOffset, textMatchTransformersByTrigger)) {
+ return
+ }
+
+ $runTextFormatTransformers(anchorNode, anchorOffset, textFormatTransformersByTrigger)
+ }
+
+ return editor.registerUpdateListener(({ dirtyLeaves, editorState, prevEditorState, tags }) => {
+ // Ignore updates from collaboration and undo/redo (as changes already calculated)
+ if (tags.has('collaboration') || tags.has('historic')) {
+ return
+ }
+
+ // If editor is still composing (i.e. backticks) we must wait before the user confirms the key
+ if (editor.isComposing()) {
+ return
+ }
+
+ const selection = editorState.read($getSelection)
+ const prevSelection = prevEditorState.read($getSelection)
+
+ // We expect selection to be a collapsed range and not match previous one (as we want
+ // to trigger transforms only as user types)
+ if (
+ !$isRangeSelection(prevSelection) ||
+ !$isRangeSelection(selection) ||
+ !selection.isCollapsed() ||
+ selection.is(prevSelection)
+ ) {
+ return
+ }
+
+ const anchorKey = selection.anchor.key
+ const anchorOffset = selection.anchor.offset
+
+ const anchorNode = editorState._nodeMap.get(anchorKey)
+
+ if (
+ !$isTextNode(anchorNode) ||
+ !dirtyLeaves.has(anchorKey) ||
+ (anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1)
+ ) {
+ return
+ }
+
+ editor.update(() => {
+ // Markdown is not available inside code
+ if (anchorNode.hasFormat('code')) {
+ return
+ }
+
+ const parentNode = anchorNode.getParent()
+
+ if (parentNode === null) {
+ return
+ }
+
+ $transform(parentNode, anchorNode, selection.anchor.offset)
+ })
+ })
+}
diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts
new file mode 100644
index 000000000..626b78194
--- /dev/null
+++ b/packages/richtext-lexical/src/packages/@lexical/markdown/MarkdownTransformers.ts
@@ -0,0 +1,484 @@
+/* eslint-disable regexp/no-unused-capturing-group */
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ListType } from '@lexical/list'
+import type { HeadingTagType } from '@lexical/rich-text'
+import type { ElementNode, Klass, LexicalNode, TextFormatType, TextNode } from 'lexical'
+
+import {
+ $createListItemNode,
+ $createListNode,
+ $isListItemNode,
+ $isListNode,
+ ListItemNode,
+ ListNode,
+} from '@lexical/list'
+import {
+ $createHeadingNode,
+ $createQuoteNode,
+ $isHeadingNode,
+ $isQuoteNode,
+ HeadingNode,
+ QuoteNode,
+} from '@lexical/rich-text'
+import { $createLineBreakNode } from 'lexical'
+
+export type Transformer =
+ | ElementTransformer
+ | MultilineElementTransformer
+ | TextFormatTransformer
+ | TextMatchTransformer
+
+export type ElementTransformer = {
+ dependencies: Array>
+ /**
+ * `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown.
+ *
+ * @return return null to cancel the export, even though the regex matched. Lexical will then search for the next transformer.
+ */
+ export: (
+ node: LexicalNode,
+
+ traverseChildren: (node: ElementNode) => string,
+ ) => null | string
+ regExp: RegExp
+ /**
+ * `replace` is called when markdown is imported or typed in the editor
+ *
+ * @return return false to cancel the transform, even though the regex matched. Lexical will then search for the next transformer.
+ */
+ replace: (
+ parentNode: ElementNode,
+ children: Array,
+ match: Array,
+ /**
+ * Whether the match is from an import operation (e.g. through `$convertFromMarkdownString`) or not (e.g. through typing in the editor).
+ */
+ isImport: boolean,
+ ) => boolean | void
+ type: 'element'
+}
+
+export type MultilineElementTransformer = {
+ dependencies: Array>
+ /**
+ * `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown.
+ *
+ * @return return null to cancel the export, even though the regex matched. Lexical will then search for the next transformer.
+ */
+ export?: (
+ node: LexicalNode,
+
+ traverseChildren: (node: ElementNode) => string,
+ ) => null | string
+ /**
+ * Use this function to manually handle the import process, once the `regExpStart` has matched successfully.
+ * Without providing this function, the default behavior is to match until `regExpEnd` is found, or until the end of the document if `regExpEnd.optional` is true.
+ *
+ * @returns a tuple or null. The first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed. If null is returned, the next multilineElementTransformer will be tried. If undefined is returned, the default behavior will be used.
+ */
+ handleImportAfterStartMatch?: (args: {
+ lines: Array
+ rootNode: ElementNode
+ startLineIndex: number
+ startMatch: RegExpMatchArray
+ transformer: MultilineElementTransformer
+ }) => [boolean, number] | null | undefined
+ /**
+ * This regex determines when to stop matching. Anything in between regExpStart and regExpEnd will be matched
+ */
+ regExpEnd?:
+ | {
+ /**
+ * Whether the end match is optional. If true, the end match is not required to match for the transformer to be triggered.
+ * The entire text from regexpStart to the end of the document will then be matched.
+ */
+ optional?: true
+ regExp: RegExp
+ }
+ | RegExp
+ /**
+ * This regex determines when to start matching
+ */
+ regExpStart: RegExp
+ /**
+ * `replace` is called only when markdown is imported in the editor, not when it's typed
+ *
+ * @return return false to cancel the transform, even though the regex matched. Lexical will then search for the next transformer.
+ */
+ replace: (
+ rootNode: ElementNode,
+ /**
+ * During markdown shortcut transforms, children nodes may be provided to the transformer. If this is the case, no `linesInBetween` will be provided and
+ * the children nodes should be used instead of the `linesInBetween` to create the new node.
+ */
+ children: Array | null,
+ startMatch: Array,
+ endMatch: Array | null,
+ /**
+ * linesInBetween includes the text between the start & end matches, split up by lines, not including the matches themselves.
+ * This is null when the transformer is triggered through markdown shortcuts (by typing in the editor)
+ */
+ linesInBetween: Array | null,
+ /**
+ * Whether the match is from an import operation (e.g. through `$convertFromMarkdownString`) or not (e.g. through typing in the editor).
+ */
+ isImport: boolean,
+ ) => boolean | void
+ type: 'multiline-element'
+}
+
+export type TextFormatTransformer = Readonly<{
+ format: ReadonlyArray
+ intraword?: boolean
+ tag: string
+ type: 'text-format'
+}>
+
+export type TextMatchTransformer = Readonly<{
+ dependencies: Array>
+ /**
+ * Determines how a node should be exported to markdown
+ */
+ export?: (
+ node: LexicalNode,
+
+ exportChildren: (node: ElementNode) => string,
+
+ exportFormat: (node: TextNode, textContent: string) => string,
+ ) => null | string
+ /**
+ * For import operations, this function can be used to determine the end index of the match, after `importRegExp` has matched.
+ * Without this function, the end index will be determined by the length of the match from `importRegExp`. Manually determining the end index can be useful if
+ * the match from `importRegExp` is not the entire text content of the node. That way, `importRegExp` can be used to match only the start of the node, and `getEndIndex`
+ * can be used to match the end of the node.
+ *
+ * @returns The end index of the match, or false if the match was unsuccessful and a different transformer should be tried.
+ */
+ getEndIndex?: (node: TextNode, match: RegExpMatchArray) => false | number
+ /**
+ * This regex determines what text is matched during markdown imports
+ */
+ importRegExp?: RegExp
+ /**
+ * This regex determines what text is matched for markdown shortcuts while typing in the editor
+ */
+ regExp: RegExp
+ /**
+ * Determines how the matched markdown text should be transformed into a node during the markdown import process
+ */
+ replace?: (node: TextNode, match: RegExpMatchArray) => void
+ /**
+ * Single character that allows the transformer to trigger when typed in the editor. This does not affect markdown imports outside of the markdown shortcut plugin.
+ * If the trigger is matched, the `regExp` will be used to match the text in the second step.
+ */
+ trigger?: string
+ type: 'text-match'
+}>
+
+const ORDERED_LIST_REGEX = /^(\s*)(\d+)\.\s/
+const UNORDERED_LIST_REGEX = /^(\s*)[-*+]\s/
+const CHECK_LIST_REGEX = /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i
+const HEADING_REGEX = /^(#{1,6})\s/
+const QUOTE_REGEX = /^>\s/
+const CODE_START_REGEX = /^[ \t]*```(\w+)?/
+const CODE_END_REGEX = /[ \t]*```$/
+const CODE_SINGLE_LINE_REGEX = /^[ \t]*```[^`]+(?:(?:`{1,2}|`{4,})[^`]+)*```(?:[^`]|$)/
+const TABLE_ROW_REG_EXP = /^\|(.+)\|\s?$/
+const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/
+
+const createBlockNode = (
+ createNode: (match: Array) => ElementNode,
+): ElementTransformer['replace'] => {
+ return (parentNode, children, match) => {
+ const node = createNode(match)
+ node.append(...children)
+ parentNode.replace(node)
+ node.select(0, 0)
+ }
+}
+
+// Amount of spaces that define indentation level
+// TODO: should be an option
+const LIST_INDENT_SIZE = 4
+
+function getIndent(whitespaces: string): number {
+ const tabs = whitespaces.match(/\t/g)
+ const spaces = whitespaces.match(/ /g)
+
+ let indent = 0
+
+ if (tabs) {
+ indent += tabs.length
+ }
+
+ if (spaces) {
+ indent += Math.floor(spaces.length / LIST_INDENT_SIZE)
+ }
+
+ return indent
+}
+
+const listReplace = (listType: ListType): ElementTransformer['replace'] => {
+ return (parentNode, children, match) => {
+ const previousNode = parentNode.getPreviousSibling()
+ const nextNode = parentNode.getNextSibling()
+ const listItem = $createListItemNode(listType === 'check' ? match[3] === 'x' : undefined)
+ if ($isListNode(nextNode) && nextNode.getListType() === listType) {
+ const firstChild = nextNode.getFirstChild()
+ if (firstChild !== null) {
+ firstChild.insertBefore(listItem)
+ } else {
+ // should never happen, but let's handle gracefully, just in case.
+ nextNode.append(listItem)
+ }
+ parentNode.remove()
+ } else if ($isListNode(previousNode) && previousNode.getListType() === listType) {
+ previousNode.append(listItem)
+ parentNode.remove()
+ } else {
+ const list = $createListNode(listType, listType === 'number' ? Number(match[2]) : undefined)
+ list.append(listItem)
+ parentNode.replace(list)
+ }
+ listItem.append(...children)
+ listItem.select(0, 0)
+ const indent = getIndent(match[1])
+ if (indent) {
+ listItem.setIndent(indent)
+ }
+ }
+}
+
+const listExport = (
+ listNode: ListNode,
+ exportChildren: (node: ElementNode) => string,
+ depth: number,
+): string => {
+ const output: string[] = []
+ const children = listNode.getChildren()
+ let index = 0
+ for (const listItemNode of children) {
+ if ($isListItemNode(listItemNode)) {
+ if (listItemNode.getChildrenSize() === 1) {
+ const firstChild = listItemNode.getFirstChild()
+ if ($isListNode(firstChild)) {
+ output.push(listExport(firstChild, exportChildren, depth + 1))
+ continue
+ }
+ }
+ const indent = ' '.repeat(depth * LIST_INDENT_SIZE)
+ const listType = listNode.getListType()
+ const prefix =
+ listType === 'number'
+ ? `${listNode.getStart() + index}. `
+ : listType === 'check'
+ ? `- [${listItemNode.getChecked() ? 'x' : ' '}] `
+ : '- '
+ output.push(indent + prefix + exportChildren(listItemNode))
+ index++
+ }
+ }
+
+ return output.join('\n')
+}
+
+export const HEADING: ElementTransformer = {
+ type: 'element',
+ dependencies: [HeadingNode],
+ export: (node, exportChildren) => {
+ if (!$isHeadingNode(node)) {
+ return null
+ }
+ const level = Number(node.getTag().slice(1))
+ return '#'.repeat(level) + ' ' + exportChildren(node)
+ },
+ regExp: HEADING_REGEX,
+ replace: createBlockNode((match) => {
+ const tag = ('h' + match[1].length) as HeadingTagType
+ return $createHeadingNode(tag)
+ }),
+}
+
+export const QUOTE: ElementTransformer = {
+ type: 'element',
+ dependencies: [QuoteNode],
+ export: (node, exportChildren) => {
+ if (!$isQuoteNode(node)) {
+ return null
+ }
+
+ const lines = exportChildren(node).split('\n')
+ const output: string[] = []
+ for (const line of lines) {
+ output.push('> ' + line)
+ }
+ return output.join('\n')
+ },
+ regExp: QUOTE_REGEX,
+ replace: (parentNode, children, _match, isImport) => {
+ if (isImport) {
+ const previousNode = parentNode.getPreviousSibling()
+ if ($isQuoteNode(previousNode)) {
+ previousNode.splice(previousNode.getChildrenSize(), 0, [
+ $createLineBreakNode(),
+ ...children,
+ ])
+ previousNode.select(0, 0)
+ parentNode.remove()
+ return
+ }
+ }
+
+ const node = $createQuoteNode()
+ node.append(...children)
+ parentNode.replace(node)
+ node.select(0, 0)
+ },
+}
+
+export const UNORDERED_LIST: ElementTransformer = {
+ type: 'element',
+ dependencies: [ListNode, ListItemNode],
+ export: (node, exportChildren) => {
+ return $isListNode(node) ? listExport(node, exportChildren, 0) : null
+ },
+ regExp: UNORDERED_LIST_REGEX,
+ replace: listReplace('bullet'),
+}
+
+export const CHECK_LIST: ElementTransformer = {
+ type: 'element',
+ dependencies: [ListNode, ListItemNode],
+ export: (node, exportChildren) => {
+ return $isListNode(node) ? listExport(node, exportChildren, 0) : null
+ },
+ regExp: CHECK_LIST_REGEX,
+ replace: listReplace('check'),
+}
+
+export const ORDERED_LIST: ElementTransformer = {
+ type: 'element',
+ dependencies: [ListNode, ListItemNode],
+ export: (node, exportChildren) => {
+ return $isListNode(node) ? listExport(node, exportChildren, 0) : null
+ },
+ regExp: ORDERED_LIST_REGEX,
+ replace: listReplace('number'),
+}
+
+export const INLINE_CODE: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['code'],
+ tag: '`',
+}
+
+export const HIGHLIGHT: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['highlight'],
+ tag: '==',
+}
+
+export const BOLD_ITALIC_STAR: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['bold', 'italic'],
+ tag: '***',
+}
+
+export const BOLD_ITALIC_UNDERSCORE: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['bold', 'italic'],
+ intraword: false,
+ tag: '___',
+}
+
+export const BOLD_STAR: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['bold'],
+ tag: '**',
+}
+
+export const BOLD_UNDERSCORE: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['bold'],
+ intraword: false,
+ tag: '__',
+}
+
+export const STRIKETHROUGH: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['strikethrough'],
+ tag: '~~',
+}
+
+export const ITALIC_STAR: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['italic'],
+ tag: '*',
+}
+
+export const ITALIC_UNDERSCORE: TextFormatTransformer = {
+ type: 'text-format',
+ format: ['italic'],
+ intraword: false,
+ tag: '_',
+}
+
+export function normalizeMarkdown(input: string, shouldMergeAdjacentLines = false): string {
+ const lines = input.split('\n')
+ let inCodeBlock = false
+ const sanitizedLines: string[] = []
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]
+ const lastLine = sanitizedLines[sanitizedLines.length - 1]
+
+ // Code blocks of ```single line``` don't toggle the inCodeBlock flag
+ if (CODE_SINGLE_LINE_REGEX.test(line)) {
+ sanitizedLines.push(line)
+ continue
+ }
+
+ // Detect the start or end of a code block
+ if (CODE_START_REGEX.test(line) || CODE_END_REGEX.test(line)) {
+ inCodeBlock = !inCodeBlock
+ sanitizedLines.push(line)
+ continue
+ }
+
+ // If we are inside a code block, keep the line unchanged
+ if (inCodeBlock) {
+ sanitizedLines.push(line)
+ continue
+ }
+
+ // In markdown the concept of "empty paragraphs" does not exist.
+ // Blocks must be separated by an empty line. Non-empty adjacent lines must be merged.
+ if (
+ line === '' ||
+ lastLine === '' ||
+ !lastLine ||
+ HEADING_REGEX.test(lastLine) ||
+ HEADING_REGEX.test(line) ||
+ QUOTE_REGEX.test(line) ||
+ ORDERED_LIST_REGEX.test(line) ||
+ UNORDERED_LIST_REGEX.test(line) ||
+ CHECK_LIST_REGEX.test(line) ||
+ TABLE_ROW_REG_EXP.test(line) ||
+ TABLE_ROW_DIVIDER_REG_EXP.test(line) ||
+ !shouldMergeAdjacentLines
+ ) {
+ sanitizedLines.push(line)
+ } else {
+ sanitizedLines[sanitizedLines.length - 1] = lastLine + line
+ }
+ }
+
+ return sanitizedLines.join('\n')
+}
diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/index.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/index.ts
new file mode 100644
index 000000000..5fb757825
--- /dev/null
+++ b/packages/richtext-lexical/src/packages/@lexical/markdown/index.ts
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ElementNode } from 'lexical'
+
+import type {
+ ElementTransformer,
+ MultilineElementTransformer,
+ TextFormatTransformer,
+ TextMatchTransformer,
+ Transformer,
+} from './MarkdownTransformers.js'
+
+import { createMarkdownExport } from './MarkdownExport.js'
+import { createMarkdownImport } from './MarkdownImport.js'
+import { registerMarkdownShortcuts } from './MarkdownShortcuts.js'
+import {
+ BOLD_ITALIC_STAR,
+ BOLD_ITALIC_UNDERSCORE,
+ BOLD_STAR,
+ BOLD_UNDERSCORE,
+ CHECK_LIST,
+ HEADING,
+ HIGHLIGHT,
+ INLINE_CODE,
+ ITALIC_STAR,
+ ITALIC_UNDERSCORE,
+ normalizeMarkdown,
+ ORDERED_LIST,
+ QUOTE,
+ STRIKETHROUGH,
+ UNORDERED_LIST,
+} from './MarkdownTransformers.js'
+
+const ELEMENT_TRANSFORMERS: Array = [
+ HEADING,
+ QUOTE,
+ UNORDERED_LIST,
+ ORDERED_LIST,
+]
+
+const MULTILINE_ELEMENT_TRANSFORMERS: Array = []
+
+// Order of text format transformers matters:
+//
+// - code should go first as it prevents any transformations inside
+// - then longer tags match (e.g. ** or __ should go before * or _)
+const TEXT_FORMAT_TRANSFORMERS: Array = [
+ INLINE_CODE,
+ BOLD_ITALIC_STAR,
+ BOLD_ITALIC_UNDERSCORE,
+ BOLD_STAR,
+ BOLD_UNDERSCORE,
+ HIGHLIGHT,
+ ITALIC_STAR,
+ ITALIC_UNDERSCORE,
+ STRIKETHROUGH,
+]
+
+const TEXT_MATCH_TRANSFORMERS: Array = []
+
+const TRANSFORMERS: Array = [
+ ...ELEMENT_TRANSFORMERS,
+ ...MULTILINE_ELEMENT_TRANSFORMERS,
+ ...TEXT_FORMAT_TRANSFORMERS,
+ ...TEXT_MATCH_TRANSFORMERS,
+]
+
+/**
+ * Renders markdown from a string. The selection is moved to the start after the operation.
+ *
+ * @param {boolean} [shouldPreserveNewLines] By setting this to true, new lines will be preserved between conversions
+ * @param {boolean} [shouldMergeAdjacentLines] By setting this to true, adjacent non empty lines will be merged according to commonmark spec: https://spec.commonmark.org/0.24/#example-177. Not applicable if shouldPreserveNewLines = true.
+ */
+function $convertFromMarkdownString(
+ markdown: string,
+ transformers: Array = TRANSFORMERS,
+ node?: ElementNode,
+ shouldPreserveNewLines = false,
+ shouldMergeAdjacentLines = false,
+): void {
+ const sanitizedMarkdown = shouldPreserveNewLines
+ ? markdown
+ : normalizeMarkdown(markdown, shouldMergeAdjacentLines)
+ const importMarkdown = createMarkdownImport(transformers, shouldPreserveNewLines)
+ return importMarkdown(sanitizedMarkdown, node)
+}
+
+/**
+ * Renders string from markdown. The selection is moved to the start after the operation.
+ */
+function $convertToMarkdownString(
+ transformers: Array = TRANSFORMERS,
+ node?: ElementNode,
+ shouldPreserveNewLines: boolean = false,
+): string {
+ const exportMarkdown = createMarkdownExport(transformers, shouldPreserveNewLines)
+ return exportMarkdown(node)
+}
+
+export {
+ $convertFromMarkdownString,
+ $convertToMarkdownString,
+ BOLD_ITALIC_STAR,
+ BOLD_ITALIC_UNDERSCORE,
+ BOLD_STAR,
+ BOLD_UNDERSCORE,
+ CHECK_LIST,
+ ELEMENT_TRANSFORMERS,
+ type ElementTransformer,
+ HEADING,
+ HIGHLIGHT,
+ INLINE_CODE,
+ ITALIC_STAR,
+ ITALIC_UNDERSCORE,
+ MULTILINE_ELEMENT_TRANSFORMERS,
+ type MultilineElementTransformer,
+ ORDERED_LIST,
+ QUOTE,
+ registerMarkdownShortcuts,
+ STRIKETHROUGH,
+ TEXT_FORMAT_TRANSFORMERS,
+ TEXT_MATCH_TRANSFORMERS,
+ type TextFormatTransformer,
+ type TextMatchTransformer,
+ type Transformer,
+ TRANSFORMERS,
+ UNORDERED_LIST,
+}
diff --git a/packages/richtext-lexical/src/packages/@lexical/markdown/utils.ts b/packages/richtext-lexical/src/packages/@lexical/markdown/utils.ts
new file mode 100644
index 000000000..e782e84f6
--- /dev/null
+++ b/packages/richtext-lexical/src/packages/@lexical/markdown/utils.ts
@@ -0,0 +1,430 @@
+/* eslint-disable regexp/no-obscure-range */
+/* eslint-disable regexp/no-empty-group */
+/* eslint-disable regexp/no-empty-capturing-group */
+/* eslint-disable regexp/optimal-quantifier-concatenation */
+/* eslint-disable regexp/no-misleading-capturing-group */
+/* eslint-disable regexp/no-contradiction-with-assertion */
+/* eslint-disable regexp/no-super-linear-backtracking */
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { ListNode } from '@lexical/list'
+
+import { $isListItemNode, $isListNode } from '@lexical/list'
+import { $isHeadingNode, $isQuoteNode } from '@lexical/rich-text'
+import {
+ $isParagraphNode,
+ $isTextNode,
+ type ElementNode,
+ type LexicalNode,
+ type TextFormatType,
+} from 'lexical'
+
+import type {
+ ElementTransformer,
+ MultilineElementTransformer,
+ TextFormatTransformer,
+ TextMatchTransformer,
+ Transformer,
+} from './MarkdownTransformers.js'
+
+type MarkdownFormatKind =
+ | 'bold'
+ | 'code'
+ | 'horizontalRule'
+ | 'italic'
+ | 'italic_bold'
+ | 'link'
+ | 'noTransformation'
+ | 'paragraphBlockQuote'
+ | 'paragraphCodeBlock'
+ | 'paragraphH1'
+ | 'paragraphH2'
+ | 'paragraphH3'
+ | 'paragraphH4'
+ | 'paragraphH5'
+ | 'paragraphH6'
+ | 'paragraphOrderedList'
+ | 'paragraphUnorderedList'
+ | 'strikethrough'
+ | 'strikethrough_bold'
+ | 'strikethrough_italic'
+ | 'strikethrough_italic_bold'
+ | 'underline'
+
+type MarkdownCriteria = Readonly<{
+ export?: (
+ node: LexicalNode,
+ traverseChildren: (elementNode: ElementNode) => string,
+ ) => null | string
+ exportFormat?: TextFormatType
+ exportTag?: string
+ exportTagClose?: string
+ markdownFormatKind: MarkdownFormatKind | null | undefined
+ regEx: RegExp
+ regExForAutoFormatting: RegExp
+ requiresParagraphStart: boolean | null | undefined
+}>
+
+type MarkdownCriteriaArray = Array
+
+const autoFormatBase: MarkdownCriteria = {
+ markdownFormatKind: null,
+ regEx: /(?:)/,
+ regExForAutoFormatting: /(?:)/,
+ requiresParagraphStart: false,
+}
+
+const paragraphStartBase: MarkdownCriteria = {
+ ...autoFormatBase,
+ requiresParagraphStart: true,
+}
+
+const markdownHeader1: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: createHeadingExport(1),
+ markdownFormatKind: 'paragraphH1',
+ regEx: /^# /,
+ regExForAutoFormatting: /^# /,
+}
+
+const markdownHeader2: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: createHeadingExport(2),
+ markdownFormatKind: 'paragraphH2',
+ regEx: /^## /,
+ regExForAutoFormatting: /^## /,
+}
+
+const markdownHeader3: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: createHeadingExport(3),
+ markdownFormatKind: 'paragraphH3',
+ regEx: /^### /,
+ regExForAutoFormatting: /^### /,
+}
+
+const markdownHeader4: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: createHeadingExport(4),
+ markdownFormatKind: 'paragraphH4',
+ regEx: /^#### /,
+ regExForAutoFormatting: /^#### /,
+}
+
+const markdownHeader5: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: createHeadingExport(5),
+ markdownFormatKind: 'paragraphH5',
+ regEx: /^##### /,
+ regExForAutoFormatting: /^##### /,
+}
+
+const markdownHeader6: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: createHeadingExport(6),
+ markdownFormatKind: 'paragraphH6',
+ regEx: /^###### /,
+ regExForAutoFormatting: /^###### /,
+}
+
+const markdownBlockQuote: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: blockQuoteExport,
+ markdownFormatKind: 'paragraphBlockQuote',
+ regEx: /^> /,
+ regExForAutoFormatting: /^> /,
+}
+
+const markdownUnorderedListDash: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: listExport,
+ markdownFormatKind: 'paragraphUnorderedList',
+ regEx: /^(\s{0,10})- /,
+ regExForAutoFormatting: /^(\s{0,10})- /,
+}
+
+const markdownUnorderedListAsterisk: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: listExport,
+ markdownFormatKind: 'paragraphUnorderedList',
+ regEx: /^(\s{0,10})\* /,
+ regExForAutoFormatting: /^(\s{0,10})\* /,
+}
+
+const markdownOrderedList: MarkdownCriteria = {
+ ...paragraphStartBase,
+ export: listExport,
+ markdownFormatKind: 'paragraphOrderedList',
+ regEx: /^(\s{0,10})(\d+)\.\s/,
+ regExForAutoFormatting: /^(\s{0,10})(\d+)\.\s/,
+}
+
+const markdownHorizontalRule: MarkdownCriteria = {
+ ...paragraphStartBase,
+ markdownFormatKind: 'horizontalRule',
+ regEx: /^\*\*\*$/,
+ regExForAutoFormatting: /^\*\*\* /,
+}
+
+const markdownHorizontalRuleUsingDashes: MarkdownCriteria = {
+ ...paragraphStartBase,
+ markdownFormatKind: 'horizontalRule',
+ regEx: /^---$/,
+ regExForAutoFormatting: /^--- /,
+}
+
+const markdownInlineCode: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'code',
+ exportTag: '`',
+ markdownFormatKind: 'code',
+ regEx: /(`)(\s*)([^`]*)(\s*)(`)()/,
+ regExForAutoFormatting: /(`)(\s*\b)([^`]*)(\b\s*)(`)(\s)$/,
+}
+
+const markdownBold: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'bold',
+ exportTag: '**',
+ markdownFormatKind: 'bold',
+ regEx: /(\*\*)(\s*)([^*]*)(\s*)(\*\*)()/,
+ regExForAutoFormatting: /(\*\*)(\s*\b)([^*]*)(\b\s*)(\*\*)(\s)$/,
+}
+
+const markdownItalic: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'italic',
+ exportTag: '*',
+ markdownFormatKind: 'italic',
+ regEx: /(\*)(\s*)([^*]*)(\s*)(\*)()/,
+ regExForAutoFormatting: /(\*)(\s*\b)([^*]*)(\b\s*)(\*)(\s)$/,
+}
+
+const markdownBold2: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'bold',
+ exportTag: '_',
+ markdownFormatKind: 'bold',
+ regEx: /(__)(\s*)([^_]*)(\s*)(__)()/,
+ regExForAutoFormatting: /(__)(\s*)([^_]*)(\s*)(__)(\s)$/,
+}
+
+const markdownItalic2: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'italic',
+ exportTag: '_',
+ markdownFormatKind: 'italic',
+ regEx: /(_)()([^_]*)()(_)()/,
+ regExForAutoFormatting: /(_)()([^_]*)()(_)(\s)$/, // Maintain 7 groups.
+}
+
+const fakeMarkdownUnderline: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'underline',
+ exportTag: '',
+ exportTagClose: '',
+ markdownFormatKind: 'underline',
+ regEx: /()(\s*)([^<]*)(\s*)(<\/u>)()/,
+ regExForAutoFormatting: /()(\s*\b)([^<]*)(\b\s*)(<\/u>)(\s)$/,
+}
+
+const markdownStrikethrough: MarkdownCriteria = {
+ ...autoFormatBase,
+ exportFormat: 'strikethrough',
+ exportTag: '~~',
+ markdownFormatKind: 'strikethrough',
+ regEx: /(~~)(\s*)([^~]*)(\s*)(~~)()/,
+ regExForAutoFormatting: /(~~)(\s*\b)([^~]*)(\b\s*)(~~)(\s)$/,
+}
+
+const markdownStrikethroughItalicBold: MarkdownCriteria = {
+ ...autoFormatBase,
+ markdownFormatKind: 'strikethrough_italic_bold',
+ regEx: /(~~_\*\*)(\s*\b)([^*_~]+)(\b\s*)(\*\*_~~)()/,
+ regExForAutoFormatting: /(~~_\*\*)(\s*\b)([^*_~]+)(\b\s*)(\*\*_~~)(\s)$/,
+}
+
+const markdownItalicbold: MarkdownCriteria = {
+ ...autoFormatBase,
+ markdownFormatKind: 'italic_bold',
+ regEx: /(_\*\*)(\s*\b)([^*_]+)(\b\s*)(\*\*_)/,
+ regExForAutoFormatting: /(_\*\*)(\s*\b)([^*_]+)(\b\s*)(\*\*_)(\s)$/,
+}
+
+const markdownStrikethroughItalic: MarkdownCriteria = {
+ ...autoFormatBase,
+ markdownFormatKind: 'strikethrough_italic',
+ regEx: /(~~_)(\s*)([^_~]+)(\s*)(_~~)/,
+ regExForAutoFormatting: /(~~_)(\s*)([^_~]+)(\s*)(_~~)(\s)$/,
+}
+
+const markdownStrikethroughBold: MarkdownCriteria = {
+ ...autoFormatBase,
+ markdownFormatKind: 'strikethrough_bold',
+ regEx: /(~~\*\*)(\s*\b)([^*~]+)(\b\s*)(\*\*~~)/,
+ regExForAutoFormatting: /(~~\*\*)(\s*\b)([^*~]+)(\b\s*)(\*\*~~)(\s)$/,
+}
+
+const markdownLink: MarkdownCriteria = {
+ ...autoFormatBase,
+ markdownFormatKind: 'link',
+ regEx: /(\[)([^\]]*)(\]\()([^)]*)(\)*)()/,
+ regExForAutoFormatting: /(\[)([^\]]*)(\]\()([^)]*)(\)*)(\s)$/,
+}
+
+const allMarkdownCriteriaForTextNodes: MarkdownCriteriaArray = [
+ // Place the combination formats ahead of the individual formats.
+ // Combos
+ markdownStrikethroughItalicBold,
+ markdownItalicbold,
+ markdownStrikethroughItalic,
+ markdownStrikethroughBold, // Individuals
+ markdownInlineCode,
+ markdownBold,
+ markdownItalic, // Must appear after markdownBold
+ markdownBold2,
+ markdownItalic2, // Must appear after markdownBold2.
+ fakeMarkdownUnderline,
+ markdownStrikethrough,
+ markdownLink,
+]
+
+const allMarkdownCriteriaForParagraphs: MarkdownCriteriaArray = [
+ markdownHeader1,
+ markdownHeader2,
+ markdownHeader3,
+ markdownHeader4,
+ markdownHeader5,
+ markdownHeader6,
+ markdownBlockQuote,
+ markdownUnorderedListDash,
+ markdownUnorderedListAsterisk,
+ markdownOrderedList,
+ markdownHorizontalRule,
+ markdownHorizontalRuleUsingDashes,
+]
+
+export function getAllMarkdownCriteriaForParagraphs(): MarkdownCriteriaArray {
+ return allMarkdownCriteriaForParagraphs
+}
+
+export function getAllMarkdownCriteriaForTextNodes(): MarkdownCriteriaArray {
+ return allMarkdownCriteriaForTextNodes
+}
+
+type Block = (
+ node: LexicalNode,
+ exportChildren: (elementNode: ElementNode) => string,
+) => null | string
+
+function createHeadingExport(level: number): Block {
+ return (node, exportChildren) => {
+ return $isHeadingNode(node) && node.getTag() === 'h' + level
+ ? '#'.repeat(level) + ' ' + exportChildren(node)
+ : null
+ }
+}
+
+function listExport(node: LexicalNode, exportChildren: (_node: ElementNode) => string) {
+ return $isListNode(node) ? processNestedLists(node, exportChildren, 0) : null
+}
+
+// TODO: should be param
+const LIST_INDENT_SIZE = 4
+
+function processNestedLists(
+ listNode: ListNode,
+ exportChildren: (node: ElementNode) => string,
+ depth: number,
+): string {
+ const output: string[] = []
+ const children = listNode.getChildren()
+ let index = 0
+
+ for (const listItemNode of children) {
+ if ($isListItemNode(listItemNode)) {
+ if (listItemNode.getChildrenSize() === 1) {
+ const firstChild = listItemNode.getFirstChild()
+
+ if ($isListNode(firstChild)) {
+ output.push(processNestedLists(firstChild, exportChildren, depth + 1))
+ continue
+ }
+ }
+
+ const indent = ' '.repeat(depth * LIST_INDENT_SIZE)
+ const prefix = listNode.getListType() === 'bullet' ? '- ' : `${listNode.getStart() + index}. `
+ output.push(indent + prefix + exportChildren(listItemNode))
+ index++
+ }
+ }
+
+ return output.join('\n')
+}
+
+function blockQuoteExport(node: LexicalNode, exportChildren: (_node: ElementNode) => string) {
+ return $isQuoteNode(node) ? '> ' + exportChildren(node) : null
+}
+
+export function indexBy(
+ list: Array,
+ callback: (arg0: T) => string | undefined,
+): Readonly>> {
+ const index: Record> = {}
+
+ for (const item of list) {
+ const key = callback(item)
+
+ if (!key) {
+ continue
+ }
+
+ if (index[key]) {
+ index[key].push(item)
+ } else {
+ index[key] = [item]
+ }
+ }
+
+ return index
+}
+
+export function transformersByType(transformers: Array): Readonly<{
+ element: Array
+ multilineElement: Array
+ textFormat: Array
+ textMatch: Array
+}> {
+ const byType = indexBy(transformers, (t) => t.type)
+
+ return {
+ element: (byType.element || []) as Array,
+ multilineElement: (byType['multiline-element'] || []) as Array,
+ textFormat: (byType['text-format'] || []) as Array,
+ textMatch: (byType['text-match'] || []) as Array,
+ }
+}
+
+export const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/
+
+const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/
+
+export function isEmptyParagraph(node: LexicalNode): boolean {
+ if (!$isParagraphNode(node)) {
+ return false
+ }
+
+ const firstChild = node.getFirstChild()
+ return (
+ firstChild == null ||
+ (node.getChildrenSize() === 1 &&
+ $isTextNode(firstChild) &&
+ MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent()))
+ )
+}
diff --git a/packages/richtext-lexical/src/utilities/jsx/lexicalMarkdownCopy.ts b/packages/richtext-lexical/src/utilities/jsx/lexicalMarkdownCopy.ts
index e4d0e585e..682abcb45 100644
--- a/packages/richtext-lexical/src/utilities/jsx/lexicalMarkdownCopy.ts
+++ b/packages/richtext-lexical/src/utilities/jsx/lexicalMarkdownCopy.ts
@@ -1,15 +1,16 @@
/* eslint-disable regexp/no-unused-capturing-group */
+import type { ElementNode } from 'lexical'
+
import type {
MultilineElementTransformer as _MultilineElementTransformer,
Transformer,
-} from '@lexical/markdown'
-import type { ElementNode } from 'lexical'
+} from '../../packages/@lexical/markdown/index.js'
import {
$convertFromMarkdownString as $originalConvertFromMarkdownString,
TRANSFORMERS,
-} from '@lexical/markdown'
+} from '../../packages/@lexical/markdown/index.js'
const EMPTY_OR_WHITESPACE_ONLY = /^[\t ]*$/
const ORDERED_LIST_REGEX = /^(\s*)(\d+)\.\s/
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 966497ef5..066bed116 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1198,9 +1198,6 @@ importers:
'@lexical/mark':
specifier: 0.20.0
version: 0.20.0
- '@lexical/markdown':
- specifier: 0.20.0
- version: 0.20.0
'@lexical/react':
specifier: 0.20.0
version: 0.20.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(yjs@13.6.20)