Rust in Everything
基于 pulldown-cmark 0.13.5

本站的 Markdown 渲染

主线位置:输出主线的替身。块主线和行内主线原样用 pulldown-cmark 的(Parser::new_ext 加七个选项);输出主线不走 push_html,而是本站 mdx.rs 的 render_stream:把事件流递归地还原成一棵 Dioxus 元素树。这一页看这条替身线在每一种事件上做了什么,和 html.rs 有什么不同。

flowchart TB
  md["Markdown 组件"] --> un{"untrusted"}
  un -->|是| san["sanitize_user_html"]
  un -->|否| fm["parse_mdx 切 frontmatter"]
  san --> fm
  fm --> adm["convert_admonitions"]
  adm --> pe["parse_events 收进 Vec"]
  pe --> tr{"untrusted"}
  tr -->|否| ids["assign_heading_ids"]
  tr -->|是| rs["render_stream"]
  ids --> rs
  rs --> cb["代码块 图片 表头"]
  rs --> rt["render_tag"]
  rs --> leaf["文本 公式 HTML"]

代码在框架仓库的 crates/widgets:mdx.rs(1313 行,第 924 行起是单元测试)和 sanitize.rs(569 行)。下面按 main 分支的 363dd8f 讲,站点代码是 2 空格缩进。Cargo.toml 写的是 pulldown-cmark = { version = "0.13.0", features = ["html"] },lock 解析到 0.13.4。默认 feature 本来就含 html,而整个工作区没有一处调 pulldown_cmark::html,所以 html 模块和 pulldown-cmark-escape 编进来了却没有用到;simd 没开。

实验的做法:把 mdx.rs 和 sanitize.rs 原样复制进一个独立的 crate(exp-pulldown-cmark/site-render),mdx.rs 末尾只追加三个 pub fn,把私有的 parse_mdx 加 convert_admonitions、parse_events、assign_heading_ids 露出来;它调用的 browser、seo、registry 三个模块换成桩(PageTitle 输出一个带 data-page-title 的 span,注册表里只有 YouTube)。渲染用 dioxus-ssr 0.7.9 的 render_element,和本站单元测试的做法相同;线上 HTML 由 dioxus-server 的渲染器输出,还带水合标记,见水合。同一份源码编两次,pulldown-cmark 分别锁 =0.13.5(site-render)和 =0.13.4(site-render134)。平台 M2 Max、macOS 27、rustc 1.99.0,release 构建。下面贴的 HTML 去掉了 Tailwind 的 class 属性,否则太长;其余照原样。

0.13.4 和 0.13.5

写这一页时本站 main 锁 0.13.4,这一卷基于 0.13.5。两版之间摘进来的 8 个 PR 列在怎么读这一卷。对照本站开的七个选项:wikilink 的 #1102、#1111 和定义列表的 #1143 碰不到(这两个扩展没开),#1046 只改 fuzz 目标。按 PR 的说明,会改变解析结果、又不依赖扩展的只有 #1021:链接引用定义后面跟一个全是空格的行,下一行再接段落。用它做对照组,同一个输入在两个版本上的事件和本站输出:

== site-render134
  Start(Paragraph)
  HardBreak
  Text(Borrowed(":"))
  End(Paragraph)
<div><p id="b1" data-block-id="b1"><br/>:</p></div>
== site-render
  Start(Paragraph)
  Text(Borrowed(":"))
  End(Paragraph)
<div><p id="b1" data-block-id="b1">:</p></div>

(输入是 [a]: /url、一行四个空格、一行 :,即 #1021 加进 specs/regression.txt 的那条例子。)0.13.4 把那行空格当成了段落的一部分,段首多出一个硬换行。

然后在本站内容上比。语料是内容仓库 ed676e8 里纳入版本管理的全部 1116 篇 Markdown,逐篇按本站的流程预处理(切掉 frontmatter、改写 :::),用本站的七个选项解析,把每个事件的 Debug 输出一行写进文件,两个版本各写一份:

  716929 ev134.txt
  716929 ev135.txt
 1433858 total
IDENTICAL

(cmp 比的是整个文件的字节;上面那条 #1021 的输入用同样的方法比,两份文件差一行 HardBreak,说明这个比法能发现差异。)1116 篇的事件流逐篇相同,本站内容在两个版本上渲染出的页面一样。解析时间在最后一节。

七个 Options

parse_events 是本站唯一构造 Parser 的地方:

fn parse_events(body: &str) -> Vec<Event<'_>> {
  let mut options = Options::empty();
  options.insert(Options::ENABLE_TABLES);
  options.insert(Options::ENABLE_FOOTNOTES);
  options.insert(Options::ENABLE_STRIKETHROUGH);
  options.insert(Options::ENABLE_TASKLISTS);
  options.insert(Options::ENABLE_MATH);
  options.insert(Options::ENABLE_GFM);
  options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
  Parser::new_ext(body, options).collect()
}

每一位打开什么见怎么读这一卷的 Options 表。放到本站的渲染器上看,七位的处境各不相同(「语料」一列是 1116 篇里的计数):

选项本站拿它做什么语料
ENABLE_TABLES表格,表头渲染成 th;对齐丢掉584 篇 1227 个表格,1 篇 2 个带对齐
ENABLE_FOOTNOTES解析出脚注,但渲染时引用被丢掉0
ENABLE_STRIKETHROUGH~~删除~~ 渲染成 del—
ENABLE_TASKLISTS解析出任务标记,但渲染时被丢掉0
ENABLE_MATH$...$、$$...$$ 交给 pulldown-latex 转 MathML0
ENABLE_GFM引用块提示,::: 改写之后靠它认出来712 篇 1702 个
ENABLE_HEADING_ATTRIBUTES标题后的 {#id} 作锚点,类名和属性不用0

计数 0 的几项不是没用:内容仓库只是这一卷所在的文档库,博客、评论、论坛帖子是另外的输入。ENABLE_GFM 在 0.13.5 里只打开提示块(容器块的「GFM 提示块」一节),本站开它就是为了这一样。没开的几位里,元数据块(ENABLE_YAML_STYLE_METADATA_BLOCKS)本站自己切;智能标点、定义列表、上下标、wikilink 都没开。没开 ENABLE_SUBSCRIPT 时,单个波浪线的 ~x~ 也按删除线解析(Tag::Strikethrough 下面 Subscript 的文档注释这么写)。

任务列表和脚注两位是开了反而丢内容的,后面单独讲。

进解析器之前:frontmatter 和 :::

parse_mdx 只看原文是不是以 --- 开头,是就 content.splitn(3, "---"),中间一段交给 serde_yaml,第三段 trim 之后当正文。pulldown-cmark 有现成的元数据块选项,它按行认 --- 和结束的 --- 或 ...;本站的切法按子串找,碰到的第一个 --- 不论在不在行首都算结束。frontmatter 里写 title: A---B 时:

<span data-page-title="A - SITE"></span><div><h2 id="b" data-block-id="b1">B</h2><p id="b2" data-block-id="b2">正文</p></div>

标题被截成 A,剩下的 B 和真正的结束行 --- 落进正文,组成一个 Setext 二级标题。语料里没有 frontmatter 含 --- 的篇目。

convert_admonitions 把 Docusaurus 式的提示块改写成 GFM 的写法,逐行处理:

  • 一行去掉首尾空白后以 ::: 开头、后面跟一个词:按词映射成 NOTE(note、info)、TIP(tip、success)、IMPORTANT、WARNING(warning、warn)、CAUTION(caution、danger、error),不认识的词也当 NOTE;这一行换成 > [!KIND]。
  • 块内每一行前面加 > ;单独一行 ::: 结束,换成一个空行,引用块到此为止。
  • 围栏代码块里的行原样保留:它自己数开头的反引号或波浪线,遇到同字符、不短于开头的围栏才算结束。rustc 报错里的 ::: src/x.rs:1:1 就不会被当成提示块。
:::tip           ->   > [!TIP]
提示正文          ->   > 提示正文
:::              ->   (空行)

改写后的文本交给 pulldown-cmark,ENABLE_GFM 认出 [!TIP],产出 Tag::BlockQuote(Some(BlockQuoteKind::Tip)),render_blockquote 按五种类型配图标和颜色。语料里 1702 个提示块全是最简单的形式::::note 1138 个、:::important 500 个、:::tip 64 个,没有标题、没有嵌套。这两步预处理加起来不便宜,在最后一节的计时里约是 pulldown-cmark 解析时间的一半。

事件怎么变成 RSX

parse_events 把事件 collect 成 Vec<Event>,每个事件 80 字节(事件模型)。不边拉边渲染,是因为 assign_heading_ids 要先往后看标题里的文字、再回头改写 Start(Tag::Heading) 里的 id;pull parser 的事件只能往前拉,要回看就得自己缓冲(改写事件流)。

然后 render_stream 拿着这个 Vec 的 IntoIter 递归:

  while let Some(event) = it.next() {
    match event {
            Event::Start(Tag::CodeBlock(kind)) => {
                // ...
            }
            Event::Start(Tag::Image { dest_url, title, .. }) => {
                // ...
            }
            Event::Start(Tag::TableHead) => {
                // ...
            }
            Event::Start(tag) => {
                let id = mint_block_id(top, block_idx);
                let children = render_stream(it, blog_id, untrusted, block_idx, false);
                nodes.push(render_tag(tag, children, untrusted, id));
            }
            Event::End(_) => break,
            Event::Text(text) => nodes.push(rsx! { "{text}" }),
            // ... Code、InlineMath、DisplayMath、SoftBreak、HardBreak、Rule、Html
            _ => {}
        }
  }

(match 体在原文件里就是这样多缩进了一层;三个特殊臂各自往后读到配对的 End,下面分别讲。)一个 Start 开一层递归,递归在遇到第一个 End 时返回收集到的子元素,render_tag 再按标签把它们包起来。这就是用事件流重建树:pulldown-cmark 的事件保证 Start 和 End 成对、正确嵌套,所以 End(_) 不用看是哪个标签的结束。顶层(top == true)每开一个块,mint_block_id 发一个 b1、b2……,写进 id 和 data-block-id,站内的标注功能靠它定位段落。

render_tag 认识的标签有:标题、段落、两种列表、列表项、链接、引用块、表格、表格行、单元格、加粗、强调、删除线;其余一律 _ => span,在本站的七个选项下会走到这里的只有脚注定义和 HTML 块。事件那一侧,match 的最后一臂 _ => {} 接住的是 FootnoteReference 和 TaskListMarker,它们没有任何输出。

📝NOTE
冷知识:TagEnd::BlockQuote 带不带提示类型,在两个补丁版本之间来回了一次

提示块进来的时候(#869,0.11.0),类型只挂在 Tag::BlockQuote(Option<BlockQuoteKind>) 上,结束事件还是不带数据的 TagEnd::BlockQuote。#926 的提交 51a2d5e「Add BlockQuoteKind to BlockQuote TagEnd」让结束事件也带上类型,首次发布于 0.11.1。这改了一个公开枚举变体的形状,写 TagEnd::BlockQuote => 的代码在补丁升级后编译不过,于是 72c022a「fix: revert TagEnd::BlockQuote kind to avoid breaking change」在 0.11.2 把它撤了回去,第二天 #940 的 8de2cde「feat: re-add kind for BlockQuote in TagEnd」在 0.12.0 这个不兼容版本里重新加上,就是今天 TagEnd 里的 BlockQuote(Option<BlockQuoteKind>)。本站的 render_stream 只写 Event::End(_),提示类型从 Start 那一侧取,这类结束事件的变化碰不到它。

标题与锚点

assign_heading_ids 只对站点作者的内容运行。有 {#id}(ENABLE_HEADING_ATTRIBUTES 解析出的 Tag::Heading 的 id)就用它;没有就把 Start 和 End(Heading) 之间所有 Text 和 Code 的内容拼起来,交给 slugify:转小写,只留字母、数字、-、_ 和空格,空格换成 -。这是 github-slugger 的规则,和 Docusaurus 生成的锚点一致,汉字算字母,所以 ## 引言 的 id 就是 引言;重名依次加 -1、-2。heading_outline 给页内目录用,走同样的预处理和分配,只取二、三级标题。

渲染时(render_tag 的 Tag::Heading 臂)有三条规则:

  • 不可信内容的一级标题降成二级,页面只留自己的 h1;也不用 {#id},id 退回 bN。花括号本身仍被解析器吃掉,不会显示。
  • 级别只分三档:1 => h1、2 => h2、_ => h3,四到六级标题都渲染成 h3。语料里没有四级以下的标题。
  • 类名和其他属性({.cls key=val})解析出来了,没有用。

叶子块讲过一个解析器的小毛病:ATX 标题以行内标记结尾、后面再跟结尾的 # 时,事件流里多一个空的 Text。拼接锚点文字时它贡献一个空串,结果不变;渲染时它变成一个空文本节点,看不见。语料里没有这样写的标题。

❗IMPORTANT
动脑筋:## 标题 *强调* ## 在本站渲染出的 id 是什么?

站点作者内容,没有 {#id}。答案在文末。

代码块与 mermaid

Start(Tag::CodeBlock(kind)) 那一臂自己往后读,把 Text 拼成整段代码,读到 End 为止。语言取 CodeBlockKind::Fenced 的整个信息串,缩进代码块记作 text;信息串不拆,写成 rust,ignore 时 class 就是 language-rust,ignore,Prism 认不出(语料里没有带空格或逗号的信息串)。

信息串恰好是 mermaid、又是站点作者内容时(renders_as_diagram),输出 pre.mermaid,否则输出带 Copy 按钮的 CodeBlock 组件。两者都把代码经 escape_markup(只换 &、<、>)转义后写进 dangerous_inner_html,因为页面上的 Prism 和 mermaid 会改写这些节点,Dioxus 不能跟踪它们的子节点。语料里 1099 篇有 1161 个 mermaid 块。不可信内容里的 mermaid 只当普通代码显示。

图片与 alt

Start(Tag::Image) 那一臂也自己读到配对的 End,中间的 Text 和 Code 拼成 alt,换行变空格,嵌套的强调之类只取文字;title 放进 figcaption。![架构 *总览*](a.png "图注") 的事件和本站输出:

  Start(Image { link_type: Inline, dest_url: Borrowed("a.png"), title: Boxed("图注"), id: Borrowed("") })
  Text(Borrowed("架构 "))
  Start(Emphasis)
  Text(Borrowed("总览"))
  End(Emphasis)
  End(Image)
...
<p id="b5" data-block-id="b5"><figure><img src="/posts/t/a.png" alt="架构 总览" loading="lazy" decoding="async"/><figcaption>图注</figcaption></figure></p>

push_html 的 alt 也是 架构 总览:HtmlWriter::raw_text 同样只写文字。相对路径按 blog_id 拼到 /posts/<id>/ 下;不可信内容只放行 /uploads/ 下本站上传的图片。

图片在 Markdown 里是行内元素,通常在一个段落里,所以输出是 p 里套 figure。HTML 解析器不允许这样嵌套:把「一段文字、一张图」两段的本站输出交给 Chrome,--dump-dom 得到的是 <p id="b2" data-block-id="b2"></p><figure>...</figure><p></p>,段落被提前关掉,figure 变成它的兄弟,后面再多一个空段落。行内的 $$...$$ 公式(div)和注册组件同样会落进 p。语料里没有图片和公式,碰到这种情况的是博客和评论。

表格

Start(Tag::TableHead) 那一臂把表头的单元格渲染成 th(表头里的单元格直接挂在 TableHead 下,没有 TableRow),其余单元格走 render_tag 成 td。Tag::Table 带的对齐数组(Tag::Table,存在哪见表格与脚注)被 Tag::Table(_) 丢掉了,th 的样式固定是左对齐。| :-- | :-: | --: | 三列,push_html 和本站的输出:

<table><thead><tr><th style="text-align: left">左</th><th style="text-align: center">中</th><th style="text-align: right">右</th></tr></thead><tbody>
<tr><td style="text-align: left">a</td><td style="text-align: center">b</td><td style="text-align: right">c</td></tr>
</tbody></table>
...
<table id="b3" data-block-id="b3"><thead><tr><th>左</th><th>中</th><th>右</th></tr></thead><tr><td>a</td><td>b</td><td>c</td></tr></table>

push_html 在 start_tag 里按列号给每个单元格写 style(HtmlWriter::start_tag),本站两处都没有;本站也不写 tbody,由浏览器补。语料里只有一篇用了对齐:这一卷的一次解析分配了什么,两个表格的数字列写了右对齐,在本站显示成左对齐。

数学

InlineMath 和 DisplayMath 只带 TeX 原文($ 的规则见数学与智能标点)。push_html 把它转义后放进 span.math,留给前端的 KaTeX 之类;本站在服务端用 pulldown-latex 0.7.1 直接转成 MathML(latex_to_mathml_string),写进 dangerous_inner_html。pulldown-latex 会把 \text{}、\operatorname{} 里的文字和解析错误信息原样写进 MathML,所以本站先把这几类事件的字符串和错误信息转义一遍再交给它;转换失败时退回 <code>。行内公式是 span.math-inline,$$ 公式是一个 div。

HTML 事件

pulldown-cmark 把 HTML 块的每一行产出成一个 Event::Html,行内的标签产出 Event::InlineHtml,push_html 原样写出(HtmlWriter::run 里的 Html(html) | InlineHtml(html) => self.write(&html))。本站的这一臂只认两种:

  1. 去掉首尾空白后是 <br>、<br/>、<br />(不分大小写):渲染成 br,表格单元格里要换行只能这么写。
  2. 以 < 开头、标签名大写开头,并且在注册表里查得到(<YouTube id="..." />、<AudioPlayer ... /> 之类):交给注册的组件渲染。组件跨行写也行:<YouTube 单独一行时第一行不是完整的开始标签,不开 HTML 块,整段成了段落里的一个跨行的 InlineHtml。

其余所有 HTML 都放进一个 span 当文字,Dioxus 会转义它。所以作者写的 <details>、<kbd>、<!-- 注释 --> 在本站页面上原样显示成尖括号文字:

<span><span>&#60;!-- 注释 --&#62;</span></span><span><span>&#60;details&#62;</span><span>&#60;summary&#62;展开&#60;/summary&#62;</span></span><p id="b8" data-block-id="b8">内容</p><span><span>&#60;/details&#62;</span></span><p id="b10" data-block-id="b10">行内 <span>&#60;kbd&#62;</span>Ctrl<span>&#60;/kbd&#62;</span> 键,...</p>

外层的 span 来自 Tag::HtmlBlock 落到 render_tag 的 _ => span;顶层的 HTML 块也领了一个 bN(上面缺 b6、b7、b9),但 span 不带 id。

这条规则在语料里还救了几处。1116 篇里一共 6 个 InlineHtml,在 5 篇里,全是 <T>、<i64> 这样的泛型参数,来路有两种。3 个在引用的英文提交说明里,原文就把 Vec<Option<T>> 这样的类型写在正文中。另外 3 个本该在代码段里:作者在代码段里用反斜杠转义反引号,而 CommonMark 的代码段里反斜杠不起作用,代码段在那个反引号处提前结束,后面的 Option<i64> 落到正文,<i64> 被认成一个行内 HTML 标签。用 push_html 渲染,这些泛型参数会作为未知标签从页面上消失;本站把它们当文字显示,读者看到的仍是 <i64>,后一种只是代码段的边界错了。

任务列表与脚注

这两种扩展的事件在 render_stream 里没有着落。输入:

- [x] 已完成
- [ ] 未完成

正文[^1]。

[^1]: 脚注内容。

事件流和 push_html 的输出(同样的选项):

  Start(List(None))
  Start(Item)
  TaskListMarker(true)
  Text(Borrowed("已完成"))
  End(Item)
  Start(Item)
  TaskListMarker(false)
  Text(Borrowed("未完成"))
  End(Item)
  End(List(false))
  Start(Paragraph)
  Text(Borrowed("正文"))
  FootnoteReference(Borrowed("1"))
  Text(Borrowed("。"))
  End(Paragraph)
  Start(FootnoteDefinition(Borrowed("1")))
  Start(Paragraph)
  Text(Borrowed("脚注内容。"))
  End(Paragraph)
  End(FootnoteDefinition)
...
<ul>
<li><input disabled="" type="checkbox" checked=""/>
已完成</li>
<li><input disabled="" type="checkbox"/>
未完成</li>
</ul>
<p>正文<sup class="footnote-reference"><a href="#1">1</a></sup>。</p>
<div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup>
<p>脚注内容。</p>
</div>

本站的输出:

<div><ul id="b1" data-block-id="b1"><li>已完成</li><li>未完成</li></ul><p id="b2" data-block-id="b2">正文。</p><span>脚注内容。</span></div>
  • TaskListMarker 落进 _ => {}:复选框没了,勾没勾上也看不出来。
  • FootnoteReference 同样落进 _ => {}:正文里的上标编号消失,正文[^1]。 成了 正文。。
  • Tag::FootnoteDefinition 落进 render_tag 的 _ => span:脚注正文还在,但成了一段没有编号、没有锚点的文字;里面的段落不在顶层、只有一个子节点,p 也被省掉了。它领了 b3,span 却不带 id。

把这两位关掉会怎样?去掉 ENABLE_TASKLISTS,[x] 就是普通文字(Text("[")、Text("x")、Text("]")),至少原样显示出来,比现在被吞掉强。去掉 ENABLE_FOOTNOTES 却更糟:[^1]: 脚注内容。 成了一条链接引用定义,[^1] 成了指向 脚注内容。 这个地址的快捷引用链接。开着脚注、补上渲染才是出路:push_html 的做法(引用按首次出现编号,链接到同名定义的 id)在 HTML 写出器。

不可信内容

untrusted: true 的调用有五处:评论区的预览和列表(crates/app/src/components/comment.rs),论坛的帖子、回复和发帖预览(crates/modules/forum/src/forum.rs)。pulldown-cmark 自己不做任何净化,Event::Html 原样交给调用方(转义)。本站的防线大部分落在事件这一层,前面几节已经出现过:

  • HTML 事件默认当文字显示,只放行 br 和注册组件;
  • 链接经 is_safe_link_url 查协议,只放行 http、https、mailto、tel 和没有协议的地址,不合格的链接只留文字;
  • 图片只放行 /uploads/ 下的上传文件,mermaid 不画图,一级标题降级,不用自定义 id。

查协议时,pulldown-cmark 已经替调用方解开了一层实体和反斜杠:[a](j&#x61;vascript:alert(1)) 的 dest_url 是 Boxed("javascript:alert(1)")。is_safe_link_url 在这之上再解一遍实体、删掉空白和控制字符,j&amp;#x61;vascript: 这种两层编码到它手里是 j&#x61;vascript:,也会被拒。

另外一层是 sanitize_user_html,在 parse_mdx 之前对原文做字符串处理:删掉 script、iframe、object、embed、style 的整块(从开始标签到配对的结束标签,找不到结束标签就只删到第一个 >),再删掉所有 on 开头、后面跟 = 的属性(strip_on_event_attrs:前一个字节是空白或引号,on 后跟字母)。它在解析之前运行,看不见 Markdown 的结构,不知道哪里是代码。模块注释说代码块和反引号里的代码不受影响,实测不是这样:

== s1 (trusted)
<div><p id="b1" data-block-id="b1">let one=1;</p></div>
== s1 (untrusted)
<div><p id="b1" data-block-id="b1">let</p></div>
== s2 (trusted)
<div><div id="b1" data-block-id="b1"><button>Copy</button><pre><code>&lt;script src="a.js"&gt;&lt;/script&gt;
</code></pre></div></div>
== s2 (untrusted)
<div><div id="b1" data-block-id="b1"><button>Copy</button><pre><code>
</code></pre></div></div>
== s3 (trusted)
<div><div id="b1" data-block-id="b1"><button>Copy</button><pre><code>const s = "&lt;script&gt;";
</code></pre></div><p id="b2" data-block-id="b2">结尾<span>&#60;/script&#62;</span></p></div>
== s3 (untrusted)
<div><div id="b1" data-block-id="b1"><button>Copy</button><pre><code>const s = "
</code></pre></div></div>

三个输入:s1 是正文 let one=1;,one=1; 被当成 on 开头的事件属性删掉;s2 是一个 html 代码块,里面一行 <script src="a.js"></script>,整行被删;s3 是 js 代码块里有字符串 "<script>",块外的正文里有一个 </script>,两者之间的一切被删掉,包括结束围栏,代码块一直开到文末,后面的正文没了。在评论里贴前端代码,就会碰上 s2 和 s3。

这几样在事件流里本来是分开的:代码在 Code 和 CodeBlock 里的 Text 里,标签在 Html 和 InlineHtml 里。pulldown-cmark 把「这段文字是不是 HTML」的判断做完了,交给调用方的是已经分好类的事件;本站的事件层已经把所有 HTML 当文字显示,字符串层的剥除在前面做了一遍更粗的同一件事。

📝NOTE
没有蠢问题

问: 为什么不直接 push_html 出一个字符串,再整段塞进 dangerous_inner_html?代码会少很多。

答: 两个原因都在这一页的代码里。第一,注册组件和代码块都是带状态的 Dioxus 组件:CodeBlock 的 Copy 按钮用 use_signal 记「已复制」,<YouTube> 由注册表渲染成组件,它们没法活在一个 HTML 字符串里。第二,push_html 把 Event::Html 原样写出,信任边界就成了整个字符串;走 RSX 时每个 Text 都由 Dioxus 转义,HTML 默认是文字,只有 dangerous_inner_html 的四处(代码、mermaid、两种公式)需要自己转义,要审计的地方一眼能数清。代价是这一页列出的那些缺口:push_html 处理了的任务列表、脚注、对齐,本站都要自己补。

解析在渲染里占多少

用同一份语料计时:先把 1116 篇全部读进内存,分别计四件事,每件重复 20 次取最小值(整页渲染 5 次取最小):「预处理」是 parse_mdx 加 convert_admonitions;「解析」是 parse_events,即 Parser::new_ext(...).collect();「解析加锚点」再加 assign_heading_ids;「整页 SSR」是 dioxus_ssr::render_element 渲染 Markdown 组件,包括前面所有步骤。两个版本交替跑三轮,1 分钟负载 4.5 左右(其他写手的实验在并行跑),原始输出:

site-render134: docs 1116 body bytes 18268208 events 715813 | parse 48.26 ms | parse+ids 51.92 ms | prepare 24.65 ms | full SSR 384.12 ms
site-render: docs 1116 body bytes 18268208 events 715813 | parse 47.73 ms | parse+ids 51.69 ms | prepare 24.45 ms | full SSR 385.39 ms
site-render134: docs 1116 body bytes 18268208 events 715813 | parse 48.08 ms | parse+ids 51.69 ms | prepare 24.73 ms | full SSR 383.02 ms
site-render: docs 1116 body bytes 18268208 events 715813 | parse 47.61 ms | parse+ids 51.56 ms | prepare 25.47 ms | full SSR 385.82 ms
site-render134: docs 1116 body bytes 18268208 events 715813 | parse 48.02 ms | parse+ids 51.76 ms | prepare 25.01 ms | full SSR 383.02 ms
site-render: docs 1116 body bytes 18268208 events 715813 | parse 47.68 ms | parse+ids 51.56 ms | prepare 24.85 ms | full SSR 387.40 ms
  • 解析 18.3 MB、71.6 万个事件约 48 ms,0.13.5 三轮都比 0.13.4 少 0.4 到 0.5 ms,不到 1%,在这种负载下算不上差别。整页 SSR 两版也在 1% 以内。
  • 解析占整页 SSR 的八分之一左右(47.7 / 385)。预处理约 25 ms,是解析的一半,包括 YAML 反序列化和逐行改写 :::。锚点分配 4 ms。剩下的约 300 ms 花在建 RSX 元素和写出 HTML 字符串上。
  • 这是 dioxus-ssr 的数字,不是线上的 dioxus-server(多写水合标记),桩换掉的 PageTitle 和注册表也比真的简单。比例可以参考,绝对值不代表线上。

动脑筋答案

## 标题 *强调* ## 的事件和本站输出:

  Start(Heading { level: H2, id: None, classes: [], attrs: [] })
  Text(Borrowed("标题 "))
  Start(Emphasis)
  Text(Borrowed("强调"))
  End(Emphasis)
  Text(Borrowed(""))
  End(Heading(H2))
<div><h2 id="标题-强调" data-block-id="b1">标题 <em>强调</em></h2></div>

id 是 标题-强调。结尾的 ## 被去掉之后,最后一个文本节点成了空串,所以 End(Emphasis) 后面跟着 Text(Borrowed(""))。assign_heading_ids 拼出 标题 、强调、空串,得到 标题 强调,slugify 把空格换成 -;强调标记不是文字,不进锚点。空 Text 对锚点没有影响。

小结

  • 本站只用 pulldown-cmark 的解析器,开七个选项,事件收进 Vec 后由 render_stream 递归还原成 Dioxus 元素树;html feature 编进来了但没有用。
  • 解析之前本站自己切 frontmatter(按子串找 ---)、把 ::: 改写成 > [!KIND],靠 ENABLE_GFM 认成提示块;这两步约是解析时间的一半。
  • 锚点按 github-slugger 从 Text 和 Code 拼出,标题末尾的空 Text 不影响;四到六级标题渲染成 h3。
  • 丢掉的有:表格对齐、任务列表标记、脚注引用(脚注定义成了一段无编号文字)。图片和行内公式在 p 里输出成 figure、div,浏览器会把段落拆开。
  • HTML 事件默认当文字显示,只放行 br 和注册组件;sanitize_user_html 在解析前按字符串剥标签,会删掉代码里的 <script> 和 one= 这样的片段。
  • 本站内容 1116 篇在 0.13.4 和 0.13.5 上事件流逐篇相同;解析约 48 ms,两版差不到 1%,占整页 SSR 的八分之一左右。

💬 关联讨论

发起讨论
加载中...