Skip to content

Markdown

The @domternal/extension-markdown package adds bidirectional Markdown to the editor, covering the full Notion-style schema:

  • Import: parse GitHub-flavored Markdown into the document through the insertMarkdown and setMarkdownContent commands, or automatically when users paste Markdown-looking plain text.
  • Export: serialize any document back to Markdown with getMarkdown or the downloadMarkdown helper, with a warning channel for anything Markdown cannot express.
  • Headless: parseMarkdown and serializeMarkdown work against any schema without an editor instance, so the same conversion runs in Node scripts, tests, or server code.

Round trips of the supported subset are exact: serialize(parse(markdown)) reproduces the input, and parse(serialize(doc)) rebuilds an equal document.

Use Markdown when you need:

  • Users pasting Markdown from ChatGPT, GitHub, Obsidian, or any plain-text source and expecting rich content
  • A .md export path for documents (static site content, GitHub files, LLM prompts)
  • Programmatic content ingestion from Markdown sources (setMarkdownContent for whole documents, insertMarkdown at the selection)

Notes:

  • Typing shortcuts like # and **bold** are separate: they come from each extension’s input rules and work without this package
  • The parser and serializer adapt to your schema: Markdown features without a matching node or mark degrade to readable plain text instead of failing

Edit the document and click Export editor to Markdown, or change the Markdown source and load it back in. Pasting Markdown as plain text into the editor converts it too. To see a fidelity warning, underline some text (Cmd+U / Ctrl+U) and export: Markdown has no underline, so the text is kept and the loss is reported.

Click to try it out
Terminal window
pnpm add @domternal/extension-markdown
import { Editor, StarterKit } from '@domternal/core';
import { Markdown, getMarkdown, downloadMarkdown } from '@domternal/extension-markdown';
import '@domternal/theme';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [StarterKit, Markdown],
});
// Import
editor.commands.insertMarkdown('## Hello\n\n- [x] done\n- [ ] open');
editor.commands.setMarkdownContent('# Fresh document');
// Export
const { markdown, warnings } = getMarkdown(editor);
downloadMarkdown(editor, 'notes.md');
Markdown.configure({
paste: true, // convert Markdown-looking plain-text pastes
tightLists: true, // serialize lists without blank lines between items
specs: null, // extra or replacement Markdown mappings
})
OptionTypeDefaultDescription
pastebooleantrueConvert Markdown-looking plain-text pastes into rich content
tightListsbooleantrueSerialize lists without blank lines between items
specsMarkdownSerializerSpecOverrides | nullnullExtra or replacement serializer mappings for custom nodes and marks, merged over the built-ins

With paste: true (the default), a paste converts to rich content only when ALL of these hold:

  1. The clipboard has NO text/html flavor. Rich sources (Google Docs, web pages, VS Code with syntax-highlighted copies) paste through ProseMirror’s own HTML handling, which preserves more fidelity than a Markdown reparse.
  2. The plain text looks like Markdown: at least one block marker (# , > , - , 1. , code fence, $$, checkbox, pipe table row, thematic break) or inline syntax (**bold**, `code`, ~~strike~~, links, images).
  3. The selection is not inside a code block. Pastes into code blocks stay literal.

Everything else passes through untouched: plain prose stays plain, and a bare URL is left for the Link extension’s paste handling, so URL pastes still become links.

A single-paragraph Markdown paste (for example has **bold** inline) inserts as inline content and merges into the text at the cursor. Multi-block Markdown replaces the selection as blocks. One paste is one history step: undo restores the exact pre-paste document.

editor.commands.insertMarkdown(markdown: string): boolean;
editor.commands.setMarkdownContent(markdown: string, options?: SetContentOptions): boolean;
  • insertMarkdown parses the Markdown and inserts it at the selection. A single paragraph merges inline at the cursor (matching paste); anything larger inserts as blocks.
  • setMarkdownContent replaces the whole document. The options pass through to setContent (emitUpdate, parseOptions).
import { getMarkdown, downloadMarkdown } from '@domternal/extension-markdown';
const { markdown, warnings } = getMarkdown(editor);
const result = downloadMarkdown(editor, 'document.md'); // triggers a .md download

Both return a SerializeMarkdownResult:

interface SerializeMarkdownResult {
markdown: string;
warnings: MarkdownWarning[];
}
interface MarkdownWarning {
code: 'unsupported-node' | 'unsupported-mark' | 'lossy-attribute' | 'lossy-structure';
message: string;
nodeType?: string;
}
CodeMeaningExamples
unsupported-nodeA node type has no Markdown mapping; content flattened or omittedcustom nodes, table of contents block
unsupported-markA mark type has no Markdown mapping; formatting dropped, text keptunderline, superscript, subscript, text color
lossy-attributeAn attribute Markdown cannot carry was droppedtext alignment, line height, image resize dimensions, cell backgrounds
lossy-structureStructure was flattened to fit Markdowntoggle blocks, merged table cells, multi-block table cells, mentions

Warnings are deduplicated: each distinct loss is reported once per export.

Editor contentMarkdown
Headings 1 to 6# through ######
Bullet, ordered, task lists- , 1. (with start), - [x] / - [ ]
Blockquote>
Code block with languagefenced ```lang (fence grows when the code contains backticks)
Table with header row and column alignmentGFM pipe table with :---: separators
Image with alt and title![alt](src "title")
Link, autolink[text](href "title"), <https://...>
Bold, italic, strike, inline code**, *, ~~, `
Hard breakbackslash line break
Horizontal rule---
Math (with @domternal/extension-math)$...$ inline, $$ blocks
Emojithe emoji glyph

The parser side mirrors the same table, plus: soft-wrapped lines join with a space, setext headings and indented code parse normally, bare URLs linkify, and backslash escapes unescape.

A pasted bullet list converts to a task list only when EVERY item starts with a checkbox marker ([ ] or [x]): the schema does not allow mixing task and plain items in one list. Markers wrapped in formatting (**[x] done**) stay literal styled text, matching GFM.

$...$ and $$ blocks parse only when the corresponding math node is in the schema. Currency stays text: the inline rule rejects whitespace right inside the dollars and a digit right after the closing one, so price $5 and $10 never becomes a formula while $e^{i\pi}+1=0$ does.

import {
parseMarkdown,
serializeMarkdown,
createMarkdownParser,
createMarkdownSerializer,
} from '@domternal/extension-markdown';
const doc = parseMarkdown('# Title\n\nBody.', schema);
const { markdown, warnings } = serializeMarkdown(doc);
// Reusable instances for hot paths
const parser = createMarkdownParser(schema);
const serializer = createMarkdownSerializer();

The extension keeps shared instances in editor.storage.markdown (parser, serializer), so other packages and app code reuse the same configured pair.

Serialization for custom content plugs in through specs. A node serializer receives the serializer state and writes Markdown; a mark spec declares its delimiters:

import { Markdown } from '@domternal/extension-markdown';
Markdown.configure({
specs: {
nodes: {
callout(state, node) {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
},
},
marks: {
kbd: { open: '`', close: '`', escape: false },
},
},
});

Without a mapping, unknown nodes flatten to their content and unknown marks drop their formatting, each with a warning: a custom node never breaks the export.

On the parse side, Markdown features that have no schema counterpart degrade automatically: tables without the table extension stay readable text, ~~strike~~ without the Strike mark keeps its literal tildes, headings without the Heading node become paragraphs.

Markdown cannot express everything the editor can. The serializer keeps the content readable and reports a warning for: text alignment and line height, text and background colors, underline, superscript and subscript, merged table cells, multi-line table cell content, image resize dimensions, toggle (details) blocks (flattened to a bold summary paragraph plus content), and mentions (plain @label text).

Text that looks like Markdown syntax is escaped so it round-trips exactly, including |, $, <, and entity-like & sequences, which also keeps the output safe on renderers that allow raw HTML.

import {
Markdown,
getMarkdown,
downloadMarkdown,
parseMarkdown,
createMarkdownParser,
serializeMarkdown,
createMarkdownSerializer,
defaultNodeSerializers,
defaultMarkSpecs,
backticksFor,
looksLikeMarkdown,
markdownPastePlugin,
markdownPastePluginKey,
} from '@domternal/extension-markdown';
import type {
MarkdownOptions,
MarkdownStorage,
MarkdownParser,
MarkdownParseState,
MarkdownSerializer,
MarkdownSerializerState,
MarkdownSerializerSpecOverrides,
MarkdownSerializerOptions,
MarkdownSerializerSpecs,
MarkdownNodeSerializer,
MarkdownMarkSpec,
MarkdownWarning,
MarkdownWarningCode,
SerializeMarkdownResult,
SerializeMarkdownOptions,
} from '@domternal/extension-markdown';

@domternal/extension-markdown - GitHub