A surprising amount of the text people paste into a rich text editor is Markdown. It comes from ChatGPT answers, GitHub READMEs, Obsidian notes, Slack drafts. The structure is right there in the text: ## headings, - [ ] task lists, pipe tables. And most editors paste it as what it technically is, a wall of plain text with funny punctuation, leaving the user to rebuild by hand the structure they can already see.
@domternal/extension-markdown, new in 0.12.0, closes that gap in both directions. Markdown-looking pastes convert to real blocks, two commands and a headless API cover programmatic import, and the serializer turns any document back into GitHub-flavored Markdown, telling you exactly what could not survive the trip. Here's how it works, including the parts that are deliberately conservative.
A paste that knows when to do nothing
The dangerous part of Markdown paste isn't the conversion, it's the false positive. Nobody wants an editor that mangles a regular sentence because it happened to contain an asterisk. So the paste handler is built around refusal, and converts only when all of these hold:
-
The clipboard has no HTML flavor. A paste from Google Docs, a web page, or VS Code carries
text/html, and ProseMirror's own HTML paste preserves more fidelity than any Markdown reparse could. Those pastes are never touched. This also means you can't demo the feature by copying rendered Markdown from a web page: it has to be plain text, which is exactly the point. -
The text actually looks like Markdown. At least one block marker (
#,>, a list marker,1., a code fence,$$, a checkbox, a pipe-table row, a thematic break) or inline span (**bold**,`code`,~~strike~~, a link or image) has to be present. Plain prose passes through untouched, and a bare URL is left alone so the Link extension's paste handling can turn it into a link, as before. - The selection isn't inside a code block. Pasting Markdown into a code block keeps it literal, because inside a code block, Markdown source is the content.
If the parser itself fails for any reason, the handler falls back to the default plain-text paste rather than letting anything escape. And when a paste does convert, one more detail matters: a single-paragraph paste like has **bold** inline inserts as inline content and merges into the text at the cursor, while multi-block Markdown replaces the selection as blocks. Either way it's one transaction, so undo restores the exact pre-paste document in a single step.
If you'd rather not have any of this, it's one option: Markdown.configure({ paste: false }).
Import as an API
The same parser backs two commands, for the cases where the Markdown comes from your code instead of the clipboard:
editor.commands.insertMarkdown('## Hello\n\n- [x] done\n- [ ] open');
editor.commands.setMarkdownContent('# Fresh document');
insertMarkdown parses and inserts at the selection with the same single-paragraph-merges-inline behavior as paste. setMarkdownContent replaces the whole document and passes its options through to setContent, so { emitUpdate: false } works the way you'd expect when you're loading content and don't want to trigger save logic.
An export that tells you what it lost
Serializing a rich document to Markdown is inherently lossy. An editor document can carry text alignment, colors, underline, merged table cells; Markdown can express none of those. There are two common ways to handle that, and I didn't like either: throw and refuse to export, or silently drop the formatting and let the user discover the damage later.
Domternal's serializer takes a third path. It always produces the best Markdown it can, keeps the content readable, and reports every fidelity loss through a warnings channel:
import { getMarkdown, downloadMarkdown } from '@domternal/extension-markdown';
const { markdown, warnings } = getMarkdown(editor);
// warnings: [{ code: 'unsupported-mark', message: 'Mark type "underline" ...', nodeType: 'underline' }]
downloadMarkdown(editor, 'notes.md'); // triggers a .md download, returns the same result
There are four warning codes: unsupported-node and unsupported-mark for content with no Markdown mapping, lossy-attribute for things like alignment or image resize dimensions, and lossy-structure for content that had to be flattened, like a toggle block becoming a bold summary paragraph followed by its content, or a mention becoming plain @label text. Warnings are deduplicated, so a document with forty underlined spans reports the underline loss once.
What you do with them is a product decision: show a toast, list them in an export dialog, or ignore them. The point is that the information exists, so "export to Markdown" never has to mean "find out next week what your document quietly dropped".
The GFM surface, and the round-trip promise
The serializer and parser cover the full Notion-style schema: headings, bullet and ordered lists (with start), GFM task lists, blockquotes, fenced code with a language (the fence grows when the code itself contains backticks), pipe tables with column alignment, images with alt and title, links and autolinks, bold, italic, strikethrough, inline code, hard breaks, horizontal rules, LaTeX math, and emoji.
For that supported subset, round trips are exact and covered by tests: serialize(parse(markdown)) reproduces the input, and parse(serialize(doc)) rebuilds an equal document. Exactness is what makes the feature trustworthy; "mostly the same after a round trip" is how documents rot.
My favorite small detail is the math currency guard. $...$ is the standard inline LaTeX delimiter, but it's also how people write prices. The inline rule refuses whitespace just inside the dollars and a digit right after the closing one, so price $5 and $10 total stays text while $e^{i\pi}+1=0$ becomes an equation. Escaping runs in the other direction too: text that merely looks like Markdown (|, $, <, entity-like & sequences) is escaped on export so it round-trips exactly, which also keeps the output well-behaved on renderers that allow raw HTML.
Headless, and schema-adaptive
Parsing and serialization don't need an editor instance:
import { parseMarkdown, serializeMarkdown } from '@domternal/extension-markdown';
const doc = parseMarkdown('# Title\n\nBody.', schema);
const { markdown, warnings } = serializeMarkdown(doc);
That runs in Node scripts, tests, or on a server, against any schema. And the parser adapts to whatever schema you give it: the handler set is derived from what the schema actually contains, so Markdown features without a counterpart degrade to readable text instead of failing. Paste a table into an editor without the Table extension and you get the rows as plain text, not an exception. ~~strike~~ without the Strike mark keeps its literal tildes.
Custom content plugs into the same machinery. A node serializer is a function that writes Markdown, a mark spec declares its delimiters:
Markdown.configure({
specs: {
nodes: {
callout(state, node) {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
},
},
marks: {
kbd: { open: '`', close: '`', escape: false },
},
},
});
Without a mapping, a custom node flattens to its content with a warning. A custom node never breaks the export.
Setting it up
One package, no configuration required:
pnpm add @domternal/extension-markdown
import { Editor, StarterKit } from '@domternal/core';
import { Markdown, getMarkdown } from '@domternal/extension-markdown';
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [StarterKit, Markdown],
});
The same setup works through the React, Vue, Angular, and vanilla wrappers, and it composes with the Notion mode stack: the paste handler and SmartPaste divide the work cleanly, since SmartPaste ignores plain-text pastes and the Markdown heuristic rejects what isn't Markdown. There's a live playground on the docs page where you can round-trip a document both ways without installing anything.
Shipping in 0.12.0
@domternal/extension-markdown is new in 0.12.0, MIT licensed like everything else. The same release taught plugin views to dispatch transactions during editor construction and gave the framework wrappers a history: false option, groundwork for editors that bring their own undo. The full list is in the changelog.
- Markdown documentation - options, commands, the warnings reference, headless usage, and the live playground
- Live examples - the homepage editors now convert Markdown pastes too
- GitHub - MIT licensed, issues and stars welcome
If you wire the warnings channel into an export UI, or feed the parser Markdown from somewhere I haven't thought of, I'd genuinely like to see it. Lossy conversions are only honest when someone is checking the loss report.


Top comments (0)