Skip to content

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:

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

  1. Syntax. micromark tokenizers, not regular expressions, turn refs into raftRef nodes: kind, the canonical target, the source text raw, an author’s label, 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.
  2. Data. resolveRef(ref) returns what your app knows, { label?, href?, status?, avatarUrl?, description?, self? }, or null for a ref it doesn’t know, which then shows as plain text. Pass it to Markdown, or once for the whole app as ConfigProvider’s componentOptions.Markdown.resolveRef. A new resolver renders again but doesn’t parse again.
  3. View. MarkdownRaftRef draws a chip for each kind. Replace it with components.raftRef.
tsx
<ConfigProvider componentOptions={{ Markdown: { resolveRef, onRefClick, linkAs: RouterLinkAdapter } }} > <Markdown preset="compact" source={message.body} /> </ConfigProvider>

Grammar

WrittenkindNotes
@alice, <@alice>mentionNames 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>channelIn 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:1a2b3c4dthread6–8 hex digits.
dm:@bob, dm:@bob~agentdmThe 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:1a2b3c4ddmThread
task #42taskAny case, spaces or tabs between.
#42task, form: "bare"Could be a task or just a number: the resolver decides, and null keeps it text.
#general msg=ID, #general:1a2b3c4d msg=IDmessage
<dm:@bob msg=ID>, <dm:@bob:1a2b3c4d msg=ID>messageOnly between < and >.
<computer:UUID>computerOnly between < and >.
<app:system.reminder>appOnly between < and >.
[label](<target>)anyThe 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

kindLooks likeShows
mentionBold text in the link color; highlighted when selfThe avatar or @, then label or the name
channel, dmAn accent chip# or @, then label or the name
thread, dmThreadA neutral chipThe channel or peer, then label (the thread’s title) or its id
messageText in the link colorThe channel or peer, then label or “message”
taskA 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 / appA neutral chip / text in the link colorlabel 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.

ts
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

ts
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, alertExtension and raftRefExtension (defaultMarkdownExtensions).
  • extensions={(defaults) => [...]} returns the whole list, to drop or reorder defaults; () => [mine] is CommonMark plus mine. ([] is the same as no extensions: the defaults.)
  • Transforms run in extension order after parsing; then the preset finishes the tree (line breaks in compact, heading ids and footnote numbers in document). A transform may only depend on the tree: its result is cached.
  • Component precedence: the components prop, 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

NameTypeDefaultDescriptionVersion
sourcestring | null | undefinedThe Markdown text.Unreleased
preset'compact' | 'document''document'Chat or document reading and density. ConfigProvider componentOptions.Markdown.preset sets the default.Unreleased
extensionsMarkdownExtension[] | (defaults) => MarkdownExtension[]Extensions after the defaults, or a function from the defaults to the full list.Unreleased
componentsMarkdownComponentsComponents by node type.Unreleased
urlTransform(url, key: 'href' | 'src', node) => string | null | undefinedRewrites URLs before the protocol filter; null drops one.Unreleased
imageTransform(src, node) => string | null | undefinedRewrites image sources, after urlTransform.Unreleased
protocolsstring[]URL schemes to allow besides http, https and mailto.Unreleased
idPrefixstring''Put in front of heading and footnote ids.Unreleased
resolveRef(ref: RaftRef) => RaftRefResolved | null | undefinedWhat your app knows about a Raft ref; null shows it as text. componentOptions.Markdown.resolveRef sets the default.Unreleased
onRefClick(ref: RaftRef, event) => voidCalled 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

NameSignatureDescription
parseMarkdown(source, { preset?, extensions? }) => RootThe cached mdast tree. Read it, don’t change it.
markdownToPlainText(source, options?) => stringText 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 | undefinedThe protocol filter.
clearMarkdownCache() => voidEmpties the parse cache.
parseRef(text) => RaftRef | nullOne Raft ref: a target (@alice) or an explicit ref (<@alice>, [label](<@alice>)).
stringifyRef(ref: RaftRefTarget & { label? }) => stringThe explicit form, <target> or [label](<target>).

Node types

TypeDefaultProps besides node
headingHeadinglevel, id, children
paragraphParagraphchildren (no <p> in tight lists)
linkLinkhref (undefined when dropped), title, children
strong / emphasis / delete<strong> / <em> / <del>children
inlineCodeCode inlinecode
codeCodecode, language, meta, preset
blockquoteBlockquotechildren
alertBannerkind, children
list / listItemList / ListItemordered, start, spread / checked
taskCheckboxCheckbox (small, read-only)checked
thematicBreakSeparator
table / tableRow / tableCellTable / tr / th, tdalign / header / align, header
imageimgsrc (undefined when dropped), alt, title
breakbr
htmltextvalue, block
footnoteReference / footnotes / footnoteDefinitionsup link / section / ListItemindex, href, id / labelId / id, index, backReferences
textplain textvalue (only goes through a component if you pass one)
raftRefMarkdownRaftRefresolved (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.