packages/lexical-website/docs/serialization/markdown-mdast.md
:::warning 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.
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).
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.
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.
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.
Markdown passes raw HTML through — GitHub-style <details> blocks,
inline <kbd> runs — and by default it imports as literal text. The
opt-in MdastHtmlExtension routes it through the editor's
DOMImportExtension rules instead,
so any HTML the editor can already import works from Markdown, and
Markdown inside the construct keeps working in both directions, the
way it does on GitHub:
<details><summary>
The *summary* line
</summary>
The **body** blocks
</details>
On export the same idea runs in reverse: registering the generic
$exportViaDOM handler for a node type makes that node's own
exportDOM the single source of truth for its Markdown encoding —
the rendered shell has its children channel and named slots
substituted with embedded Markdown, and custom-element tags are
placed on their own lines where CommonMark requires it to re-parse.
rawHtmlBlock(...parts) builds the same kind of output from a
hand-written template. Context states parallel the HTML pipeline's:
RenderContextMarkdownExport lets exportDOM diverge between the
Markdown encoding and the HTML clipboard, and ImportContextMarkdown
lets a DOM rule distinguish Markdown import from HTML paste.
A complete HTML-encoded construct is one DOM import rule (which then
also serves HTML paste) plus one $exportViaDOM export rule — see
the package README for a template and
the mdast-editor dev example for
complete constructs on the block path (<details><summary> with a
named slot) and the inline path (<kbd>).
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.
The Markdown pipeline runs under the same context-record mechanism as
the @lexical/html import/render pipelines, so rules use the same
techniques in either registry:
ImportContextMarkdown set. An
mdast import handler reads ambient state with
$getImportContextValue(...) and layers state for its subtree with
$withImportContext(pairs)(() => ctx.importChildren(node)) — the
layer is visible to nested handlers and to any DOM-rule session the
subtree opens for raw HTML (DOM import sessions chain to the ambient
import context), and to nothing outside it. createImportState
defines app states exactly as it does for DOM rules.RenderContextMarkdownExport marks exportDOM shells
rendered for Markdown ($exportViaDOM), and selection exports
($convertSelectionToMarkdownString) run under
RenderContextMarkdownSelection carrying the selection — a
contributed to-markdown handler that appends end-of-document data
(like the dev example's footnote definitions) reads it with
$getRenderContextValue to scope its output to a clipboard copy.@lexical/markdown | @lexical/mdast | |
|---|---|---|
| Parser | bespoke regular expressions | micromark (CommonMark + GFM spec-compliant) |
| Configuration | transformer arrays | extensions (nodes ship with their rules) |
| Typing shortcuts | separate matchers | same grammar as document import |
| Syntax preservation | partial | extensive, via NodeState |
| mdast tree access | – | $convertToMdast / $convertFromMdast |
| New syntax | custom transformer | micromark/mdast extension ecosystem |
| Bundle cost | baseline | ~+26 kB min+gzip |
| Stability | stable | experimental |