Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Upload Files
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Authored Changes
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting
  • Locations
  • Transactions
  • Serializing
  • Roots
  • Document Meta
  • Clipboard and Paste
  • Decorations and annotations
  • Schema
  • History
  • Pagination
  • Annotations
  • DOM Coverage
  • External Text Views
  • Virtualized Rendering

Markdown

PreviousNext

Parse Markdown documents, slices, and inline content, and serialize Plate documents to Markdown.

Markdown conversion has two directions:

  • Parse a complete Markdown file, a closed insertion slice, or inline content into Plate.
  • Serialize a schema-valid Plate document into Markdown.

Use MarkdownPlugin for editor-bound conversion. Use the standalone functions from platejs/markdown for detached browser, server, worker, and CLI work.

Parse Markdown

Installed editor













HTMLForm

On This Page

Parse MarkdownInstalled editorDetached conversionSerialize MarkdownInstalled editorDetached conversionSyntaxRegistered tagsWriter outputReading rulesTable cellsMentionsBlock IDsStreaming previewsAuthored contentDefine Markdown mappingsMarksCustom callbacksRemark pluginsResults and diagnosticsAPI Reference
Build your editor
Production-ready AI template and reusable components.
Get all-access
import { MarkdownPlugin } from 'platejs/markdown';
import { createEditor } from 'platejs';
const editor = createEditor({
plugins: [BaseParagraphPlugin, BaseHeadingPlugin, BaseBoldPlugin, MarkdownPlugin],
});
const result = editor.api.markdown.parse('# Hello\n\n**Plate** document');
if (result.ok) {
editor.update((tx) => tx.value.replace(result.document));
}
showDiagnostics(result.diagnostics);
import { MarkdownPlugin } from 'platejs/markdown';
import { createEditor } from 'platejs';
 
const editor = createEditor({
  plugins: [BaseParagraphPlugin, BaseHeadingPlugin, BaseBoldPlugin, MarkdownPlugin],
});
 
const result = editor.api.markdown.parse('# Hello\n\n**Plate** document');
 
if (result.ok) {
  editor.update((tx) => tx.value.replace(result.document));
}
showDiagnostics(result.diagnostics);

parse returns one complete EditorDocumentValue, including roots and metadata that Markdown can represent. Use parseSlice for block insertion and parseInline for inline insertion. Both return a closed, rootless ContentSlice.

const blocks = editor.api.markdown.parseSlice('- One\n- Two');
const inline = editor.api.markdown.parseInline('Hello **world**');
 
if (blocks.ok) editor.update.slice.replace(blocks.slice);
if (inline.ok) editor.update.slice.replace(inline.slice);
const blocks = editor.api.markdown.parseSlice('- One\n- Two');
const inline = editor.api.markdown.parseInline('Hello **world**');
 
if (blocks.ok) editor.update.slice.replace(blocks.slice);
if (inline.ok) editor.update.slice.replace(inline.slice);

parseInline accepts one paragraph of content; block content fails with markdown-inline-blocks. Direct syntax parsing does not preserve arbitrary open edges or detached roots. Those are native transfer properties owned by DataTransferFormat.

Detached conversion

import { parseMarkdown } from 'platejs/markdown';
 
const result = parseMarkdown(source, { plugins: EditorKit });
import { parseMarkdown } from 'platejs/markdown';
 
const result = parseMarkdown(source, { plugins: EditorKit });

Standalone functions compile the supplied plugin declarations for one operation. They do not activate an editor runtime, handlers, effects, or React hooks. Parse slices and inline content through an installed editor.

Serialize Markdown

Installed editor

const result = editor.api.markdown.serialize({
  projection: 'proposed',
});
 
showDiagnostics(result.diagnostics);
if (result.ok) downloadMarkdown(result.data);
const result = editor.api.markdown.serialize({
  projection: 'proposed',
});
 
showDiagnostics(result.diagnostics);
if (result.ok) downloadMarkdown(result.data);

Pass a captured document when the operation should not read the current value:

const result = editor.api.markdown.serialize({
  document: editor.read.value(),
  projection: 'accepted',
});
const result = editor.api.markdown.serialize({
  document: editor.read.value(),
  projection: 'accepted',
});

Detached conversion

import { serializeMarkdown } from 'platejs/markdown';
 
const result = serializeMarkdown(document, {
  plugins: EditorKit,
  projection: 'accepted',
});
import { serializeMarkdown } from 'platejs/markdown';
 
const result = serializeMarkdown(document, {
  plugins: EditorKit,
  projection: 'accepted',
});

Serialization asserts the document and the selected authored projection. Invalid model data and mapping bugs throw. Expected format loss returns a diagnosed result.

lossPolicy defaults to 'reject'. Unsupported visible content returns { ok: false, diagnostics } without data. lossPolicy: 'allow' can publish only a drop, replacement, or unwrap that the format reports as a warning. A loss that keeps the content, such as a paragraph boundary folded into <br/> in a table cell, warns under every policy.

Syntax

Plate reads and writes CommonMark plus the tags that installed feature plugins register. GFM, math, and emoji come from the remark plugins configured on MarkdownPlugin. Registered tags look like HTML, but only registered names are tags and nothing is evaluated.

Registered tags

TagFeature
<callout icon="💡">…</callout>Callout
<columnGroup>, <column width="50%">Column
<details>, <summary>Details
<toc />Table of Contents
<date value="2025-01-01" />Date
<img>, <figure> with <figcaption>Image
<video src="…" />, <audio>, <file>, <mediaEmbed>Media
<u>, <kbd>, <mark>, <sub>, <sup>, <del>Basic marks
<span style="color: red;">Font colors, family, size, and weight

A tag is registered only while its feature plugin is installed. Element tags use the schema type, so an application that renames a type also renames its tag.

Writer output

<callout icon="💡">Remember to save.</callout>
 
<details>
 
<summary>Title</summary>
 
Body text.
 
</details>
 
Due <date value="2025-01-01" />, ask [Jane](mention:user_123).
<callout icon="💡">Remember to save.</callout>
 
<details>
 
<summary>Title</summary>
 
Body text.
 
</details>
 
Due <date value="2025-01-01" />, ask [Jane](mention:user_123).
  • Block tags are separated from their content by blank lines, with no indentation.
  • A block holding one single-line paragraph stays on one line.
  • Attribute values are double-quoted. & is written as &amp;, " as &quot;, a newline as &#10;, and | as &#124;.
  • Footnote labels that CommonMark would merge, such as A and a, are written as distinct labels.

Reading rules

  • Every string parses. Raw HTML that no mapping accepts stays literal text and reports a lossless markdown-unsupported-node warning under every policy.
  • HTML comments are dropped with a lossless warning.
  • <br>, <br/>, and <br /> become line breaks.
  • A line holding only a registered block tag is a block, even next to paragraph text. A block tag elsewhere in a paragraph, heading, or table cell stays text.
  • Tags pair within one container, such as a paragraph, list item, or block quote. An unclosed tag closes at the end of its container, and an unmatched closing tag stays text. Each repair reports a markdown-tag-repair warning with reason: 'unclosed' | 'unmatched' | 'misplaced'.
  • Inside a Plate block tag, only fenced code is code. Indented lines are read as Markdown, so indented tag bodies parse like unindented ones. Attribute values written as name={…} are read as their raw text.
  • Reference-style links and images, [text][ref] with a [ref]: url definition, resolve to ordinary links and images.
  • Destinations no reader or page may act on are removed before any mapping reads them. A link keeps its label and reports a markdown-unsafe-content warning under every policy: lossless when the destination could run script, such as javascript: or data:, and lossy otherwise, such as a protocol-relative or malformed URL. An image whose source cannot load keeps its alt text and reports a lossy one; a script source never rendered, so its removal is lossless. Link's allowedSchemes narrows or widens link schemes above that floor; a link it rejects keeps its label and reports a lossy markdown-unsupported-node warning.

Table cells

A GFM row is one line, so Plate writes a table cell's blocks inline on that line:

| Plan |
| ---- |
| Intro<ul><li><input type="checkbox" checked disabled /> ship</li></ul><ol start="3"><li>step</li></ol> |
| Plan |
| ---- |
| Intro<ul><li><input type="checkbox" checked disabled /> ship</li></ul><ol start="3"><li>step</li></ol> |
  • Plate writes list paragraphs as <ul>, <ol start="n">, and <li> HTML, with a disabled checkbox leading each task item. With List installed, parsing reads that HTML back as list paragraphs; without it, the HTML stays literal text.
  • Plate joins other paragraphs with <br/>. Parsing reads them back as one paragraph with line breaks, so serializing reports the folded boundary as a lossy warning.
  • A heading or quote keeps its inline content, and a code or math block keeps its text, each with a lossy warning. Serializing drops any other block, such as a horizontal rule or a captioned image, and reports it under lossPolicy.
  • Plate writes a merged cell in its first slot and empty cells in the slots it covers. Each span reports markdown-property-omitted.
  • No line ending or | ends a cell early. In inline math, serializing writes | as \vert and \| as \Vert, writes | in a text-mode group such as \text{…} as \textbar{}, and gives \verb a delimiter other than |. Inline math with a | or \| it cannot place, such as one in another command's argument, an array column spec or \verb text, becomes text. Serializing writes | as %7C in an autolink destination and &#124; in raw HTML, turns a line ending in inline math or raw HTML into a space, and writes inline code holding \| as text. Each kind of rewrite reports one lossy warning per cell.

Mentions

Mentions are links with a mention: URL. Bare @jane text stays text.

Hello [Jane Smith](mention:user_123).
Hello [Jane Smith](mention:user_123).

See Mention.

Block IDs

Markdown does not carry element IDs, and serialization writes none. Parsing reads a <block id="…"> wrapper around one block: with ElementIdPlugin installed, the block keeps that id; otherwise the wrapper is removed with a markdown-unsupported-node warning. Mappings cannot claim the block tag.

Streaming previews

Parse each unfinished stream prefix with partial: true and the previous result, then parse the complete source strictly with the same hint:

const preview = editor.api.markdown.parseSlice(accumulated, {
  lossPolicy: 'allow',
  partial: true,
  previous,
});
previous = preview.ok ? preview : undefined;
 
// When the stream finishes or the user stops it:
const result = editor.api.markdown.parseSlice(accumulated, { previous });
previous = undefined;
const preview = editor.api.markdown.parseSlice(accumulated, {
  lossPolicy: 'allow',
  partial: true,
  previous,
});
previous = preview.ok ? preview : undefined;
 
// When the stream finishes or the user stops it:
const result = editor.api.markdown.parseSlice(accumulated, { previous });
previous = undefined;

partial: true hides a trailing tag that has not finished arriving, such as Before <callo, and does not report tags left open at the end of the source. lossPolicy: 'allow' keeps unsupported content visible as text while the stream is incomplete. previous must come from the same method of the same editor. When the source extends the previous result's source and the plugins and limits are unchanged, blocks it already completed keep their node objects, and only the text after them is converted again; any other hint parses the whole source. A parse under another lossPolicy, such as the strict final parse, keeps only the completed blocks that reported nothing. A source with a link reference or footnote definition ([id]: url, [^1]: note) always parses whole, because a definition can change blocks before it.

Render a read-only preview as a document of the parsed blocks; the editor supplies the plugins and is not edited:

<EditorStatic editor={editor} document={{ children: nodes }} />
<EditorStatic editor={editor} document={{ children: nodes }} />

To show the stream in an editable editor instead, publish each result outside the undo history. Replace only the blocks after the first one that changed, or the whole value when no leading block is unchanged:

let start = 0;
 
while (start < nodes.length && published[start] === nodes[start]) start += 1;
 
if (start === 0) {
  editor.update({ history: 'skip' }).value.replace({ children: nodes });
} else {
  editor.update({ history: 'skip' }, (tx) => {
    tx.nodes.replaceChildren(nodes.slice(start), { at: [], index: start });
  });
}
published = nodes;
let start = 0;
 
while (start < nodes.length && published[start] === nodes[start]) start += 1;
 
if (start === 0) {
  editor.update({ history: 'skip' }).value.replace({ children: nodes });
} else {
  editor.update({ history: 'skip' }, (tx) => {
    tx.nodes.replaceChildren(nodes.slice(start), { at: [], index: start });
  });
}
published = nodes;

An editor with authored changes records every node write as an authored change, so an editable preview with authored changes always replaces the value.

The consumer owns the source, the abort signal, the hint and the preview cadence:

  • Publish the first chunk at once, then the latest accumulated source at most every 32 ms.
  • Keep only the latest partial result as previous. Drop it when the stream finishes, is cancelled or replaced, or unmounts.
  • When the stream finishes or the user stops it, parse the current source once without partial, continuing the latest preview. A failed final parse clears the preview.
  • Cancel, replacement and unmount abort the stream and drop the pending preview without a final parse.

AI chat previews follow the same rules; see AI streaming.

Open in New Tab
Loading…

Authored content

Markdown represents visible accepted or proposed content. It does not append a hidden Plate document and cannot restore exact review state.

const proposed = editor.api.markdown.serialize({ projection: 'proposed' });
const accepted = editor.api.markdown.serialize({ projection: 'accepted' });
const proposed = editor.api.markdown.serialize({ projection: 'proposed' });
const accepted = editor.api.markdown.serialize({ projection: 'accepted' });

Use authored JSON for exact proposals, decisions, roots, and metadata. Use DOCX review export for Word revisions. See Authored Changes.

Define Markdown mappings

Declare mappings on the plugin that owns the node or mark. Most mappings are declarations: Plate builds the node, converts its properties, and reads and writes its children from the plugin's schema.

import { definePlugin, property, schema } from 'platejs';
 
const CalloutPlugin = definePlugin('callout', {
  schema: {
    element: schema.element.textBlock({
      properties: { tone: property.string() },
    }),
  },
  formats: ({ defineFormats, schema: { type } }) =>
    defineFormats({ markdown: { tag: type } }),
});
import { definePlugin, property, schema } from 'platejs';
 
const CalloutPlugin = definePlugin('callout', {
  schema: {
    element: schema.element.textBlock({
      properties: { tone: property.string() },
    }),
  },
  formats: ({ defineFormats, schema: { type } }) =>
    defineFormats({ markdown: { tag: type } }),
});

This mapping reads and writes <callout tone="info">Text</callout>.

  • tag registers a Plate tag. For an element, use its schema type, so an application that renames the type also renames the tag.
  • The element's content model decides the children. A void element has none; a text block holds one paragraph of inline content, written only when it is not empty; any other block holds blocks.
  • Attributes carry the element's non-metadata properties: the plugin's own properties first, in schema order, then other plugins' properties, such as textAlign, by key. List properties never become attributes, because the Markdown list around the element carries them. A property with a default that is not omitted is filled in when its attribute is absent.
  • attributes renames the plugin's own properties on the wire:
markdown: { tag: type, attributes: { url: 'src' } }
markdown: { tag: type, attributes: { url: 'src' } }
  • An attribute value is decoded by the property's kind and checked by its schema validator. An invalid value is left out with a markdown-property-omitted warning. When a required value is invalid, such as an unsafe src, the element is removed and its content kept. A script URL reports a lossless markdown-unsafe-content warning instead.

Marks

A plugin whose schema declares a mark maps a mark. Its declarations name where the mark comes from and how it is written:

// A standard Markdown container.
markdown: { node: 'strong' }
 
// A tag: `<kbd>` reads as `true` and a `true` mark writes `<kbd>`.
markdown: { tag: 'kbd' }
 
// One tag per value of an enum mark.
markdown: [
  { tag: 'sub', value: 'sub' },
  { tag: 'sup', value: 'sup' },
]
 
// A CSS-valued mark: `<span style="color: red;">` reads as `'red'`.
markdown: { tag: 'span', style: 'color' }
// A standard Markdown container.
markdown: { node: 'strong' }
 
// A tag: `<kbd>` reads as `true` and a `true` mark writes `<kbd>`.
markdown: { tag: 'kbd' }
 
// One tag per value of an enum mark.
markdown: [
  { tag: 'sub', value: 'sub' },
  { tag: 'sup', value: 'sup' },
]
 
// A CSS-valued mark: `<span style="color: red;">` reads as `'red'`.
markdown: { tag: 'span', style: 'color' }
  • node accepts 'strong', 'emphasis', 'delete', and 'inlineCode'.
  • A mark is written by the first declaration whose value matches, so a later declaration for the same value only reads, as <del> does next to { node: 'delete' }.
  • Every mark mapping on one tag contributes, so <span style="color: red; background-color: yellow;"> sets both marks. A tag no mark mapping reads keeps its source text.
  • A text property no mapping writes keeps its text and reports a markdown-property-omitted warning.

Custom callbacks

Use decode and encode for a real format difference. Image chooses between ![alt](src), <img>, and <figure>; Date writes a value it cannot normalize as text.

markdown: {
  node: 'heading',
  decode: ({ decode, marks, node }) => ({
    children: decode(node.children, marks),
    level: node.depth,
    type,
  }),
  encode: ({ encodePhrasing, node, preserve }) => {
    preserve('level');
 
    return {
      children: encodePhrasing(node.children),
      depth: node.level,
      type: 'heading',
    };
  },
}
markdown: {
  node: 'heading',
  decode: ({ decode, marks, node }) => ({
    children: decode(node.children, marks),
    level: node.depth,
    type,
  }),
  encode: ({ encodePhrasing, node, preserve }) => {
    preserve('level');
 
    return {
      children: encodePhrasing(node.children),
      depth: node.level,
      type: 'heading',
    };
  },
}
  • node selects a standard Markdown node kind, such as 'blockquote', 'link', or 'image'. Select one of node or tag. nestedTags names tags read only inside this tag, such as Image's figcaption.
  • Returning undefined from decode declines the node; the next mapping for the same source runs, in descending priority order. Mention decodes mention: links this way before Link.
  • marks contains the inherited persisted text marks. Pass it to decode or decodeNodes when nested content must keep those marks.
  • previousSibling is the Markdown node before this one. Lists use it to read a list right after another as restarted numbering.
  • readTagAttributes(tag?) returns { attributes, properties }: every attribute as written, and the node's properties decoded as a declared tag decodes them.
  • refuse(message) reports content the feature cannot represent as markdown-unsupported-node. A refused tag keeps its source text.
  • report({ action, kind, message, nodeType }) reports a loss the mapping handled itself. kind: 'property' marks a loss that keeps the content, such as a link destination the editor does not allow; it warns under every policy. The default kind: 'element' follows the loss policy.
  • caption(children) returns inline content when children is one paragraph, and null otherwise.
  • preserve(...keys) claims that the returned output carries these properties of the plugin. Every other content property on the node is reported as markdown-property-omitted. Claims count only when encode returns output.
  • encodeNodeAttributes() writes the node's attributes as a declared tag does and claims them. encodeAttributes(properties) writes the given property values and claims each property whose attribute the returned output keeps.
  • encodeLine(children) writes block children on one line, as Plate writes a table cell. encodePhrasing(children) writes inline children. decodeLine(children, marks?) reads that line back into blocks.

Pass an array to declare several mappings for one plugin. Use defineFormats(map) for self mappings and defineFormats(TargetPlugin, map) for a foreign target. A plugin's formats accept only the html, markdown, and plainText keys; MIME payloads belong in dataTransferFormats.

Remark plugins

Configure remark plugins once on MarkdownPlugin. Every parse and serialize operation of that editor uses them. MarkdownKit enables GFM, math, and emoji:

import { MarkdownPlugin } from 'platejs/markdown';
import remarkEmoji from 'remark-emoji';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
 
export const MarkdownKit = [
  MarkdownPlugin.configure({
    initialState: {
      remarkPlugins: [remarkMath, remarkGfm, remarkEmoji],
    },
  }),
];
import { MarkdownPlugin } from 'platejs/markdown';
import remarkEmoji from 'remark-emoji';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
 
export const MarkdownKit = [
  MarkdownPlugin.configure({
    initialState: {
      remarkPlugins: [remarkMath, remarkGfm, remarkEmoji],
    },
  }),
];
import { MarkdownPlugin } from 'platejs/markdown';
import remarkGfm from 'remark-gfm';
 
const plugins = [
  ...BasicBlocksKit,
  MarkdownPlugin.configure({
    initialState: { remarkPlugins: [remarkGfm] },
  }),
];
import { MarkdownPlugin } from 'platejs/markdown';
import remarkGfm from 'remark-gfm';
 
const plugins = [
  ...BasicBlocksKit,
  MarkdownPlugin.configure({
    initialState: { remarkPlugins: [remarkGfm] },
  }),
];

Standalone functions read the configured MarkdownPlugin from plugins. Remark plugins must be synchronous. An attacher or transformer that returns a Promise is a configuration error and throws. The synchronous functions never return Result | Promise<Result>.

Results and diagnostics

Parse success contains warnings and exactly one carrier. Failure contains a nonempty error-first diagnostic tuple and no carrier. Serialization follows the same rule for data.

if (!result.ok) {
  reportImportFailure(result.diagnostics);
  return;
}
 
useDocument(result.document);
reportWarnings(result.diagnostics);
if (!result.ok) {
  reportImportFailure(result.diagnostics);
  return;
}
 
useDocument(result.document);
reportWarnings(result.diagnostics);
CodeSeverityReports
markdown-unsupported-nodeWarning when lossless or when the content stays; other lossy follows lossPolicyContent no mapping accepts, refused content, or nodes dropped on export (lossy). Raw HTML kept as text, HTML comments and removed <block> wrappers are lossless. A table cell's folded paragraph boundary or collapsed block keeps its content and warns.
markdown-unsafe-contentWarning when lossless; lossy follows lossPolicyA removed link or image destination.
markdown-tag-repairWarningAn unclosed, unmatched, or misplaced registered tag.
markdown-property-omittedWarningA property or mark Markdown cannot carry on export, such as a paragraph's textAlign, or an invalid tag attribute value on import.
markdown-limit-exceededErrorSource bytes, node count, or depth above limits.
markdown-inline-blocksErrorparseInline input that produced blocks.
markdown-schema-repairWarning; error when lossy under 'reject'A schema repair applied to the parsed document.
markdown-schema-invalidErrorParsed content the schema rejects.
markdown-unsupported-root, markdown-unsupported-metadataWarningA named root or document metadata omitted on export.

Authored projection diagnostics use the same result. Configuration failures and mapping contract violations throw.

The defaults are 5 MiB of UTF-8 source, 100,000 mdast nodes, and depth 256. Markdown checks bytes before parsing and applies node/depth admission after the parser returns because mdast does not expose a bounded construction hook.

API Reference

APIResult
parseMarkdown(source, options)Complete EditorDocumentValue.
serializeMarkdown(document, options)Markdown string in data.
editor.api.markdown.parse(source, options?)Complete document using the installed target.
editor.api.markdown.parseSlice(source, options?)Closed, rootless block ContentSlice.
editor.api.markdown.parseInline(source, options?)Closed, rootless inline ContentSlice.
editor.api.markdown.serialize(options?)Current or supplied document using the installed target.

Import MarkdownPlugin, the standalone functions, and their result, diagnostic, and option types from platejs/markdown.

Parse optionUse
limitsOverride maxBytes, maxNodes, or maxDepth.
lossPolicy'reject' (default) or 'allow'.
partialParse an unfinished stream prefix for a preview.
previouseditor.api.markdown.parseSlice only: the previous result of a growing source to continue.
Serialize optionUse
documentEditor method only: the document to serialize, typed as the editor's value. Defaults to the current value.
lossPolicy'reject' (default) or 'allow'.
plainMarksMark keys written as plain text without Markdown formatting.
preserveEmptyParagraphsKeep empty paragraphs. Defaults to true.
projection'accepted' or 'proposed'. Required for authored documents.
remarkStringifyOptionsOptions passed to remark-stringify.
spreadWrite loose lists. Defaults to false.

Standalone functions also require plugins and accept schema. Installed editor methods omit both because the plugin captures them from the editor. MarkdownPlugin state holds remarkPlugins, remarkStringifyOptions, and plainMarks.