Better Rich Text Buy licence

Markdown

On this page
  1. Quick start
  2. Markdown-first editors
  3. Headless editors
  4. Framework bindings
  5. Syntax
  6. Front matter
  7. Raw HTML
  8. Footnotes
  9. Writing Markdown
  10. Writer options
  11. Smart paste
  12. Copy as Markdown
  13. Source mode
  14. Split view
  15. Commands
  16. Input rules
  17. Configuration
  18. Extensions
  19. Hooks
  20. Limits

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 codec createRichTextEditor uses for getMarkdown(), 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

TypeScript
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 content is read as Markdown;

  • getContent() and setContent() 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:

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

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

TSX
<RichTextEditor markdown={markdown} format="markdown" value={value} onChange={(c) => setValue(c.value)} />
Vue
<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:

SyntaxResultDefault
==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 < b and 50% & more stay as they are, while *, a leading # or 1., &copy; 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

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

TypeScript
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

CommandInputDoes
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

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

TypeScript
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,
};
  • syntax adds micromark constructs and their mdast handlers.

  • parseNode maps an mdast node to model nodes. Return null to fall through.

  • serializeNode writes node types and serializeMark gives delimiters for mark types.

  • escape marks 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.extensions filter;

  • pass it to markdownCodec(host, { extensions }) or createMarkdownCodec({ parse: { extensions } }).

Hooks

FilterValueArguments
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
ActionArguments
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.