Extensions
import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
export const appMarkdown = defineMarkdownPreset({ extensions: [gfm()] })A preset describes what Markdown means in your application. If you pass the same preset to the renderer, the editor and the variables plugin, all three parse the same dialect. To add a feature you add an extension to that list, instead of adding a prop to three different components.
The contract#
An extension is a plain object. There are no classes involved and nothing gets registered globally.
interface MarkdownExtension {
readonly name: string
readonly version?: string
readonly contractVersion?: 1
readonly capabilities?: {
readonly syntax?: SyntaxAdapter
readonly renderer?: RendererAdapter
readonly editor?: EditorAdapter
readonly variables?: VariablesAdapter
}
}name identifies the extension, and merging, replacement and diagnostics all
key on it. contractVersion is the version of the adapter shapes (currently 1).
An extension only declares the capabilities it has, and each package only reads the ones it understands.
| Capability | Fields | Who reads it |
|---|---|---|
syntax | micromarkExtensions, fromMarkdownExtensions, toMarkdownExtensions, nodeTypes, transform | Renderer, editor and variables plugin |
renderer | handlers, components, rehypePlugins | Renderer |
editor | nodes, plugins, commands | Editor |
variables | literalNodeTypes, formatters, transform | Variables plugin |
The renderer ignores editor adapters. The variables plugin uses the syntax and variables adapters and never imports editor code, which means a headless Node service can load an editor-aware extension without installing the editor.
How gfm() is built#
The built-in dialect goes through the same mechanism a third-party extension would. It doesn't get any special treatment.
export function gfm(options: GfmOptions = {}): MarkdownExtension {
return {
name: 'gfm',
version: '1',
contractVersion: 1,
capabilities: {
syntax: {
micromarkExtensions: [micromarkGfm({ singleTilde: options.singleTilde ?? true })],
fromMarkdownExtensions: [gfmFromMarkdown()],
toMarkdownExtensions: [gfmToMarkdown()],
nodeTypes: ['table', 'tableRow', 'tableCell', 'delete', 'footnoteDefinition', 'footnoteReference'],
},
variables: { literalNodeTypes: [] },
},
}
}The three syntax fields handle parsing, building the tree and serializing. With all three declared, the dialect round-trips, so text goes in, becomes a tree, and comes back out unchanged.
gfm() ships once, from @react-markdown-kit/renderer. The variables plugin
and the editor read the same preset, so that one definition covers the dialect
everywhere.
| Metric | Before | After | | --- | ---: | ---: | | Cold render | 12.4 ms | 9.1 ms | - [x] Ship tables - [ ] Ship footnotes ~~Deprecated~~ replaced.
| Metric | Before | After |
|---|---|---|
| Cold render | 12.4 ms | 9.1 ms |
- Ship tables
- Ship footnotes
Deprecated replaced.
Writing one#
A renderer-only extension#
The smallest useful extension just contributes components.
import type { MarkdownExtension } from '@react-markdown-kit/renderer'
import { ExternalLink } from './external-link'
export function externalLinks(): MarkdownExtension {
return {
name: 'external-links',
version: '1',
contractVersion: 1,
capabilities: {
renderer: { components: { a: ExternalLink } },
},
}
}A variables-only extension#
Formatters can ship inside an extension. That way a team can define its house date format once and every document picks it up.
import type { MarkdownExtension } from '@react-markdown-kit/variables'
export function houseFormats(): MarkdownExtension {
return {
name: 'house-formats',
version: '1',
contractVersion: 1,
capabilities: {
variables: {
formatters: {
shortDate: (value, { locale, timeZone }) =>
new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeZone }).format(
new Date(value as string),
),
},
},
},
}
}literalNodeTypes is the other half of the variables adapter. If you list a
node type there, the engine won't scan its text for placeholders (this is how
code blocks stay literal).
A multi-capability extension#
variables({ data }) from @react-markdown-kit/variables is a syntax.transform
that reads its siblings. Every other extension's variables.literalNodeTypes
and variables.formatters apply, which is how mermaid() keeps placeholders
in a payload literal without either package knowing about the other.
variableChips() from the same package declares four capabilities at once.
The syntax adapter lifts {{...}} runs into a variable node and
serializes them back byte-for-byte. The variables adapter marks that node
literal so a chip never reaches the interpolator, and the renderer adapter
shows an unresolved placeholder as a chip. The editor adapter is added by
@react-markdown-kit/variables/editor under the same name and supplies the
Lexical chip, the {{ trigger and the insert command.
If a renderer loads the editor's version, it draws the document and ignores the editor half. You don't need to configure anything for that.
A shipped example: mermaid()#
@react-markdown-kit/mermaid is built only on this contract.
Its syntax adapter lifts a ```mermaid fence into a diagram node
and serializes it back. Its renderer adapter draws that node as a static SVG,
its variables adapter marks the payload literal, and
@react-markdown-kit/mermaid/editor adds an editor adapter with a Lexical
node, a canvas and a toolbar button. The root entry doesn't load React or
Lexical, so the same extension can go into a Node service's preset unchanged.
The package doesn't export anything besides the plugin.
import { mermaid } from '@react-markdown-kit/mermaid'
export const appMarkdown = defineMarkdownPreset({ extensions: [gfm(), mermaid()] })A shipped example: slides()#
@react-markdown-kit/slides is a different kind of
extension. It doesn't add a fence or any inline syntax, and it never
introduces a container node. A deck is plain CommonMark with slides separated
by ---. Its syntax.transform runs on the flat tree after parsing and
re-types a ??? or -- paragraph to a slideMarker and a
<!-- key: value --> comment to a slideDirective. Its renderer adapter
contributes a handler for the mdast root, which groups the flat children into
one <article data-rmk-deck> of <section>s. Every block still renders through
the renderer's own handlers, so headings, lists and a GFM table inside a slide
come out as the same elements they would outside one. The tree stays flat, the
file still reads as a normal document on GitHub, and documentToMarkdown
writes every construct back the way it was spelled.
@react-markdown-kit/slides/present replaces the article component with a
client one, and /editor adds the editor adapter, all under the same
name.
import { slides } from '@react-markdown-kit/slides'
export const deckMarkdown = defineMarkdownPreset({ extensions: [gfm(), slides()] })An extension that needs its own node in the editor uses
@react-markdown-kit/editor/lexical: block adapters and inline adapters
(mdast node in, editor node out and back), plugins that register commands,
toolbar contributions, and useLexicalEditor() for a decorator to reach the
engine.
Composition rules#
const base = defineMarkdownPreset({ extensions: [gfm()] })
const docs = defineMarkdownPreset({
extends: [base],
extensions: [externalLinks()],
})- Preset extensions come first, then extensions passed to the component.
- An extension whose
namematches an existing one replaces it in place, keeping the original position, so ordering stays stable. mergeExtensions(base, next)applies the same rule if you need it yourself.- The
profilerecorded on a compiled document isgfmwhen agfmextension is present, otherwisecommonmark.
<Markdown preset={appMarkdown} extensions={[gfm({ singleTilde: true })]}>
{source}
</Markdown>That call replaces the preset's gfm instead of adding a second one.
remark and rehype#
The existing remark and rehype ecosystem is still supported. These props are mostly there for compatibility, but they're fully supported and fine to rely on.
<Markdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{source}
</Markdown>remarkRehypeOptions reaches remark-rehype, including footnote labels and
clobber prefixes. Plugin behaviour matches react-markdown on every case in the
compatibility matrix.
There are two things worth knowing here.
A remark plugin that changes the dialect, such as remark-gfm, registers
parser extensions. Parsing happens before the render pipeline exists, so those
registrations get collected and handed to the parser. That works fine for a
string, but it can't work on an already-compiled document, since the text was
parsed before the plugin was around. Compile with the same extensions you
render with, or pass the string.
Plugins are treated as trusted application code and aren't sandboxed. The content policy still runs after them, so it filters what they produce, but the kit doesn't audit what a plugin does.
I'd use a kit extension when you want the feature to work across the renderer, the editor and variables. remark or rehype makes more sense when the feature already exists there and only rendering needs it.
Versioning#
contractVersion is 1. It only gets bumped when the adapter shapes change
incompatibly, so a host can detect an extension it can't load instead of
failing at render time. Third-party authoring isn't frozen for v1 yet, so the
adapter internals should be fairly stable but are still young. Pin your versions.
Last updated on