跳到正文

Markdown

Markdown 用 raft-ui 的组件渲染 Markdown 文本:标题是 Heading,段落是 Paragraph,链接是 Link,代码是 Code,表格是 Table,以此类推。它先把文本解析成 Markdown 语法树(mdast),再直接渲染这棵树,中间没有 HTML 这一层,所以源文本里的原始 HTML 只会显示成文字。

它有单独的入口,不渲染 Markdown 的应用不会带上解析器:

tsx
import { Markdown } from "raft-ui/markdown"; <Markdown source={message.body} preset="compact" />;

解析器是 micromark 加 GitHub Flavored Markdown(表格、删除线、任务列表、自动链接、脚注),连同渲染部分 gzip 后约 30 KB。解析是同步的,服务端渲染能得到完整输出;解析结果按源文本缓存。

演示

基础用法

GitHub Flavored Markdown,用 document 预设渲染。

聊天与文档

preset 决定文本怎么读、排得多密。

  • compact,用于聊天:每个换行都是换行;缩进代码块和脚注关闭;标题没有 id;间距更紧、标题更小;代码块只浮一个复制按钮。
  • document,默认值:按 CommonMark 原样读;标题带 id,支持脚注;代码块上方有一条显示语言和复制按钮的标题栏。

整个应用的预设可以用 ConfigProvider 的 componentOptions={{ Markdown: { preset: "compact" } }} 统一设置。

代码块

代码块就是 Code,所以用传给 ConfigProvider 的 Shiki 高亮器高亮(见 Code)。mermaid 和 math 代码块目前按代码显示。

任务列表

任务项是 ListItem,用一个只读的小号 Checkbox 代替列表标记。

表格

GFM 表格用 Table 渲染,保留列的对齐方式。表格过宽时自己横向滚动。

提示块

GitHub 提示块(> [!NOTE]、[!TIP]、[!IMPORTANT]、[!WARNING]、[!CAUTION])渲染成带本地化标题的 Banner。标记必须单独占引用的第一行。

自定义组件

components 可以替换任意节点类型的组件。每个组件拿到 mdast 的 node 和该类型的属性(标题有 level 和 id,链接有 href,代码有 code 和 language 等),见节点类型。换 components 不会重新解析;把它们定义在模块顶层,渲染出的元素也能保持稳定。

扩展

扩展可以加语法、树变换和组件。这个例子加了 ==高亮==。它是 micromark 的分词器,不是对文本跑正则,所以代码里、URL 里和反斜杠后面的 == 都不会触发,里面的文字也照样按 Markdown 解析。

安全

原始 HTML 显示为源文本。链接和图片地址要过协议过滤:允许 http、https、mailto、相对路径和 #片段;javascript:、vbscript:、data: 以及其他协议一律丢弃,不管大小写、中间夹的空白还是字符实体。没有地址的链接只显示文字,图片显示替代文本。

预览、纯文本和大纲

MarkdownInline 把 Markdown 拍平成一行文字,给会话列表这类预览用;外面包一层 Ellipsis 就能截成一行。markdownToPlainText 给通知和 aria-label 用的纯文本,extractHeadings 给出大纲,id 和 Markdown 给标题的一致。

Raft 引用

Raft 的引用是内置的:@alice、#general、task #42 以及 Raft 的其余写法开箱即用,由默认扩展之一 raftRefExtension 提供。raft-ui 只知道语法和每种引用长什么样;引用指向什么(@alice 是否存在、task #42 是什么状态、#general 跳到哪里)由应用提供。

它分三层,每层都能替换:

  1. 语法。 由 micromark 分词器(不是正则)把引用识别成 raftRef 节点:kind、规范化的 target、源文本 raw、作者写的 label、写法 form,以及目标的各个部分(name、channel、threadId、taskNumber 等)。语法树只取决于文本,和其他解析结果一样会被缓存。
  2. 数据。 resolveRef(ref) 返回应用知道的信息 { label?, href?, status?, avatarUrl?, description?, self? };不认识的引用返回 null,显示为纯文本。可以传给 Markdown,也可以在 ConfigProvider 的 componentOptions.Markdown.resolveRef 上给整个应用配一次。换一个 resolver 会重新渲染,但不会重新解析。
  3. 显示。 MarkdownRaftRef 按类型画出标签,可以用 components.raftRef 替换。
tsx
<ConfigProvider componentOptions={{ Markdown: { resolveRef, onRefClick, linkAs: RouterLinkAdapter } }} > <Markdown preset="compact" source={message.body} /> </ConfigProvider>

写法

写法kind说明
@alice、<@alice>mention名字是 [\p{L}\p{N}_-]+,所以 @张三 也行。正文里 @ 前面不能是字母、数字、_、- 或 /:alice@example.com、react@19.1.0、草案@Mona、medium.com/@alice 都保持文字。
#general、<#general>channel正文里 # 前面不能是 ASCII 字母数字、_、- 或 /(issue#12、docs#intro);中文可以紧挨着(看#频道)。
#general:1a2b3c4dthread6–8 位十六进制。
dm:@bob、dm:@bob~agentdm对方名字是 [\w.-]+,可带 ~agent 或 ~human。其他 ~后缀(dm:@bob~bot)是纯文字,绝不会变成指向 bob 的引用。末尾的点算标点。
dm:@bob:1a2b3c4ddmThread
task #42task不分大小写,中间可以是空格或制表符。
#42task,form: "bare"可能是任务,也可能只是个数字:由 resolver 决定,返回 null 就保持文字。
#general msg=ID、#general:1a2b3c4d msg=IDmessage
<dm:@bob msg=ID>、<dm:@bob:1a2b3c4d msg=ID>message只能写在 < > 里。
<computer:UUID>computer只能写在 < > 里。
<app:system.reminder>app只能写在 < > 里。
[文字](<目标>)任意文字是纯文本,代替引用的名字显示。
\<目标>目标原样显示为文字,包括尖括号。

这些都遵循 Markdown 的规则:行内代码和代码块(任何围栏)里、URL 和自动链接里、反斜杠后面(\@alice、\#general、\<@alice>)都不会识别成引用。链接文字里的引用保持文字。在 < > 里,#7 是名叫 7 的频道。

解析引用

这个演示里的 resolver 只是在几个内存里的表中查找。onRefClick 拿到引用和点击事件;有 href 的标签是 Link,设置了 componentOptions.Markdown.linkAs 时用它渲染(比如路由链接的适配组件),没有 href 的是按钮。

不认识的引用

没有 resolver 时,写在 < > 里的引用显示成带自身文字的标签(作者明确写成了引用),正文里的引用保持文字,因为 @alice 可能只是个词。有 resolver 时,它返回 null 的都显示为文字。

不会变成引用的情况

下面的 resolver 认得所有引用,所以任何被识别成引用的地方都会显示成标签。

自定义标签

components.raftRef 拿到引用 node、resolver 的结果 resolved(为 null 时把 node.value 显示为文字)和 onRefClick。不想改的类型交回给 MarkdownRaftRef。

关闭引用

从默认扩展里去掉 raftRefExtension。extensions={() => []} 是纯 CommonMark,没有 GFM、提示块和引用。

标签样式

kind外观内容
mention链接色粗体文字;self 时高亮头像或 @,然后是 label 或名字
channel、dm强调色标签# 或 @,然后是 label 或名字
thread、dmThread中性标签频道或对方,然后是 label(thread 标题)或 id
message链接色文字频道或对方,然后是 label 或“消息”
task状态色的 Tag:todo warning、in_progress info、in_review accent、done success、closed default#N,状态读给屏幕阅读器;label 放进 Tooltip
computer / app中性标签 / 链接色文字label 或 id

作者写的 [文字] 在任何类型里都代替显示文字。description 给标签加一个 Tooltip。带框的标签和行内代码同样的字号和内边距,一行标签不会撑高行高;形状在 --r-raft-ref-* token 里。

给输入框用

parseRef 用和 Markdown 相同的分词器读一个引用;stringifyRef 写出输入框应该插入的显式写法。

ts
import { parseRef, stringifyRef } from "raft-ui/markdown"; parseRef("#general:1a2b3c4d"); // { kind: "thread", channel: "general", threadId: "1a2b3c4d", target: "#general:1a2b3c4d", … } parseRef("hello @alice"); // null:不是恰好一个引用 stringifyRef({ kind: "mention", name: "alice" }); // "<@alice>" stringifyRef({ kind: "task", taskNumber: 42, label: "the sidebar" }); // "[the sidebar](<task #42>)"

要列出一条消息里的引用(比如要通知谁),遍历 parseMarkdown(source) 找 raftRef 节点即可:代码、链接和转义已经排除在外。

扩展接口

ts
type MarkdownExtension = { name: string; micromark?: MicromarkExtension[]; // 语法:分词器(micromark-util-types 的 `Extension`) fromMarkdown?: FromMarkdownExtension[]; // token → mdast 节点(mdast-util-from-markdown 的 `Extension`) transforms?: ((tree: Root, context: { preset: MarkdownPreset }) => void)[]; components?: MarkdownComponents; // 渲染节点类型,新的或内置的都行 protocols?: string[]; // 链接和图片额外允许的协议 };
  • extensions={[a, b]} 在默认扩展 gfmExtension、alertExtension、raftRefExtension(即 defaultMarkdownExtensions)之后追加。
  • extensions={(defaults) => [...]} 返回完整列表,用来去掉或调整默认扩展;() => [mine] 就是 CommonMark 加 mine。([] 和不传 extensions 一样,即默认扩展。)
  • 解析后按扩展顺序执行 transforms,然后预设收尾(compact 处理换行,document 生成标题 id 和脚注编号)。transform 只能依赖语法树,因为结果会被缓存。
  • 组件优先级:components 属性 > 扩展(靠后的优先)> 默认组件。
  • 解析缓存的键是源文本、预设和每个扩展对象本身。扩展在模块顶层创建一次;每次传一个新数组、里面是同样的扩展没有关系。

需要和 Markdown 其余部分配合的语法(在代码和链接里不触发、支持转义、能嵌套)应该写成 micromark 扩展,就像上面的例子。改写已有节点(比如 URL 用了自定义协议的 link)用 transform 就够了。

地址与图片

每个链接和图片地址先经过 urlTransform(如果传了),再经过协议过滤。所以 transform 可以把 zulip:alice 改成 /zulip/alice,而它返回的地址照样会被过滤。protocols(或扩展的 protocols)可以放行更多协议。外链(mailto: 以外的协议)在新标签页打开,带 rel="noopener noreferrer nofollow ugc"。

图片懒加载、不发送 referrer、宽度不超过正文。imageTransform 可以改写图片地址(比如走代理);要做灯箱,替换 image 组件。

document 预设下,标题得到 GitHub 风格的 id(## Getting started → getting-started),脚注是 fn-1 / fnref-1。idPrefix 加在所有这些 id 前面,也加在指向同一文本内标题的 #片段 链接前面,用于同一页面有多篇文档时保持 id 唯一。给 extractHeadings 传同样的 idPrefix。

间距

排版组件自带上下间距,并在容器开头和结尾去掉。Markdown 加一个 .r-markdown 根节点设置文字默认值;自己没有间距的块(代码、表格、提示块、脚注)使用 --r-markdown-block-gap。compact 下根节点把 --r-markdown-compact-* 的值交给各组件自己的 token(--r-paragraph-margin-top、--r-heading-font-size-1、--r-list-margin 等),所以换进来的组件只要读这些 token 也会跟着变。都可以用 themeOverrides 覆盖。

属性

Markdown

名称类型默认值说明版本
sourcestring | null | undefinedMarkdown 文本。未发布
preset'compact' | 'document''document'聊天或文档的读法和密度。ConfigProvider 的 componentOptions.Markdown.preset 设置默认值。未发布
extensionsMarkdownExtension[] | (defaults) => MarkdownExtension[]追加在默认扩展之后的扩展,或从默认扩展得到完整列表的函数。未发布
componentsMarkdownComponents按节点类型指定的组件。未发布
urlTransform(url, key: 'href' | 'src', node) => string | null | undefined在协议过滤前改写地址;返回 null 丢弃。未发布
imageTransform(src, node) => string | null | undefined改写图片地址,在 urlTransform 之后。未发布
protocolsstring[]除 http、https、mailto 外额外允许的协议。未发布
idPrefixstring''加在标题和脚注 id 前面。未发布
resolveRef(ref: RaftRef) => RaftRefResolved | null | undefined应用对某个 Raft 引用知道的信息;返回 null 显示为文字。componentOptions.Markdown.resolveRef 设置默认值。未发布
onRefClick(ref: RaftRef, event) => void点击引用标签时调用。componentOptions.Markdown.onRefClick 设置默认值。未发布

其他属性(className、style、data-*、translate 等)传给根节点 div。componentOptions.Markdown.linkAs 用来渲染带 href 的引用标签(比如路由的链接组件)。

MarkdownInline 的属性相同(没有 idPrefix),渲染一个 span。

函数

名称签名说明
parseMarkdown(source, { preset?, extensions? }) => Root缓存的 mdast 树。只读,不要修改。
markdownToPlainText(source, options?) => string去掉标记的文本,每个块一行。
extractHeadings(source, { extensions?, idPrefix? }) => { depth, id, text }[]标题及其在 document 下的 id。
sanitizeUrl(url, protocols: Set<string>) => string | undefined协议过滤。
clearMarkdownCache() => void清空解析缓存。
parseRef(text) => RaftRef | null读一个 Raft 引用:目标(@alice)或显式写法(<@alice>、[文字](<@alice>))。
stringifyRef(ref: RaftRefTarget & { label? }) => string显式写法,<目标> 或 [文字](<目标>)。

节点类型

类型默认渲染node 之外的属性
headingHeadinglevel、id、children
paragraphParagraphchildren(紧凑列表里不包 <p>)
linkLinkhref(被丢弃时为 undefined)、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(小号、只读)checked
thematicBreakSeparator
table / tableRow / tableCellTable / tr / th、tdalign / header / align、header
imageimgsrc(被丢弃时为 undefined)、alt、title
breakbr
html文字value、block
footnoteReference / footnotes / footnoteDefinitionsup 链接 / section / ListItemindex、href、id / labelId / id、index、backReferences
text纯文本value(只有传了组件才会经过组件)
raftRefMarkdownRaftRefresolved(为 null 时显示 node.value)、onRefClick

strong、emphasis、delete 用语义元素而不是 Text:Text 会设置自己的字号和颜色,而这些标记出现在标题、链接、提示块里,应该沿用外层的字号和颜色。

与 react-markdown 的区别

没有 rehype 这一步:mdast 树直接渲染,所以没有 rehypePlugins、allowedElements、skipHtml 的对应项(原始 HTML 永远是文字)。components 按 mdast 节点类型(heading、inlineCode)而不是 HTML 标签名(h1、code)取键。解析有缓存,且是同步的。