Skip to main content

yggdrasil/utils/
html.rs

1//! HTML 转义工具(零依赖纯函数,前端后端通用)。
2//!
3//! 仓库内原先存在两份 `escape_html` 实现:
4//! - `utils::html::escape_html`(`'` → `'`)
5//! - `api::comments::helpers::escape_html`(`'` → `'`,server-only)
6//! 现统一到本模块,单引号采用 HTML5 标准的 `'`。
7
8/// 转义 HTML 特殊字符:`& < > " '`。
9///
10/// 单引号统一为 `&#x27;`(HTML5 规范,与原 server 端 `helpers::escape_html` 一致)。
11/// 可安全用于文本节点与属性值上下文。
12///
13/// 单遍扫描:旧实现链式调用 5 次 `str::replace`,每次全量扫描 + 分配一个新 String,
14/// 5 个特殊字符意味着 5 次堆分配 + 5 遍扫描。单遍 `match` 只扫描一次、只分配一次。
15/// 该函数被 markdown 渲染管线密集调用(每个标题、每个代码块),属于热点路径。
16pub fn escape_html(input: &str) -> String {
17    // 快速路径:无特殊字符直接 clone(大多纯文本走这里,零额外扫描)。
18    // memchr 风格的逐字节查找比总是分配 + 遍历更省。
19    if !input
20        .as_bytes()
21        .iter()
22        .any(|&b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
23    {
24        return input.to_string();
25    }
26    let mut out = String::with_capacity(input.len());
27    for c in input.chars() {
28        match c {
29            '&' => out.push_str("&amp;"),
30            '<' => out.push_str("&lt;"),
31            '>' => out.push_str("&gt;"),
32            '"' => out.push_str("&quot;"),
33            '\'' => out.push_str("&#x27;"),
34            _ => out.push(c),
35        }
36    }
37    out
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn escapes_all_five_special_chars() {
46        assert_eq!(escape_html("&<>\"'"), "&amp;&lt;&gt;&quot;&#x27;");
47    }
48
49    #[test]
50    fn escapes_ampersand_first_to_avoid_double_escape() {
51        // & 必须先转义,否则后续引入的 &amp; 会被再次处理。
52        assert_eq!(escape_html("<&>"), "&lt;&amp;&gt;");
53    }
54
55    #[test]
56    fn leaves_plain_text_untouched() {
57        assert_eq!(escape_html("hello world"), "hello world");
58    }
59
60    #[test]
61    fn empty_input_returns_empty() {
62        assert_eq!(escape_html(""), "");
63    }
64}