Markdown conversion has two directions:
Use MarkdownPlugin for editor-bound conversion. Use the standalone functions
from platejs/markdown for detached browser, server, worker, and CLI work.
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.
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.
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',
});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.
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.
| Tag | Feature |
|---|---|
<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.
<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).& is written as &, " as
", a newline as , and | as |.A and a, are written
as distinct labels.markdown-unsupported-node warning under every policy.<br>, <br/>, and <br /> become line breaks.markdown-tag-repair warning
with reason: 'unclosed' | 'unmatched' | 'misplaced'.name={…} are read as their raw text.[text][ref] with a [ref]: url
definition, resolve to ordinary links and images.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.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> |<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.<br/>. Parsing reads them back as one
paragraph with line breaks, so serializing reports the folded boundary as a
lossy warning.lossPolicy.markdown-property-omitted.| 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 | 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 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.
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.
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:
previous. Drop it when the stream
finishes, is cancelled or replaced, or unmounts.partial, continuing the latest preview. A failed final parse clears
the preview.AI chat previews follow the same rules; see AI streaming.
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.
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.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' } }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.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'.value matches, so a
later declaration for the same value only reads, as <del> does next to
{ node: 'delete' }.<span style="color: red; background-color: yellow;"> sets both marks. A
tag no mark mapping reads keeps its source text.markdown-property-omitted warning.Use decode and encode for a real format difference. Image chooses between
, <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.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.
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>.
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);| Code | Severity | Reports |
|---|---|---|
markdown-unsupported-node | Warning when lossless or when the content stays; other lossy follows lossPolicy | Content 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-content | Warning when lossless; lossy follows lossPolicy | A removed link or image destination. |
markdown-tag-repair | Warning | An unclosed, unmatched, or misplaced registered tag. |
markdown-property-omitted | Warning | A property or mark Markdown cannot carry on export, such as a paragraph's textAlign, or an invalid tag attribute value on import. |
markdown-limit-exceeded | Error | Source bytes, node count, or depth above limits. |
markdown-inline-blocks | Error | parseInline input that produced blocks. |
markdown-schema-repair | Warning; error when lossy under 'reject' | A schema repair applied to the parsed document. |
markdown-schema-invalid | Error | Parsed content the schema rejects. |
markdown-unsupported-root, markdown-unsupported-metadata | Warning | A 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 | Result |
|---|---|
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 option | Use |
|---|---|
limits | Override maxBytes, maxNodes, or maxDepth. |
lossPolicy | 'reject' (default) or 'allow'. |
partial | Parse an unfinished stream prefix for a preview. |
previous | editor.api.markdown.parseSlice only: the previous result of a growing source to continue. |
| Serialize option | Use |
|---|---|
document | Editor method only: the document to serialize, typed as the editor's value. Defaults to the current value. |
lossPolicy | 'reject' (default) or 'allow'. |
plainMarks | Mark keys written as plain text without Markdown formatting. |
preserveEmptyParagraphs | Keep empty paragraphs. Defaults to true. |
projection | 'accepted' or 'proposed'. Required for authored documents. |
remarkStringifyOptions | Options passed to remark-stringify. |
spread | Write 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.