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
insertMarkdownandsetMarkdownContentcommands, or automatically when users paste Markdown-looking plain text. - Export: serialize any document back to Markdown with
getMarkdownor thedownloadMarkdownhelper, with a warning channel for anything Markdown cannot express. - Headless:
parseMarkdownandserializeMarkdownwork 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.
When to use
Section titled “When to use”Use Markdown when you need:
- Users pasting Markdown from ChatGPT, GitHub, Obsidian, or any plain-text source and expecting rich content
- A
.mdexport path for documents (static site content, GitHub files, LLM prompts) - Programmatic content ingestion from Markdown sources (
setMarkdownContentfor whole documents,insertMarkdownat 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
Live Playground
Section titled “Live Playground”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.
Installation
Section titled “Installation”pnpm add @domternal/extension-markdownnpm install @domternal/extension-markdownyarn add @domternal/extension-markdownQuickstart
Section titled “Quickstart”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],});
// Importeditor.commands.insertMarkdown('## Hello\n\n- [x] done\n- [ ] open');editor.commands.setMarkdownContent('# Fresh document');
// Exportconst { markdown, warnings } = getMarkdown(editor);downloadMarkdown(editor, 'notes.md');Options
Section titled “Options”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})| Option | Type | Default | Description |
|---|---|---|---|
paste | boolean | true | Convert Markdown-looking plain-text pastes into rich content |
tightLists | boolean | true | Serialize lists without blank lines between items |
specs | MarkdownSerializerSpecOverrides | null | null | Extra or replacement serializer mappings for custom nodes and marks, merged over the built-ins |
Markdown paste
Section titled “Markdown paste”With paste: true (the default), a paste converts to rich content only when ALL of these hold:
- The clipboard has NO
text/htmlflavor. 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. - 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). - 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.
Commands
Section titled “Commands”editor.commands.insertMarkdown(markdown: string): boolean;editor.commands.setMarkdownContent(markdown: string, options?: SetContentOptions): boolean;insertMarkdownparses the Markdown and inserts it at the selection. A single paragraph merges inline at the cursor (matching paste); anything larger inserts as blocks.setMarkdownContentreplaces the whole document. Theoptionspass through tosetContent(emitUpdate,parseOptions).
Export
Section titled “Export”import { getMarkdown, downloadMarkdown } from '@domternal/extension-markdown';
const { markdown, warnings } = getMarkdown(editor);const result = downloadMarkdown(editor, 'document.md'); // triggers a .md downloadBoth return a SerializeMarkdownResult:
interface SerializeMarkdownResult { markdown: string; warnings: MarkdownWarning[];}
interface MarkdownWarning { code: 'unsupported-node' | 'unsupported-mark' | 'lossy-attribute' | 'lossy-structure'; message: string; nodeType?: string;}| Code | Meaning | Examples |
|---|---|---|
unsupported-node | A node type has no Markdown mapping; content flattened or omitted | custom nodes, table of contents block |
unsupported-mark | A mark type has no Markdown mapping; formatting dropped, text kept | underline, superscript, subscript, text color |
lossy-attribute | An attribute Markdown cannot carry was dropped | text alignment, line height, image resize dimensions, cell backgrounds |
lossy-structure | Structure was flattened to fit Markdown | toggle blocks, merged table cells, multi-block table cells, mentions |
Warnings are deduplicated: each distinct loss is reported once per export.
What maps to what
Section titled “What maps to what”| Editor content | Markdown |
|---|---|
| Headings 1 to 6 | # through ###### |
| Bullet, ordered, task lists | - , 1. (with start), - [x] / - [ ] |
| Blockquote | > |
| Code block with language | fenced ```lang (fence grows when the code contains backticks) |
| Table with header row and column alignment | GFM pipe table with :---: separators |
| Image with alt and title |  |
| Link, autolink | [text](href "title"), <https://...> |
| Bold, italic, strike, inline code | **, *, ~~, ` |
| Hard break | backslash line break |
| Horizontal rule | --- |
Math (with @domternal/extension-math) | $...$ inline, $$ blocks |
| Emoji | the 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.
Task lists
Section titled “Task lists”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.
Headless usage
Section titled “Headless usage”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 pathsconst 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.
Custom nodes and marks
Section titled “Custom nodes and marks”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.
Fidelity notes
Section titled “Fidelity notes”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.
All exports
Section titled “All exports”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';Source
Section titled “Source”@domternal/extension-markdown - GitHub
See also
Section titled “See also”- Adding Markdown to the Editor: Paste, Export, and What Gets Lost - the blog post behind this extension, with demo videos
- Typography - smart quotes and dashes while typing
- Code Block Lowlight - syntax highlighting for the fenced code blocks you paste
- Math - the LaTeX nodes behind
$...$parsing - Table - the table nodes behind pipe-table parsing