Markdown
Markdown 用 raft-ui 的组件渲染 Markdown 文本:标题是 Heading,段落是 Paragraph,链接是 Link,代码是 Code,表格是 Table,以此类推。它先把文本解析成 Markdown 语法树(mdast),再直接渲染这棵树,中间没有 HTML 这一层,所以源文本里的原始 HTML 只会显示成文字。
它有单独的入口,不渲染 Markdown 的应用不会带上解析器:
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 跳到哪里)由应用提供。
它分三层,每层都能替换:
- 语法。 由 micromark 分词器(不是正则)把引用识别成
raftRef节点:kind、规范化的target、源文本raw、作者写的label、写法form,以及目标的各个部分(name、channel、threadId、taskNumber等)。语法树只取决于文本,和其他解析结果一样会被缓存。 - 数据。
resolveRef(ref)返回应用知道的信息{ label?, href?, status?, avatarUrl?, description?, self? };不认识的引用返回null,显示为纯文本。可以传给Markdown,也可以在ConfigProvider的componentOptions.Markdown.resolveRef上给整个应用配一次。换一个 resolver 会重新渲染,但不会重新解析。 - 显示。
MarkdownRaftRef按类型画出标签,可以用components.raftRef替换。
<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:1a2b3c4d | thread | 6–8 位十六进制。 |
dm:@bob、dm:@bob~agent | dm | 对方名字是 [\w.-]+,可带 ~agent 或 ~human。其他 ~后缀(dm:@bob~bot)是纯文字,绝不会变成指向 bob 的引用。末尾的点算标点。 |
dm:@bob:1a2b3c4d | dmThread | |
task #42 | task | 不分大小写,中间可以是空格或制表符。 |
#42 | task,form: "bare" | 可能是任务,也可能只是个数字:由 resolver 决定,返回 null 就保持文字。 |
#general msg=ID、#general:1a2b3c4d msg=ID | message | |
<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 写出输入框应该插入的显式写法。
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 节点即可:代码、链接和转义已经排除在外。
扩展接口
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
| 名称 | 类型 | 默认值 | 说明 | 版本 |
|---|---|---|---|---|
source | string | null | undefined | Markdown 文本。 | 未发布 | |
preset | 'compact' | 'document' | 'document' | 聊天或文档的读法和密度。ConfigProvider 的 componentOptions.Markdown.preset 设置默认值。 | 未发布 |
extensions | MarkdownExtension[] | (defaults) => MarkdownExtension[] | 追加在默认扩展之后的扩展,或从默认扩展得到完整列表的函数。 | 未发布 | |
components | MarkdownComponents | 按节点类型指定的组件。 | 未发布 | |
urlTransform | (url, key: 'href' | 'src', node) => string | null | undefined | 在协议过滤前改写地址;返回 null 丢弃。 | 未发布 | |
imageTransform | (src, node) => string | null | undefined | 改写图片地址,在 urlTransform 之后。 | 未发布 | |
protocols | string[] | 除 http、https、mailto 外额外允许的协议。 | 未发布 | |
idPrefix | string | '' | 加在标题和脚注 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 之外的属性 |
|---|---|---|
heading | Heading | level、id、children |
paragraph | Paragraph | children(紧凑列表里不包 <p>) |
link | Link | href(被丢弃时为 undefined)、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(小号、只读) | checked |
thematicBreak | Separator | |
table / tableRow / tableCell | Table / tr / th、td | align / header / align、header |
image | img | src(被丢弃时为 undefined)、alt、title |
break | br | |
html | 文字 | value、block |
footnoteReference / footnotes / footnoteDefinition | sup 链接 / section / ListItem | index、href、id / labelId / id、index、backReferences |
text | 纯文本 | value(只有传了组件才会经过组件) |
raftRef | MarkdownRaftRef | resolved(为 null 时显示 node.value)、onRefClick |
strong、emphasis、delete 用语义元素而不是 Text:Text 会设置自己的字号和颜色,而这些标记出现在标题、链接、提示块里,应该沿用外层的字号和颜色。
与 react-markdown 的区别
没有 rehype 这一步:mdast 树直接渲染,所以没有 rehypePlugins、allowedElements、skipHtml 的对应项(原始 HTML 永远是文字)。components 按 mdast 节点类型(heading、inlineCode)而不是 HTML 标签名(h1、code)取键。解析有缓存,且是同步的。