Markdown
On this page
Better Rich Text reads and writes CommonMark and GitHub Flavored Markdown. You can paste it, type it, edit it as source, store it and bind it to forms, and plugins can add their own syntax.
Three packages are involved:
@better-rich-text/format-markdown: parser, writer, extension hooks and paste detection. No DOM.@better-rich-text/core/markdown: the codeccreateRichTextEditoruses forgetMarkdown(),setMarkdown()and Markdown-first editors.@better-rich-text/plugin-markdown: commands, smart paste, copy as Markdown, input rules, source mode and split view.
None of them is part of the standard bundle. You only pay for Markdown when you import it.
Quick start
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { markdown, markdownPlugin, markdownFeature } from '@better-rich-text/plugin-markdown';
const editor = createRichTextEditor('#editor', {
markdown,
contentFormat: 'markdown',
content: '# Hello\n\nSome **bold** text',
plugins: [markdownPlugin],
features: [...standardPreset.features, markdownFeature],
toolbar: `${standardPreset.toolbar} | markdown markdownSplit markdownCopy`,
});
editor.getContent(); // '# Hello\n\nSome **bold** text\n'Markdown-first editors
The markdown option enables Markdown. contentFormat: 'markdown' then makes Markdown the editor's own format:
a string
contentis read as Markdown;getContent()andsetContent()use Markdown;a replaced
<textarea>holds Markdown, so a plain form submits Markdown.
getMarkdown() and setMarkdown() work whenever markdown is set, whatever the content format. Without markdown, core never loads a Markdown parser.
markdown is a MarkdownCodec. The default one supports GFM, ==highlight==, heading anchors and front matter. Use createMarkdownCodec for other options:
import { createMarkdownCodec } from '@better-rich-text/core/markdown';
const markdown = createMarkdownCodec({
parse: { superscript: true, subscript: true, html: 'escape' },
serialize: { bullet: '*', complexTables: 'flatten' },
});When markdownPlugin is installed, its configuration supplies these defaults for every conversion of that editor, including getMarkdown(). Options passed to createMarkdownCodec take precedence.
Headless editors
getMarkdown(target) and setMarkdown(target, markdown) from plugin-markdown accept a RichTextEditor or an engine Editor, and use the same codec:
import { createEditor } from '@better-rich-text/engine';
import { getMarkdown, setMarkdown } from '@better-rich-text/plugin-markdown';
const editor = createEditor({ plugins });
setMarkdown(editor, '## Notes');
getMarkdown(editor); // '## Notes\n'Framework bindings
Adapters take format: 'markdown' next to 'html' and 'json'. Pass the codec in the editor options:
<RichTextEditor markdown={markdown} format="markdown" value={value} onChange={(c) => setValue(c.value)} /><RichTextEditor :options="{ markdown }" format="markdown" v-model="body" />The web component accepts format="markdown" when its config property includes markdown.
Syntax
Everything in CommonMark 0.31 and GFM:
headings (ATX and setext), paragraphs, hard breaks (backslash or two spaces), quotes, thematic breaks;
fenced and indented code with languages;
nested, ordered (any start number) and task lists;
tables with column alignment and inline formatting;
strikethrough, autolinks (
<url>,www., bare URLs and emails);links and images with titles, reference definitions, linked images;
footnotes;
raw HTML, through the schema.
On top of that:
| Syntax | Result | Default |
|---|---|---|
==text== |
Highlight | On |
^text^ |
Superscript | Off |
~text~ |
Subscript (then single tildes no longer strike through) | Off |
# Title {#anchor} |
Heading anchor | On |
--- YAML or +++ TOML at the top |
Front matter | On |
Superscript and subscript follow Pandoc: no unescaped spaces inside.
Front matter
Front matter is kept in the document metadata under frontMatter as { format, raw, data }. data holds top-level scalar values, so title: Notes gives data.title === 'Notes'. It is written back unchanged.
Raw HTML
html controls raw HTML in Markdown:
parse(default): HTML goes through the same schema parser as pasted HTML.<u>x</u>becomes underline,<span style="color: red">a colour, scripts and event handlers disappear.escape: HTML stays as literal text.drop: HTML is removed, with a diagnostic.
Inline tags wrap Markdown normally: <u>**both**</u> is bold and underlined.
Footnotes
Footnotes map to whatever node a plugin registers through markdown.parseNode. Without one, references and definitions stay as [^label] text, and the writer leaves that text unescaped, so the footnotes come back as footnotes.
Writing Markdown
serializeMarkdown writes Markdown that reads back to the same document:
Characters are escaped only where they would change meaning.
snake_case,a < band50% & morestay as they are, while*, a leading#or1.,©and bare URLs are escaped.Emphasis that would not parse, such as bold inside a word next to punctuation (
foo**(bar)**baz), is written as<strong>instead.Adjacent lists alternate markers (
-then*,.then)), so they stay separate.Table columns are padded. Tables with merged cells or block content become HTML, a flattened GFM table (
complexTables: 'flatten', cells joined with<br>) or nothing ('drop').Formatting Markdown cannot express (underline, colours, sizes, image dimensions) is written as inline HTML, or dropped with
unsupported: 'drop'.
Every simplification is listed in losses, so you can show people what changed.
Writing is idempotent: serialize(parse(serialize(doc))) equals serialize(doc). The test suite checks this on more than a hundred tricky documents.
Writer options
| Option | Values | Default |
|---|---|---|
bullet |
- * + |
- |
emphasis |
* _ |
* |
strong |
** __ |
** |
fence |
` ~ |
` |
unsupported |
html drop |
html |
complexTables |
html flatten drop |
html |
alignTables |
boolean | true |
highlight, superscript, subscript, headingIds |
boolean | as for parsing |
Smart paste
When plain text is pasted, plugin-markdown checks whether it is Markdown. If it is, the text arrives formatted in two undo steps: the first undo gives back the plain text and the second removes the paste. A short note offers Keep plain text.
Detection weighs signals such as headings, lists, fences, tables, links and paired emphasis, and ignores text that looks like source code. A stray asterisk, 5 * 3 or # comment stays plain text. detectMarkdown(text) returns the score, a confidence between 0 and 1 and the signals found.
The text stays literal when:
it is pasted inside a code block or inline code;
the clipboard also has HTML (the HTML wins);
the person pressed Ctrl/Cmd+Shift+V.
Configure it with pasteMarkdown:
config: { markdown: { pasteMarkdown: 'auto', pasteThreshold: 3 } } // 'auto' | 'always' | 'never'Copy as Markdown
With copyAsMarkdown: true, copying puts Markdown in text/plain, so pasting into a plain text field, a chat or a code editor gives Markdown. Rich targets still get HTML.
The markdown.copy command copies the selection, or the whole document when nothing is selected, and runs markdown.copied when the clipboard write finishes.
Source mode
The Markdown source button, or Ctrl/Cmd+Shift+M, swaps the formatted view for the document's Markdown. Leaving with the button, Done, Escape or Ctrl/Cmd+Enter applies your edits as one undoable step. Only the top-level blocks you changed are replaced, so the rest of the document keeps its identity, comments and collaboration positions.
Tab and Shift+Tab indent lines. Escape always leaves, so keyboard focus is never trapped.
Split view
Markdown split view shows the Markdown beside the live document. Both sides are editable: typing in the Markdown updates the document after a short pause, and edits in the document update the Markdown unless you are in the middle of editing it. On narrow screens the panes stack.
Commands
| Command | Input | Does |
|---|---|---|
markdown.insert |
{ markdown } |
Inserts Markdown at the caret, replacing the selection |
markdown.get |
{ scope?: 'document' | 'selection' } |
Returns { markdown, losses } |
markdown.set |
{ markdown } |
Replaces the document, keeping unchanged blocks |
markdown.toggleSource |
{ mode?: 'rich' | 'source' | 'split' } |
Switches views (needs the interface) |
markdown.copy |
{ scope?: 'document' | 'selection' } |
Copies Markdown to the clipboard |
Inputs are validated, edits are undoable, and every command has a label, description and aliases, so editor.toolDefinitions() exposes them as AI tools.
Input rules
The editor already turns # , > , - , 1. , [ ] , ---, ```, **bold**, *italic*, ~~strike~~ and `code` into formatting as you type. plugin-markdown adds:
==text==to highlight;[text](https://…)to a link;- [ ]and- [x]to a task item (in plugin-lists, so it works without plugin-markdown too).
Configuration
config: {
markdown: {
pasteMarkdown: 'auto',
pasteThreshold: 3,
copyAsMarkdown: false,
html: 'parse',
gfm: true,
highlight: true,
superscript: false,
subscript: false,
headingIds: true,
bullet: '-',
emphasis: '*',
strong: '**',
complexTables: 'html',
},
}Extensions
Plugins add syntax with a MarkdownExtension:
import type { MarkdownExtension } from '@better-rich-text/format-markdown';
export const mathMarkdown: MarkdownExtension = {
name: 'math',
syntax: { micromark: [mathSyntax()], mdast: [mathFromMarkdown()] },
parseNode(node, ctx) {
if (node.type !== 'inlineMath') return null;
return [ctx.schema.element(ctx.id(), 'mathInline', { tex: node.value })];
},
serializeNode: {
mathInline: (node) => `$${String(node.attrs['tex'])}$`,
},
escape: /\$/g,
};syntaxadds micromark constructs and their mdast handlers.parseNodemaps an mdast node to model nodes. Returnnullto fall through.serializeNodewrites node types andserializeMarkgives delimiters for mark types.escapemarks plain text that must be backslash-escaped so it is not read as the new syntax.
Register an extension in one of three ways:
contribute it from a plugin:
contributes: { markdown: mathMarkdown };add it with the
markdown.extensionsfilter;pass it to
markdownCodec(host, { extensions })orcreateMarkdownCodec({ parse: { extensions } }).
Hooks
| Filter | Value | Arguments |
|---|---|---|
markdown.extensions |
extension list | |
markdown.parse.options |
parse option defaults | |
markdown.parse.input |
source string | |
markdown.parseNode |
ModelNode[] | null |
mdast node, context |
markdown.parse.result |
{ root, diagnostics, sourceMap, frontMatter } |
|
markdown.serialize.options |
serialize option defaults | |
markdown.serializeNode |
string | null |
node, context |
markdown.serialize.output |
Markdown string | root |
| Action | Arguments |
|---|---|
markdown.pasted |
{ text, confidence, undoRestoresText } |
markdown.copied |
Markdown string |
markdown.copyFailed |
error |
markdown.parseNode sees every block and inline node, so a plugin can take over any construct without an extension object. GFM alerts (> [!NOTE]), for example, can become callouts by claiming blockquote nodes whose first line is an alert marker.
Limits
Pasting from apps that also put HTML on the clipboard, such as some code editors, uses the HTML.
Whitespace just inside formatting (
**bold **) moves outside the delimiters, because Markdown cannot keep it inside.Loose and tight lists are not distinguished in the document. A list is written loose when an item has more than one block.
Image sizes, alignment, text colour and similar styling have no Markdown form and are written as HTML.
Link targets (
target="_blank") are not written.