Back to Lexical

Markdown with @lexical/mdast

packages/lexical-website/docs/serialization/markdown-mdast.md

0.48.09.0 KB
Original Source

Markdown with @lexical/mdast

:::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.

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:

ts
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:

ts
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.

Raw HTML

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:

md
<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>).

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.

Context states

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:

  • The whole import walk runs with 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.
  • On export, 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.

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