Markdown
Markdown renders Markdown text with raft-ui components: headings are Heading, paragraphs Paragraph, links Link, code Code, tables Table, and so on. It parses the text into a Markdown syntax tree (mdast) and renders that tree directly, with no HTML in between, so raw HTML in the source only ever shows as text.
It lives in its own entry point, so apps that don’t render Markdown don’t ship its parser:
import { Markdown } from "raft-ui/markdown";
<Markdown source={message.body} preset="compact" />;The parser is micromark with GitHub Flavored Markdown (tables, strikethrough, task lists, autolinks, footnotes), about 30 KB gzipped together with the renderer. Parsing is synchronous, so server rendering produces the full output, and the result is cached per source.
Demos
Basic
GitHub Flavored Markdown, rendered with the document preset.
Chat and documents
preset picks how the text is read and how dense it looks.
compact, for chat: every newline is a line break, indented code blocks and footnotes are off, headings get no ids, spacing is tight and headings smaller, and code blocks only float a copy button.document, the default: CommonMark as written, with heading ids, footnotes and a bar over each code block with its language and a copy button.
Set the preset for a whole app with ConfigProvider’s componentOptions={{ Markdown: { preset: "compact" } }}.
Code blocks
Code blocks are Code, so they highlight with the Shiki highlighter you give ConfigProvider (see Code). mermaid and math blocks show as code for now.
Task lists
Task items are ListItems with a small read-only Checkbox in place of the marker.
Tables
GFM tables render with Table and keep their column alignment. A wide table scrolls sideways on its own.
Alerts
GitHub alerts (> [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) render as a Banner with a localized title. The marker must be alone on the first line of the quote.
Custom components
components replaces the component for any node type. Each one gets the mdast node and props for its type (level and id for a heading, href for a link, code and language for code, …); see Node types. Changing components doesn’t parse again; define them at module scope so the rendered elements stay stable too.
Extensions
An extension adds syntax, tree transforms and components. This one adds ==highlight==. It is a micromark tokenizer, not a regular expression over the text, so == in code, in URLs and after a backslash is left alone, and the text inside is Markdown too.
Security
Raw HTML shows as its source text. Link and image URLs pass a protocol filter: http, https, mailto, relative URLs and #fragments are allowed; javascript:, vbscript:, data: and anything else are dropped, whatever their case, whitespace or character references. A link without a URL shows its text; an image shows its alt text.
Previews, plain text and outlines
MarkdownInline flattens Markdown into one run of text for previews such as a conversation list; wrap it in Ellipsis to cut it to one line. markdownToPlainText gives the text for notifications and aria-labels, and extractHeadings the outline with the ids Markdown gives the headings.
Raft refs
Raft’s references are built in: @alice, #general, task #42 and the rest of Raft’s grammar work out of the box, through raftRefExtension, one of the default extensions. raft-ui knows the syntax and how each kind of ref looks. What a ref points at (whether @alice exists, what status task #42 has, where #general goes) comes from your app.
It works in three layers, each replaceable:
- Syntax. micromark tokenizers, not regular expressions, turn refs into
raftRefnodes:kind, the canonicaltarget, the source textraw, an author’slabel, how it was written (form) and the parts of the target (name,channel,threadId,taskNumber…). The tree depends only on the text, so it is cached like any other parse. - Data.
resolveRef(ref)returns what your app knows,{ label?, href?, status?, avatarUrl?, description?, self? }, ornullfor a ref it doesn’t know, which then shows as plain text. Pass it toMarkdown, or once for the whole app asConfigProvider’scomponentOptions.Markdown.resolveRef. A new resolver renders again but doesn’t parse again. - View.
MarkdownRaftRefdraws a chip for each kind. Replace it withcomponents.raftRef.
<ConfigProvider
componentOptions={{ Markdown: { resolveRef, onRefClick, linkAs: RouterLinkAdapter } }}
>
<Markdown preset="compact" source={message.body} />
</ConfigProvider>Grammar
| Written | kind | Notes |
|---|---|---|
@alice, <@alice> | mention | Names are [\p{L}\p{N}_-]+, so @张三 works. In running text the @ can’t follow a letter, digit, _, - or /: alice@example.com, react@19.1.0, 草案@Mona and medium.com/@alice stay text. |
#general, <#general> | channel | In running text the # can’t follow an ASCII letter or digit, _, - or / (issue#12, docs#intro); Chinese text may run right up to it (看#频道). |
#general:1a2b3c4d | thread | 6–8 hex digits. |
dm:@bob, dm:@bob~agent | dm | The peer is [\w.-]+ with an optional ~agent or ~human. Another ~kind (dm:@bob~bot) is plain text, never a ref to bob. Dots at the end are punctuation. |
dm:@bob:1a2b3c4d | dmThread | |
task #42 | task | Any case, spaces or tabs between. |
#42 | task, form: "bare" | Could be a task or just a number: the resolver decides, and null keeps it text. |
#general msg=ID, #general:1a2b3c4d msg=ID | message | |
<dm:@bob msg=ID>, <dm:@bob:1a2b3c4d msg=ID> | message | Only between < and >. |
<computer:UUID> | computer | Only between < and >. |
<app:system.reminder> | app | Only between < and >. |
[label](<target>) | any | The label is plain text, shown in place of the ref’s name. |
\<target> | The target as text, brackets included. |
All of it follows Markdown: nothing turns into a ref inside code spans or code blocks (any fence), inside URLs and autolinks, or after a backslash (\@alice, \#general, \<@alice>). A ref inside a link’s text stays text. Between < and >, #7 is the channel 7.
Resolving refs
The resolver in this demo is a lookup in a few in-memory maps. onRefClick gets the ref and the click event; a chip with an href is a Link, rendered with componentOptions.Markdown.linkAs when you set it (an adapter for your router’s link, say), and one without is a button.
Unknown refs
Without a resolver, refs written between < and > show as chips with their own text (the author marked them as refs), and refs in running text stay text, since @alice could just be a word. With a resolver, whatever it returns null for is text.
What doesn't turn into a ref
The resolver below resolves every ref, so anything that turned into a ref would show as a chip.
Custom chips
components.raftRef gets the ref as node, the resolver’s answer as resolved (null: show node.value as text) and onRefClick. Hand the kinds you don’t change back to MarkdownRaftRef.
Turning refs off
Drop raftRefExtension from the defaults. extensions={() => []} is plain CommonMark, without GFM, alerts or refs.
Chips
kind | Looks like | Shows |
|---|---|---|
mention | Bold text in the link color; highlighted when self | The avatar or @, then label or the name |
channel, dm | An accent chip | # or @, then label or the name |
thread, dmThread | A neutral chip | The channel or peer, then label (the thread’s title) or its id |
message | Text in the link color | The channel or peer, then label or “message” |
task | A Tag in the status color: todo warning, in_progress info, in_review accent, done success, closed default | #N, with the status read out to screen readers; label goes in a tooltip |
computer / app | A neutral chip / text in the link color | label or the id |
An author’s [label] replaces the text of any kind. description puts a Tooltip on the chip. Boxed chips have inline code’s size and padding, so a line of chips keeps its height; their shape is in the --r-raft-ref-* tokens.
For composers
parseRef reads one ref with the same tokenizers Markdown uses; stringifyRef writes the explicit form a composer should insert.
import { parseRef, stringifyRef } from "raft-ui/markdown";
parseRef("#general:1a2b3c4d");
// { kind: "thread", channel: "general", threadId: "1a2b3c4d", target: "#general:1a2b3c4d", … }
parseRef("hello @alice"); // null: not exactly one ref
stringifyRef({ kind: "mention", name: "alice" }); // "<@alice>"
stringifyRef({ kind: "task", taskNumber: 42, label: "the sidebar" }); // "[the sidebar](<task #42>)"To list the refs in a message (who to notify, say), walk parseMarkdown(source) for raftRef nodes: code, links and escapes are already left out.
Extension API
type MarkdownExtension = {
name: string;
micromark?: MicromarkExtension[]; // syntax: tokenizers (micromark-util-types `Extension`)
fromMarkdown?: FromMarkdownExtension[]; // tokens → mdast nodes (mdast-util-from-markdown `Extension`)
transforms?: ((tree: Root, context: { preset: MarkdownPreset }) => void)[];
components?: MarkdownComponents; // renders node types, new or built-in
protocols?: string[]; // more URL schemes for links and images
};extensions={[a, b]}adds extensions after the defaults,gfmExtension,alertExtensionandraftRefExtension(defaultMarkdownExtensions).extensions={(defaults) => [...]}returns the whole list, to drop or reorder defaults;() => [mine]is CommonMark plusmine. ([]is the same as noextensions: the defaults.)- Transforms run in extension order after parsing; then the preset finishes the tree (line breaks in
compact, heading ids and footnote numbers indocument). A transform may only depend on the tree: its result is cached. - Component precedence: the
componentsprop, then extensions (later wins), then the defaults. - The parse cache is keyed by the source, the preset and the identity of each extension. Create extensions once at module scope; a new array of the same extensions is fine.
Syntax that has to interact with the rest of Markdown (not firing in code or links, escapes, nesting) belongs in a micromark extension, as in the example above. Rewriting existing nodes (for example links whose URL uses your own scheme) is a transform.
URLs and images
Every link and image URL goes through urlTransform first (if given), then the protocol filter. So a transform can turn zulip:alice into /zulip/alice, and whatever it returns is still filtered. protocols (or an extension’s protocols) allows more schemes. External links (a scheme other than mailto:) open in a new tab with rel="noopener noreferrer nofollow ugc".
Images load lazily, send no referrer and never grow wider than the text. imageTransform rewrites the source (to route images through a proxy, say); for a lightbox, replace the image component.
In document, headings get GitHub-style ids (## Getting started → getting-started) and footnotes fn-1 / fnref-1. idPrefix is put in front of all of them, and of #fragment links that point at a heading in the same text, to keep ids unique when several documents share a page. Pass the same idPrefix to extractHeadings.
Spacing
The typography components carry their own margins and drop them at the start and end of their container. Markdown adds a .r-markdown root that sets the text defaults; blocks that have no margin of their own (code, tables, alerts, footnotes) get --r-markdown-block-gap. In compact the root hands the --r-markdown-compact-* values to the components’ own tokens (--r-paragraph-margin-top, --r-heading-font-size-1, --r-list-margin …), so a component you swap in that reads those tokens follows along. Override any of them with themeOverrides.
Props
Markdown
| Name | Type | Default | Description | Version |
|---|---|---|---|---|
source | string | null | undefined | The Markdown text. | Unreleased | |
preset | 'compact' | 'document' | 'document' | Chat or document reading and density. ConfigProvider componentOptions.Markdown.preset sets the default. | Unreleased |
extensions | MarkdownExtension[] | (defaults) => MarkdownExtension[] | Extensions after the defaults, or a function from the defaults to the full list. | Unreleased | |
components | MarkdownComponents | Components by node type. | Unreleased | |
urlTransform | (url, key: 'href' | 'src', node) => string | null | undefined | Rewrites URLs before the protocol filter; null drops one. | Unreleased | |
imageTransform | (src, node) => string | null | undefined | Rewrites image sources, after urlTransform. | Unreleased | |
protocols | string[] | URL schemes to allow besides http, https and mailto. | Unreleased | |
idPrefix | string | '' | Put in front of heading and footnote ids. | Unreleased |
resolveRef | (ref: RaftRef) => RaftRefResolved | null | undefined | What your app knows about a Raft ref; null shows it as text. componentOptions.Markdown.resolveRef sets the default. | Unreleased | |
onRefClick | (ref: RaftRef, event) => void | Called when a ref chip is clicked. componentOptions.Markdown.onRefClick sets the default. | Unreleased |
Other props (className, style, data-*, translate …) go to the root div. componentOptions.Markdown.linkAs renders ref chips that have an href (for a router’s link component).
MarkdownInline takes the same props except idPrefix, and renders a span.
Functions
| Name | Signature | Description |
|---|---|---|
parseMarkdown | (source, { preset?, extensions? }) => Root | The cached mdast tree. Read it, don’t change it. |
markdownToPlainText | (source, options?) => string | Text without markup; blocks on their own lines. |
extractHeadings | (source, { extensions?, idPrefix? }) => { depth, id, text }[] | Headings with their document ids. |
sanitizeUrl | (url, protocols: Set<string>) => string | undefined | The protocol filter. |
clearMarkdownCache | () => void | Empties the parse cache. |
parseRef | (text) => RaftRef | null | One Raft ref: a target (@alice) or an explicit ref (<@alice>, [label](<@alice>)). |
stringifyRef | (ref: RaftRefTarget & { label? }) => string | The explicit form, <target> or [label](<target>). |
Node types
| Type | Default | Props besides node |
|---|---|---|
heading | Heading | level, id, children |
paragraph | Paragraph | children (no <p> in tight lists) |
link | Link | href (undefined when dropped), title, children |
strong / emphasis / delete | <strong> / <em> / <del> | children |
inlineCode | Code inline | code |
code | Code | code, language, meta, preset |
blockquote | Blockquote | children |
alert | Banner | kind, children |
list / listItem | List / ListItem | ordered, start, spread / checked |
taskCheckbox | Checkbox (small, read-only) | checked |
thematicBreak | Separator | |
table / tableRow / tableCell | Table / tr / th, td | align / header / align, header |
image | img | src (undefined when dropped), alt, title |
break | br | |
html | text | value, block |
footnoteReference / footnotes / footnoteDefinition | sup link / section / ListItem | index, href, id / labelId / id, index, backReferences |
text | plain text | value (only goes through a component if you pass one) |
raftRef | MarkdownRaftRef | resolved (null: show node.value), onRefClick |
strong, emphasis and delete are the semantic elements rather than Text, because Text sets its own size and color and these marks sit inside headings, links and alerts that set theirs.
Differences from react-markdown
There is no rehype step: the mdast tree renders directly, so rehypePlugins, allowedElements and skipHtml have no counterpart (raw HTML is always text). components is keyed by mdast node type (heading, inlineCode) instead of HTML tag names (h1, code). The parse is cached and synchronous.