Skip to main content

Markdown with @lexical/mdast

Experimental

@lexical/mdast and everything described on this page are marked @experimental and may change between any two Lexical releases — including breaking renames, signature changes, or behavior changes — until the API stabilizes. We track issues and proposals in the GitHub repo; breaking changes will be called out in release notes.

The existing @lexical/markdown package is unchanged and remains the supported default for production apps that don't want to track an experimental API.

@lexical/mdast is an alternative to @lexical/markdown built on the micromark / mdast ecosystem. Where @lexical/markdown ships its own regular-expression based parser, @lexical/mdast delegates parsing and serialization to the same spec-compliant parser used by remark, and recognizes Markdown typing shortcuts by feeding keystrokes back through that same grammar — there is no second set of regular expressions to keep in sync with import.

The trade-off is bundle weight: the micromark/mdast stack costs roughly 26 kB (min+gzip) more than the bespoke @lexical/markdown implementation in an equivalent configuration, in exchange for CommonMark + GFM compliance, one grammar shared by import and typing shortcuts, and the micromark/mdast extension ecosystem (footnotes, frontmatter, directives, ...) as the path for new syntax.

Extensions are the unit of configuration

Modeled on @lexical/html's DOMImportExtension, the package is configured exclusively through the extension system. Each feature extension ships the nodes it needs and contributes its import/export rules (and the micromark grammar that tokenizes them) to the core MdastImportExtension registry. There is no transformer list to curate and no registerMarkdownShortcuts call:

import {
$convertFromMarkdownString,
$convertToMarkdownString,
MdastCommonMarkExtension,
MdastExtension,
MdastGfmExtension,
MdastShortcutsExtension,
} from '@lexical/mdast';
import {buildEditorFromExtensions} from '@lexical/extension';
import {defineExtension} from 'lexical';

const editor = buildEditorFromExtensions(
defineExtension({
dependencies: [
// CommonMark: headings, quotes, lists, code, links, thematic breaks
MdastCommonMarkExtension,
// GFM: strikethrough, task lists, literal autolinks, tables
MdastGfmExtension,
// Import + export (MdastExtension bundles both directions)
MdastExtension,
// Streaming typing shortcuts, driven by the registry's grammar
MdastShortcutsExtension,
],
name: '[root]',
}),
);

editor.update(() => $convertFromMarkdownString('# Hello *world*'));
const markdown = editor.read(() => $convertToMarkdownString());

Try it live in the mdast-editor dev example, a WYSIWYG Markdown editor with an editable Markdown source pane — typing on either side exercises import or export through this package.

The bundles unpack into granular, one-construct-each extensions (MdastHeadingExtension, MdastBlockquoteExtension, MdastTaskListExtension, ...) for editors that only want some of the grammar — see the full list or the package README. Granular configurations degrade gracefully: unsupported constructs import as their content (a table becomes its cell text), and typing shortcuts only fire for constructs the editor can represent (> stays literal without MdastBlockquoteExtension).

Import and export are split

MdastImportExtension owns the compiled registry and parsing; MdastExportExtension compiles the same registry into a serializer ($convertToMarkdownString). An editor that never converts back to Markdown simply omits MdastExportExtension and doesn't bundle mdast-util-to-markdown. MdastExtension is a convenience bundle of both directions.

Round-trips are minimally different

The literal syntax of each construct is preserved on the Lexical nodes with NodeState: list bullet -/*/+, ordered delimiter ./), code fence style and info-string meta, setext vs ATX headings, hard-break style, soft line breaks, thematic break marker, table column alignment, _ vs * emphasis, and link style ([text](url) vs <autolink> vs bare GFM literal). Normalization is never forced; nodes created in the editor defer to the document-level serialization options, which can be configured by contributing mdast-util-to-markdown options through toMarkdownExtensions.

unified / remark interop

The mdast tree itself is part of the API, so editor content can flow through the wider unified ecosystem — remark plugins, remark-rehype for HTML rendering, tree diffing:

import {$convertFromMdast, $convertToMdast} from '@lexical/mdast';

// Editor -> mdast tree (before serialization).
const tree = editor.read(() => $convertToMdast());
// ... run remark plugins / transform the tree ...
// mdast tree -> editor.
editor.update(() => $convertFromMdast(tree));

To parse Markdown into nodes without replacing the document (e.g. to insert at the current selection), $generateNodesFromMarkdownString returns a detached array of block-level nodes and leaves the document and selection untouched.

Custom syntax

New syntax is added the same way the built-in features are built: an extension contributes import/export rules and the micromark/mdast grammar for its construct, and ships the nodes those rules need. See the package README for a template.

Comparison with @lexical/markdown

@lexical/markdown@lexical/mdast
Parserbespoke regular expressionsmicromark (CommonMark + GFM spec-compliant)
Configurationtransformer arraysextensions (nodes ship with their rules)
Typing shortcutsseparate matcherssame grammar as document import
Syntax preservationpartialextensive, via NodeState
mdast tree access$convertToMdast / $convertFromMdast
New syntaxcustom transformermicromark/mdast extension ecosystem
Bundle costbaseline~+26 kB min+gzip
Stabilitystableexperimental