Skip to main content

yggdrasil/api/
markdown.rs

1//! Markdown 渲染与目录生成。
2//!
3//! 使用 pulldown-cmark 解析 Markdown,为标题生成锚点与目录(TOC),
4//! 代码块调用 `highlight` 模块进行语法高亮,最终通过 sanitizer 清理 HTML。
5//! 仅在 `feature = "server"` 时执行实际渲染。
6
7#![allow(clippy::unused_unit, deprecated)]
8
9#[cfg(feature = "server")]
10/// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。
11pub(crate) fn clean_html(input: &str) -> String {
12    crate::api::sanitizer::clean_html(input)
13}
14
15#[cfg(feature = "server")]
16/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
17///
18/// 复用 `utils::html::escape_html`(转义 `& < > " '`),避免在仓库内
19/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如
20/// `" onmouseover="alert(1)` 会越出属性边界。
21fn escape_heading_text(s: &str) -> String {
22    crate::utils::html::escape_html(s)
23}
24
25#[derive(Debug, Clone)]
26#[cfg(feature = "server")]
27/// Markdown 渲染结果。
28pub struct RenderedContent {
29    /// 清理后的正文 HTML。
30    pub html: String,
31    /// 目录 HTML(无标题时为空字符串)。
32    pub toc_html: String,
33}
34
35#[cfg(feature = "server")]
36/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。
37pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
38    use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
39    use std::fmt::Write as _;
40
41    // 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对
42    // Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。
43    //
44    // 脚注模式:pulldown-cmark 的 ENABLE_OLD_FOOTNOTES = (1<<9)|(1<<2),它把
45    // ENABLE_FOOTNOTES 的 bit 也打进了 OLD 的位掩码里。Options::all() 同时置两者,
46    // 使 has_gfm_footnotes()(=ENABLE_FOOTNOTES && !ENABLE_OLD_FOOTNOTES)返回 false,
47    // 走 OLD 模式(续行宽松、label 可含换行)。我们想要 GFM 模式(与 GitHub 一致、
48    // 解析可控),所以不能简单地 remove(OLD)——那会连 ENABLE_FOOTNOTES 一起清掉。
49    // 正确做法:先 remove(OLD)(清掉 bit 9 + bit 2),再 insert(ENABLE_FOOTNOTES)
50    // 单独把 bit 2 加回,使 has_gfm_footnotes() = true。
51    let mut opts = Options::all();
52    opts.remove(Options::ENABLE_OLD_FOOTNOTES);
53    opts.insert(Options::ENABLE_FOOTNOTES);
54
55    // pulldown-cmark 只解析一次,collect 成 Vec<Event> 后两遍遍历复用。
56    // 旧实现对同一份 md 调用两次 Parser::new_ext,等于两倍的 tokenize + 解析 CPU。
57    // Event 内含 CowStr(借用 md 切片),collect 后仍可重复借用。
58    let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
59
60    // 1. 第一遍:收集标题(level, text, id),用 iter() 借用不消费 events。
61    // (level, text, id)
62    let mut headings: Vec<(u8, String, String)> = Vec::new();
63    let mut current_heading: Option<(u8, String)> = None;
64
65    // 脚注引用统计:label → (引用次数, 首次出现序号)。
66    // pulldown-cmark 不保证定义移到文末、也不保证 ref 先于 def,唯一可靠不变量是
67    // 每个唯一 label 的 FootnoteDefinition 只出现一次、FootnoteReference 每次引用触发一次。
68    // 所以 back-link 必须按 label 关联,display_num 按 label 首次出现顺序分配。
69    // fn_order 记录 label 首次出现顺序,用于分配稳定的显示编号(1, 2, 3…)。
70    use std::collections::HashMap;
71    let mut fn_refs: HashMap<String, usize> = HashMap::new();
72    let mut fn_order: Vec<String> = Vec::new();
73
74    for event in &events {
75        match event {
76            Event::Start(Tag::Heading { level, .. }) => {
77                let lvl = match level {
78                    HeadingLevel::H1 => 1,
79                    HeadingLevel::H2 => 2,
80                    HeadingLevel::H3 => 3,
81                    HeadingLevel::H4 => 4,
82                    HeadingLevel::H5 => 5,
83                    HeadingLevel::H6 => 6,
84                };
85                current_heading = Some((lvl, String::new()));
86            }
87            Event::Text(text) => {
88                if let Some((_, ref mut content)) = current_heading {
89                    content.push_str(text);
90                }
91            }
92            Event::Code(code) => {
93                if let Some((_, ref mut content)) = current_heading {
94                    content.push_str(code);
95                }
96            }
97            Event::End(TagEnd::Heading(_)) => {
98                if let Some((lvl, text)) = current_heading.take() {
99                    let id = slugify_heading(&text);
100                    headings.push((lvl, text, id));
101                }
102            }
103            // 统计脚注引用:仅对 FootnoteReference 计数(含未被定义的悬空引用)。
104            // 悬空引用([^missing] 无定义)也会产生此事件,但第二遍不会有对应 def,
105            // fn_refs 里的条目无害(查不到对应 def 时第二遍不会输出 back-link)。
106            Event::FootnoteReference(name) => {
107                let label = name.to_string();
108                let count = fn_refs.entry(label.clone()).or_insert(0);
109                *count += 1;
110                if *count == 1 {
111                    fn_order.push(label);
112                }
113            }
114            _ => {}
115        }
116    }
117
118    // 按 label 首次出现顺序分配显示编号(1-based)。脚注定义内查此表取 display_num。
119    let fn_num: HashMap<&String, usize> = fn_order
120        .iter()
121        .enumerate()
122        .map(|(i, label)| (label, i + 1))
123        .collect();
124
125    // 2. Generate TOC HTML
126    let toc_html = generate_toc_html(&headings);
127
128    // 3. 第二遍:生成 HTML,用 into_iter() 消费 events(非标题事件需 move 进 push_html)。
129    // HTML 输出通常比 md 长(标签包裹),按 md 长度 + 256 预分配,避免 String::new 的多次 realloc。
130    let mut html = String::with_capacity(md.len() + 256);
131    let mut heading_idx = 0;
132    let mut in_heading = false;
133    let mut in_codeblock = false;
134    let mut code_lang: Option<String> = None;
135    // 可运行代码块的 (lang, html-escaped overrides JSON)。
136    // 为 None 表示普通代码块。原始源码在 End 处从 code_buffer 转义后存入 data-source,
137    // 供阅读器无损提取(避免从高亮 HTML 反解)。
138    let mut code_runnable: Option<(String, String)> = None;
139    let mut code_buffer = String::new();
140    let mut non_heading_events: Vec<Event> = Vec::new();
141    // 第二遍维护的脚注引用计数:label → 已渲染的引用序号(从 1 起)。
142    // 用于给每个 ref 分配 id 后缀 fnref:{label}-{n},并让 def 末尾的 back-link 对应到每个 ref。
143    let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
144    // 脚注定义栈:Start(FootnoteDefinition) 压入 label,End 弹出。
145    // 脚注定义可嵌套(def 内引用另一个脚注),用栈保证 End 配对到正确的 label。
146    let mut fn_def_stack: Vec<String> = Vec::new();
147
148    for event in events {
149        match event {
150            Event::Start(Tag::Heading { level, .. }) => {
151                // 先把累积的普通事件刷入 HTML,再开始新标题。
152                if !non_heading_events.is_empty() {
153                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
154                    non_heading_events = Vec::new();
155                }
156                in_heading = true;
157                if heading_idx < headings.len() {
158                    let (_, _, ref id) = headings[heading_idx];
159                    let tag = match level {
160                        HeadingLevel::H1 => "h1",
161                        HeadingLevel::H2 => "h2",
162                        HeadingLevel::H3 => "h3",
163                        HeadingLevel::H4 => "h4",
164                        HeadingLevel::H5 => "h5",
165                        HeadingLevel::H6 => "h6",
166                    };
167                    // write! 直写目标 String,零中间分配(format! 会先分配临时 String 再 push_str)。
168                    let _ = write!(html, "<{tag} id=\"{id}\">");
169                }
170            }
171            Event::End(TagEnd::Heading(level)) => {
172                if heading_idx < headings.len() {
173                    let (_, _, ref id) = headings[heading_idx];
174                    let tag = match level {
175                        HeadingLevel::H1 => "h1",
176                        HeadingLevel::H2 => "h2",
177                        HeadingLevel::H3 => "h3",
178                        HeadingLevel::H4 => "h4",
179                        HeadingLevel::H5 => "h5",
180                        HeadingLevel::H6 => "h6",
181                    };
182                    let _ = write!(
183                        html,
184                        "<a class=\"anchor\" aria-hidden=\"true\" href=\"#{id}\">#</a></{tag}>"
185                    );
186                    heading_idx += 1;
187                }
188                in_heading = false;
189            }
190            Event::Start(Tag::CodeBlock(kind)) => {
191                // 代码块开始前同样先刷入未处理的普通事件。
192                if !non_heading_events.is_empty() {
193                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
194                    non_heading_events = Vec::new();
195                }
196                in_codeblock = true;
197                code_lang = match kind {
198                    pulldown_cmark::CodeBlockKind::Fenced(lang) => {
199                        if lang.is_empty() {
200                            None
201                        } else {
202                            Some(lang.to_string())
203                        }
204                    }
205                    _ => None,
206                };
207                // 解析围栏 info:识别 `runnable` 标记与可选 ResourceLimits JSON 覆盖。
208                // 仅在「标记为 runnable 且语言受支持」时挂 data-*,供阅读器扫描挂载运行器。
209                code_runnable = code_lang.as_deref().and_then(|info| {
210                    let (lang, runnable, overrides) =
211                        crate::api::code_runner::languages::parse_fence_info(info);
212                    if runnable && crate::api::code_runner::languages::is_supported_lang(&lang) {
213                        // overrides 序列化为 JSON 后 HTML 转义,避免属性注入。
214                        let ov_json = overrides
215                            .map(|o| serde_json::to_string(&o).unwrap_or_default())
216                            .unwrap_or_default();
217                        let overrides_escaped = crate::utils::html::escape_html(&ov_json);
218                        Some((lang, overrides_escaped))
219                    } else {
220                        None
221                    }
222                });
223                code_buffer.clear();
224            }
225            Event::Text(text) if in_codeblock => {
226                code_buffer.push_str(&text);
227            }
228            Event::End(TagEnd::CodeBlock) => {
229                // mermaid 代码块:前端 yggdrasil-core 扫描 language-mermaid 渲染成 SVG,
230                // 源码不应被 syntect 高亮(无语法定义,且会包 <span> 污染 textContent 提取)。
231                // 直接输出转义后的纯源码,前端 textContent 无损拿到原始 mermaid 文本。
232                let is_mermaid = code_lang
233                    .as_deref()
234                    .map(|l| l.split_whitespace().next() == Some("mermaid"))
235                    .unwrap_or(false);
236                if is_mermaid {
237                    let escaped = crate::utils::html::escape_html(&code_buffer);
238                    html.push_str(r#"<pre><code class="language-mermaid">"#);
239                    html.push_str(&escaped);
240                    html.push_str("</code></pre>");
241                    in_codeblock = false;
242                    continue;
243                }
244                // 使用 syntect 对代码块进行服务端语法高亮。
245                let highlighted =
246                    crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
247                // 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides / data-source,
248                // 阅读者(post_content.rs)客户端扫描这些标记原地挂载 CodeRunner 组件。
249                // data-source 为 HTML 转义后的原始源码,供阅读器无损提取(避免反解高亮 HTML)。
250                if let Some((lang, overrides_escaped)) = code_runnable.take() {
251                    let source_escaped = crate::utils::html::escape_html(&code_buffer);
252                    let _ = write!(
253                        html,
254                        r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}" data-source="{source_escaped}"><code class="language-{lang}">"#
255                    );
256                } else {
257                    html.push_str("<pre><code");
258                    if let Some(lang) = &code_lang {
259                        // 围栏语言可能含 info 修饰(如 `python runnable {...}`),
260                        // 高亮的 language-xxx 取纯语言 token(首个空白前)。
261                        let clean_lang = lang.split_whitespace().next().unwrap_or("");
262                        if !clean_lang.is_empty() {
263                            let _ = write!(html, " class=\"language-{clean_lang}\"");
264                        }
265                    }
266                    html.push('>');
267                }
268                html.push_str(&highlighted);
269                html.push_str("</code></pre>");
270                in_codeblock = false;
271            }
272            Event::InlineMath(tex) => {
273                // 先刷出累积的普通事件,再注入 KaTeX 渲染结果。
274                if !non_heading_events.is_empty() {
275                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
276                    non_heading_events = Vec::new();
277                }
278                // 标题内的公式按内联渲染(标题不应用块级公式)。
279                html.push_str(&crate::api::katex::render_inline(&tex));
280            }
281            Event::DisplayMath(tex) => {
282                if !non_heading_events.is_empty() {
283                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
284                    non_heading_events = Vec::new();
285                }
286                // 块级公式独占一行:用 <p class="math-display"> 包裹 KaTeX 输出。
287                // KaTeX 自身已产出 .katex-display,外层 <p> 负责段间距与居中容器。
288                html.push_str("<p class=\"math-display\">");
289                html.push_str(&crate::api::katex::render_display(&tex));
290                html.push_str("</p>");
291            }
292            Event::FootnoteReference(name) => {
293                // 先刷出累积的普通事件,再注入脚注引用标记。
294                if !non_heading_events.is_empty() {
295                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
296                    non_heading_events = Vec::new();
297                }
298                let label = name.to_string();
299                // 本 label 的第 n 次引用(1-based),用于 id 后缀。
300                let n = {
301                    let entry = fn_ref_seen.entry(label.clone()).or_insert(0);
302                    *entry += 1;
303                    *entry
304                };
305                let id = footnote_id(&label);
306                // display_num:label 首次出现顺序编号;悬空引用(无 def)查不到时回退到引用序号 n。
307                let num = fn_num.get(&label).copied().unwrap_or(n);
308                // 上标引用:id 供 back-link 回跳,href 跳到定义,role=doc-noteref 语义化。
309                let _ = write!(
310                    html,
311                    r##"<sup class="fn-ref" id="fnref:{id}-{n}"><a href="#fn:{id}" class="fn-ref-link" role="doc-noteref" aria-label="脚注 {num}">{num}</a></sup>"##
312                );
313            }
314            Event::Start(Tag::FootnoteDefinition(name)) => {
315                // 脚注定义开始:先刷出累积的普通事件,再用 <aside> 开启语义化容器。
316                if !non_heading_events.is_empty() {
317                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
318                    non_heading_events = Vec::new();
319                }
320                let label = name.to_string();
321                let id = footnote_id(&label);
322                let num = fn_num.get(&label).copied().unwrap_or_else(|| {
323                    // 定义未被任何引用提及(悬空定义):用 fn_order 长度+1 兜底编号。
324                    fn_order.len() + 1
325                });
326                // <aside role="doc-footnote">:取 pulldown-cmark 默认 <div> 的语义升级。
327                // aria-labelledby 指向 label 的 sup,让屏幕阅读器朗读「脚注 N」。
328                let _ = write!(
329                    html,
330                    r##"<aside class="footnote-definition" id="fn:{id}" role="doc-footnote" aria-labelledby="fn:{id}-label"><sup class="footnote-definition-label" id="fn:{id}-label">{num}</sup> "##
331                );
332                fn_def_stack.push(label);
333            }
334            Event::End(TagEnd::FootnoteDefinition) => {
335                // 脚注定义结束:先刷出累积的脚注正文事件(定义内部的段落/列表等),
336                // 再按引用次数输出 N 个 back-link(↩、↩²、↩³…),最后闭合 <aside>。
337                if !non_heading_events.is_empty() {
338                    pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
339                    non_heading_events = Vec::new();
340                }
341                // 弹栈取当前定义的 label,配对 Start。
342                if let Some(label) = fn_def_stack.pop() {
343                    let id = footnote_id(&label);
344                    let ref_count = fn_refs.get(&label).copied().unwrap_or(0);
345                    // back-link 上标符号序列:1 个引用用 ↩;N 个引用用 ↩¹ ↩²…(首个不加数字)。
346                    // 每个链接指向对应引用位置 fnref:{id}-{n},role=doc-backlink 语义化。
347                    // ref_count=0(悬空定义)时不输出 back-link(无引用可回跳)。
348                    if ref_count > 0 {
349                        // 首个 back-link:裸 ↩。
350                        let _ = write!(
351                            html,
352                            r##"<a href="#fnref:{id}-1" class="fn-backref" role="doc-backlink" aria-label="返回正文">↩</a>"##
353                        );
354                        // 从第 2 个引用起加数字上标。
355                        for n in 2..=ref_count {
356                            let _ = write!(
357                                html,
358                                r##" <a href="#fnref:{id}-{n}" class="fn-backref" role="doc-backlink" aria-label="返回正文 {n}">↩<sup class="fn-backref-num">{n}</sup></a>"##
359                            );
360                        }
361                    }
362                }
363                html.push_str("</aside>");
364            }
365            _ => {
366                if in_heading {
367                    // 标题内部只保留文本与行内代码,避免嵌套块级元素。
368                    match event {
369                        Event::Text(text) => html.push_str(&clean_html(&text)),
370                        Event::Code(code) => {
371                            html.push_str("<code>");
372                            html.push_str(&clean_html(&code));
373                            html.push_str("</code>");
374                        }
375                        _ => {}
376                    }
377                } else if !in_codeblock {
378                    non_heading_events.push(event);
379                }
380            }
381        }
382    }
383
384    // Flush remaining non-heading events
385    if !non_heading_events.is_empty() {
386        pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
387    }
388
389    let html = wrap_images_with_blur(&html);
390    // 表格外层套可滚动容器,移动端窄屏可横向滚动而不被外层 overflow-hidden 裁切。
391    // sanitizer 不放行 table 的 class/style,但 div 在白名单、class 属全局属性,故包裹 div。
392    let html = wrap_tables(&html);
393    RenderedContent {
394        html: clean_html(&html),
395        toc_html,
396    }
397}
398
399/// 把 HTML 里的 /uploads/ 图片转成 blur-up 双层结构。
400///
401/// 仅处理 src 以 /uploads/ 开头的 img;外链图保持原样。
402/// 对每个匹配的 img:
403/// 1. 提取 src,解析出 rel_path(去 /uploads/ 前缀和 query)
404/// 2. 查 get_image_dimensions 拿真实宽高,算 --ar(如 "16:9")
405/// 3. 生成 `<span class="blur-img" style="--ar:..">` 包裹两层 img
406#[cfg(feature = "server")]
407fn wrap_images_with_blur(html: &str) -> String {
408    wrap_images_with_blur_with(html, crate::api::image::get_image_dimensions)
409}
410
411/// `wrap_images_with_blur` 的纯函数核心,接受 dimensions 查询闭包以便单测。
412///
413/// `dims_fn(rel_path) -> Option<(u32, u32)>` 注入真实或测试用的 dimensions 来源,
414/// 使本函数不依赖文件系统——测试可注入已知宽高,生产注入 get_image_dimensions。
415#[cfg(feature = "server")]
416fn wrap_images_with_blur_with<F>(html: &str, dims_fn: F) -> String
417where
418    F: Fn(&str) -> Option<(u32, u32)>,
419{
420    use regex::Regex;
421    use std::sync::LazyLock;
422
423    // 匹配 pulldown-cmark 产出的 <img src="..." alt="..." /> 或 <img src="..." alt="...">
424    // pulldown-cmark 格式可控:src 在前,alt 在后,属性用双引号
425    static IMG_RE: LazyLock<Regex> = LazyLock::new(|| {
426        Regex::new(r#"<img\s+src="(/uploads/[^"]+)"(?:\s+alt="([^"]*)")?\s*/?>"#)
427            .expect("IMG_RE 正则模式应在编译期通过校验")
428    });
429
430    IMG_RE
431        .replace_all(html, |caps: &regex::Captures| {
432            let src = caps.get(1).map(|m| m.as_str()).unwrap_or("");
433            let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
434
435            // 从 src 解析 rel_path:去 /uploads/ 前缀 + 去 query
436            let rel_path = src
437                .strip_prefix("/uploads/")
438                .unwrap_or(src)
439                .split('?')
440                .next()
441                .unwrap_or("");
442
443            // 查 dimensions,算 aspect-ratio
444            // 注意:CSS aspect-ratio 用斜杠分隔(width / height),不是冒号
445            let ar_style = dims_fn(rel_path)
446                .map(|(w, h)| format!(" style=\"--ar:{} / {};\"", w, h))
447                .unwrap_or_default();
448
449            // alt 转义(src/alt 来自 markdown,pulldown-cmark 已转义过,这里直接用)
450            let alt_attr = if alt.is_empty() {
451                String::new()
452            } else {
453                format!(" alt=\"{}\"", alt)
454            };
455
456            format!(
457                "<span class=\"blur-img\"{ar}><img class=\"blur-img-placeholder\" src=\"{src}?w=20\"{alt_attr}><img class=\"blur-img-full\" data-src=\"{src}?w=800\"{alt_attr}></span>",
458                ar = ar_style,
459                src = src,
460                alt_attr = alt_attr,
461            )
462        })
463        .to_string()
464}
465
466#[cfg(feature = "server")]
467/// 把每个 `<table>...</table>` 包进可横向滚动的 `<div class="table-wrap">`。
468///
469/// 移动端窄屏下宽表格无法横向滚动:pulldown-cmark 产出裸 table,外层 `<main>` 又是
470/// `overflow-hidden` 会把超宽内容直接裁掉。这里给 table 套一层 `table-wrap`(CSS 配
471/// `overflow-x: auto`),让表格在容器内滚动而非撑破页面。
472///
473/// 正则匹配 pulldown-cmark 输出的 `<table ...>...</table>`(非贪婪、跨行),包裹整个
474/// table 标签。对已包裹的 HTML 幂等(不会二次嵌套)——`table-wrap` div 内的 table
475/// 仍会被正则命中并再次包裹,故调用方仅在渲染管线调用一次。
476fn wrap_tables(html: &str) -> String {
477    use regex::Regex;
478    use std::sync::LazyLock;
479
480    static TABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
481        Regex::new(r"(?s)<table(\s[^>]*)?>.*?</table>")
482            .expect("TABLE_RE 正则模式应在编译期通过校验")
483    });
484
485    TABLE_RE
486        .replace_all(html, |caps: &regex::Captures| {
487            format!("<div class=\"table-wrap\">{}</div>", &caps[0])
488        })
489        .to_string()
490}
491
492#[cfg(feature = "server")]
493/// 根据标题层级生成嵌套目录 HTML。
494fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
495    use std::fmt::Write as _;
496
497    if headings.is_empty() {
498        return String::new();
499    }
500
501    // TOC 大小按标题数估算(每个 li+a 约 64 字节起),避免 String::new 的多次 realloc。
502    let mut html = String::with_capacity(headings.len() * 64 + 16);
503    html.push_str("<ul>");
504    let mut stack: Vec<u8> = vec![headings[0].0];
505
506    for (i, (level, text, id)) in headings.iter().enumerate() {
507        let level = *level;
508
509        if i > 0 {
510            let prev_level = headings[i - 1].0;
511            if level > prev_level {
512                // 标题层级升高:打开新的嵌套列表。
513                for _ in prev_level..level {
514                    html.push_str("<ul>");
515                    stack.push(level);
516                }
517            } else if level < prev_level {
518                // 标题层级降低:关闭多余的嵌套列表。
519                while let Some(top) = stack.last() {
520                    if *top > level {
521                        html.push_str("</li></ul>");
522                        stack.pop();
523                    } else {
524                        break;
525                    }
526                }
527                html.push_str("</li>");
528            } else {
529                html.push_str("</li>");
530            }
531        }
532
533        // 标题 text 是 pulldown-cmark 收集的纯文本(Text/Code 字面字符),不是 HTML 片段,
534        // 因此正文与属性两处都走 escape_heading_text(转义 & < > " ')。原先用 clean_html
535        // 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。
536        let escaped_text = escape_heading_text(text);
537        let _ = write!(
538            html,
539            "<li><a href=\"#{id}\" aria-label=\"{escaped_text}\">{escaped_text}</a>"
540        );
541    }
542
543    // Close remaining lists
544    // 闭合所有残留的嵌套列表。
545    while stack.len() > 1 {
546        html.push_str("</li></ul>");
547        stack.pop();
548    }
549    html.push_str("</li></ul>");
550
551    html
552}
553
554#[cfg(feature = "server")]
555/// 将标题文本转换为可用于锚点的 slug。
556///
557/// 汉字转无声调拼音(每字成词,用 `-` 分隔),与 [`crate::api::slug::slugify`]
558/// 保持一致;ASCII 字母数字保留,其余字符作为词分隔。结果为空时回退 `heading`。
559fn slugify_heading(text: &str) -> String {
560    use pinyin::ToPinyin;
561
562    let mut slug = String::new();
563    let mut prev_dash = true;
564
565    for c in text.to_lowercase().chars() {
566        // 汉字优先转拼音;非汉字(含 ascii)返回 None。
567        // 每个汉字成词,拼音后补 `-` 与后续内容分隔(连续汉字也会被分开)。
568        if let Some(py) = c.to_pinyin() {
569            slug.push_str(py.plain());
570            slug.push('-');
571            prev_dash = true;
572        } else if c.is_alphanumeric() {
573            slug.push(c);
574            prev_dash = false;
575        } else if !prev_dash {
576            slug.push('-');
577            prev_dash = true;
578        }
579    }
580
581    if slug.ends_with('-') {
582        slug.pop();
583    }
584
585    if slug.is_empty() {
586        slug.push_str("heading");
587    }
588
589    slug
590}
591
592#[cfg(feature = "server")]
593/// 将脚注 label 转换为可用于 HTML id / href 锚点的安全标识符。
594///
595/// 与 `slugify_heading` 不同:脚注 label 是用户自定义的稳定引用键(`[^key]`),
596/// **不应**转拼音或回退通用词——需保留 label 原文形态以保证 ref↔def 双向一致、可读。
597/// 策略:ASCII 字母数字与 `-`/`_` 保留;其余 ASCII 字符(空格、标点、引号)转 `-`;
598/// 非 ASCII(中文、emoji 等)保留原样。合并连续 `-`、去首尾 `-`。
599///
600/// GFM 模式下 label 单行不含换行,但仍可能含空格/标点,此函数确保产出的 id:
601/// (1) 不含 `"`/`<`/`>`/`&`/空格等破坏 HTML 属性或 URL 的字符;(2) 同一 label 必产出同一 id。
602fn footnote_id(label: &str) -> String {
603    let mut out = String::with_capacity(label.len());
604    let mut prev_dash = true; // true = 当前不应再输出 `-`(开头或上一字符已是 `-`)
605
606    for c in label.chars() {
607        if c.is_ascii_alphanumeric() || c == '_' {
608            out.push(c);
609            prev_dash = false;
610        } else if !c.is_ascii() {
611            // 非 ASCII(中文等)直接保留——id 允许任意非空白字符。
612            out.push(c);
613            prev_dash = false;
614        } else if !prev_dash {
615            // 其余 ASCII 字符(空格、标点、引号)转 `-`。
616            out.push('-');
617            prev_dash = true;
618        }
619    }
620
621    // 去除尾部可能的 `-`(prev_dash 合并已保证首部无 `-`)。
622    while out.ends_with('-') {
623        out.pop();
624    }
625
626    if out.is_empty() {
627        // label 全为特殊字符的极端情况,给一个确定性回退。
628        out.push_str("fn");
629    }
630
631    out
632}
633
634#[cfg(all(test, feature = "server"))]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn wrap_images_with_blur_wraps_uploads_image() {
640        // 注入返回 None 的 dims_fn,验证 --ar 缺省时的结构正确性。
641        // 不依赖 uploads/ 文件系统。
642        let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#;
643        let result = wrap_images_with_blur_with(html, |_| None);
644        assert!(
645            result.contains("blur-img-placeholder"),
646            "should have placeholder"
647        );
648        assert!(result.contains("blur-img-full"), "should have full layer");
649        assert!(result.contains("?w=20"), "placeholder should use ?w=20");
650        assert!(result.contains("?w=800"), "full should use ?w=800");
651        assert!(result.contains("data-src"), "full should use data-src");
652    }
653
654    #[test]
655    fn wrap_images_with_blur_skips_external_image() {
656        // 外链图不进入 dims_fn,保持原样。
657        let html = r#"<img src="https://example.com/img.png" alt="ext">"#;
658        let result = wrap_images_with_blur_with(html, |_| None);
659        assert!(
660            !result.contains("blur-img"),
661            "external image should not be wrapped"
662        );
663    }
664
665    #[test]
666    fn wrap_images_with_blur_uses_slash_in_aspect_ratio() {
667        // 注入已知 dimensions,验证 --ar 用斜杠分隔(如 "--ar:800 / 600;")。
668        // 此前依赖 uploads/ 真实文件,现已解耦。
669        let html = r#"<img src="/uploads/2026/06/18/abc.webp" alt="t">"#;
670        let result = wrap_images_with_blur_with(html, |_| Some((800, 600)));
671        assert!(result.contains("--ar:"), "should have --ar");
672        assert!(
673            result.contains(" / "),
674            "aspect-ratio must use slash separator, got: {}",
675            result
676        );
677        assert!(result.contains("--ar:800 / 600;"), "应含精确宽高");
678    }
679
680    #[test]
681    fn wrap_images_with_blur_omits_ar_when_no_dimensions() {
682        // dims_fn 返回 None 时不输出 --ar,避免空 style 属性。
683        let html = r#"<img src="/uploads/x.webp" alt="t">"#;
684        let result = wrap_images_with_blur_with(html, |_| None);
685        assert!(!result.contains("--ar"), "无 dimensions 不应有 --ar");
686        // 但包裹结构仍应生成
687        assert!(result.contains("blur-img"));
688    }
689
690    #[test]
691    fn wrap_images_with_blur_strips_query_from_rel_path() {
692        // rel_path 提取应去 query 后缀,dims_fn 收到的是去 query 的路径。
693        // dims_fn 是 Fn(replace_all 要求),用 RefCell 捕获传入值。
694        use std::cell::RefCell;
695        let html = r#"<img src="/uploads/2026/x.webp?w=100" alt="t">"#;
696        let received = RefCell::new(String::new());
697        let _ = wrap_images_with_blur_with(html, |p| {
698            *received.borrow_mut() = p.to_string();
699            Some((100, 100))
700        });
701        assert_eq!(
702            received.borrow().as_str(),
703            "2026/x.webp",
704            "rel_path 应去 query"
705        );
706    }
707
708    #[test]
709    fn wrap_images_with_blur_preserves_alt() {
710        let html = r#"<img src="/uploads/x.webp" alt="描述">"#;
711        let result = wrap_images_with_blur_with(html, |_| Some((10, 10)));
712        assert!(result.contains(r#"alt="描述""#), "placeholder 应保留 alt");
713        assert!(
714            result.matches(r#"alt="描述""#).count() == 2,
715            "两层 img 都应带 alt"
716        );
717    }
718
719    #[test]
720    fn wrap_images_with_blur_omits_alt_attr_when_empty() {
721        let html = r#"<img src="/uploads/x.webp">"#;
722        let result = wrap_images_with_blur_with(html, |_| None);
723        assert!(!result.contains("alt=\""), "无 alt 时不应生成空 alt 属性");
724    }
725
726    #[test]
727    fn full_pipeline_wrap_then_clean_preserves_slash() {
728        // 模拟完整渲染管线:wrap → clean_html,验证 sanitizer 不破坏斜杠。
729        // 注入确定 dimensions,脱离文件系统依赖。
730        let html = r#"<img src="/uploads/2026/06/18/abc.webp" alt="t">"#;
731        let wrapped = wrap_images_with_blur_with(html, |_| Some((800, 600)));
732        let cleaned = clean_html(&wrapped);
733        assert!(
734            cleaned.contains(" / "),
735            "clean_html must preserve slash in --ar, got: {}",
736            cleaned
737        );
738    }
739
740    // ---- wrap_tables 单元测试 ----
741
742    #[test]
743    fn wrap_tables_wraps_bare_table() {
744        let html =
745            "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>";
746        let result = wrap_tables(html);
747        assert!(
748            result.starts_with("<div class=\"table-wrap\"><table>")
749                && result.ends_with("</table></div>"),
750            "应整体包裹一层 table-wrap, got: {}",
751            result
752        );
753    }
754
755    #[test]
756    fn wrap_tables_wraps_table_with_attributes() {
757        // pulldown-cmark 不会给 table 加属性,但正则需兼容带属性/自闭合起始的 table。
758        let html = r#"<table class="x"><tr><td>1</td></tr></table>"#;
759        let result = wrap_tables(html);
760        assert!(
761            result.contains("<div class=\"table-wrap\"><table"),
762            "应从 table 起始标签整体包裹, got: {}",
763            result
764        );
765        assert!(result.ends_with("</table></div>"));
766    }
767
768    #[test]
769    fn wrap_tables_wraps_multiple_tables() {
770        let html =
771            "<table><tr><td>1</td></tr></table>\n<p>间隔</p>\n<table><tr><td>2</td></tr></table>";
772        let result = wrap_tables(html);
773        let wrap_count = result.matches("<div class=\"table-wrap\">").count();
774        assert_eq!(wrap_count, 2, "两个 table 应各自包裹, got: {}", result);
775        // 中间段落不被误包
776        assert!(result.contains("\n<p>间隔</p>\n"));
777    }
778
779    #[test]
780    fn wrap_tables_handles_multiline_table() {
781        // pulldown-cmark 产出的 table HTML 是单行无换行的,但正则用 (?s) 跨行兼容手写 HTML。
782        let html = "<table>\n  <tr>\n    <td>1</td>\n  </tr>\n</table>";
783        let result = wrap_tables(html);
784        assert!(
785            result.starts_with("<div class=\"table-wrap\"><table>")
786                && result.ends_with("</table></div>"),
787            "跨行 table 应整体包裹, got: {}",
788            result
789        );
790    }
791
792    #[test]
793    fn wrap_tables_does_not_touch_non_table_html() {
794        let html = "<p>段落</p><ul><li>项</li></ul>";
795        let result = wrap_tables(html);
796        assert_eq!(result, html, "无 table 时应原样返回");
797    }
798
799    #[test]
800    fn wrap_tables_then_clean_preserves_div_and_table() {
801        // 端到端:wrap → clean_html,确认 sanitizer 放行 div.table-wrap 与内部 table 结构。
802        let html =
803            "<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>";
804        let wrapped = wrap_tables(html);
805        let cleaned = clean_html(&wrapped);
806        assert!(
807            cleaned.contains(r#"<div class="table-wrap">"#),
808            "clean_html 应保留 table-wrap div, got: {}",
809            cleaned
810        );
811        assert!(
812            cleaned.contains("<table>") && cleaned.contains("</table>"),
813            "clean_html 应保留 table 标签, got: {}",
814            cleaned
815        );
816    }
817
818    #[test]
819    fn render_markdown_table_wrapped_in_scroll_container() {
820        // 端到端:markdown table 经渲染管线后应被 table-wrap div 包裹。
821        let result = render_markdown_enhanced("| A | B |\n|---|---|\n| 1 | 2 |\n");
822        assert!(
823            result.html.contains(r#"<div class="table-wrap">"#),
824            "markdown table 应被 table-wrap 包裹, got: {}",
825            result.html
826        );
827        assert!(result.html.contains("<table>"));
828    }
829
830    #[test]
831    fn slugify_heading_simple() {
832        assert_eq!(slugify_heading("Hello World"), "hello-world");
833    }
834
835    #[test]
836    fn slugify_heading_special_chars() {
837        assert_eq!(slugify_heading("What's new? (2024)"), "what-s-new-2024");
838    }
839
840    #[test]
841    fn slugify_heading_chinese() {
842        // 汉字逐字转拼音,用 `-` 分隔。
843        assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
844    }
845
846    #[test]
847    fn slugify_heading_mixed_chinese_ascii() {
848        // Rust 入门指南 → rust + ru-men-zhi-nan,词之间用 `-` 分隔。
849        assert_eq!(slugify_heading("Rust 入门指南"), "rust-ru-men-zhi-nan");
850    }
851
852    #[test]
853    fn slugify_heading_collapses_dashes() {
854        assert_eq!(slugify_heading("a--b"), "a-b");
855    }
856
857    #[test]
858    fn slugify_heading_strips_trailing_dash() {
859        assert_eq!(slugify_heading("hello!"), "hello");
860    }
861
862    #[test]
863    fn slugify_heading_empty_returns_heading() {
864        assert_eq!(slugify_heading(""), "heading");
865    }
866
867    #[test]
868    fn clean_html_allows_safe_tags() {
869        let input = "<p>Hello <strong>world</strong></p>";
870        assert_eq!(clean_html(input), input);
871    }
872
873    #[test]
874    fn clean_html_removes_script() {
875        let input = "<script>alert('xss')</script><p>safe</p>";
876        let result = clean_html(input);
877        assert!(!result.contains("script"));
878        assert!(result.contains("safe"));
879    }
880
881    #[test]
882    fn clean_html_allows_id_attribute() {
883        let input = "<h2 id=\"my-heading\">Title</h2>";
884        let result = clean_html(input);
885        assert!(result.contains("id=\"my-heading\""));
886    }
887
888    #[test]
889    fn clean_html_allows_class_attribute() {
890        let input = "<span class=\"highlight\">text</span>";
891        let result = clean_html(input);
892        assert!(result.contains("class=\"highlight\""));
893    }
894
895    #[test]
896    fn generate_toc_html_empty() {
897        assert_eq!(generate_toc_html(&[]), "");
898    }
899
900    #[test]
901    fn generate_toc_html_single_heading() {
902        let headings = vec![(2u8, "Title".to_string(), "title".to_string())];
903        let html = generate_toc_html(&headings);
904        assert!(html.contains("href=\"#title\""));
905        assert!(html.contains("<ul>"));
906        assert!(html.contains("</ul>"));
907    }
908
909    #[test]
910    fn generate_toc_html_nested() {
911        let headings = vec![
912            (2u8, "Chapter".to_string(), "chapter".to_string()),
913            (3u8, "Section".to_string(), "section".to_string()),
914        ];
915        let html = generate_toc_html(&headings);
916        assert!(html.contains("href=\"#chapter\""));
917        assert!(html.contains("href=\"#section\""));
918        let ul_count = html.matches("<ul>").count();
919        assert_eq!(ul_count, 2);
920    }
921
922    #[test]
923    fn generate_toc_html_escapes_quote_in_attr() {
924        // 标题中的双引号不得越出 aria-label 属性边界。
925        let headings = vec![(
926            2u8,
927            "\" onmouseover=\"alert(1)".to_string(),
928            "heading".to_string(),
929        )];
930        let html = generate_toc_html(&headings);
931        // aria-label 中的双引号被转义为 &quot;,无法越出属性边界注入新属性。
932        assert!(
933            html.contains("aria-label=\"&quot; onmouseover=&quot;alert(1)\""),
934            "aria-label 应转义内部双引号,got: {html}"
935        );
936        // 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。
937        // 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。
938        let attr_injection = "\" onmouseover=\"";
939        let injected = html.matches(attr_injection).count();
940        // 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。
941        // 注意 aria-label 内部的双引号已变成 &quot;,因此裸的 `" onmouseover="` 不应存在。
942        assert_eq!(
943            injected, 0,
944            "不应存在未转义的属性边界 `\" onmouseover=\"`,got: {html}"
945        );
946    }
947
948    #[test]
949    fn generate_toc_html_escapes_ampersand_in_attr() {
950        let headings = vec![(2u8, "A & B".to_string(), "heading".to_string())];
951        let html = generate_toc_html(&headings);
952        assert!(
953            html.contains("aria-label=\"A &amp; B\""),
954            "& 应在属性中转义,got: {html}"
955        );
956    }
957
958    #[test]
959    fn generate_toc_html_escapes_less_than_in_attr() {
960        // `<` 在属性与正文中都应被转义,避免被误解析为标签起始。
961        let headings = vec![(2u8, "a < b".to_string(), "heading".to_string())];
962        let html = generate_toc_html(&headings);
963        assert!(
964            html.contains("aria-label=\"a &lt; b\""),
965            "< 应在属性中转义,got: {html}"
966        );
967        assert!(!html.contains("a < b"));
968    }
969
970    #[test]
971    fn render_markdown_simple_paragraph() {
972        let result = render_markdown_enhanced("Hello **world**");
973        assert!(result.html.contains("<strong>world</strong>"));
974        assert!(result.toc_html.is_empty());
975    }
976
977    #[test]
978    fn render_markdown_with_heading_generates_toc() {
979        let result = render_markdown_enhanced("## My Heading\n\nSome text.");
980        assert!(result.toc_html.contains("My Heading"));
981        assert!(result.html.contains("id=\"my-heading\""));
982    }
983
984    #[test]
985    fn render_markdown_empty() {
986        let result = render_markdown_enhanced("");
987        assert_eq!(result.html, "");
988        assert_eq!(result.toc_html, "");
989    }
990
991    #[test]
992    fn render_markdown_code_block() {
993        let result = render_markdown_enhanced("```rust\nfn main() {}\n```");
994        assert!(result.html.contains(r#"<pre><code class="language-rust">"#));
995        assert!(result
996            .html
997            .contains(r#"<span class="entity name function rust">main</span>"#));
998        assert!(result
999            .html
1000            .contains(r#"<span class="storage type function rust">fn</span>"#));
1001    }
1002
1003    #[test]
1004    fn render_markdown_code_block_without_language() {
1005        let result = render_markdown_enhanced("```\nplain text\n```");
1006        assert!(result.html.contains("<pre><code>"));
1007        assert!(!result.html.contains("class=\"language-"));
1008        assert!(result.html.contains("plain text"));
1009    }
1010
1011    #[test]
1012    fn render_markdown_runnable_block_emits_data_attrs() {
1013        // `python runnable` 围栏:pre 上挂 data-runnable / data-lang / data-overrides / data-source,
1014        // 阅读器据此原地挂载 CodeRunner 组件。
1015        let result = render_markdown_enhanced("```python runnable\nprint('hi')\n```");
1016        assert!(
1017            result.html.contains(r#"data-runnable="true""#),
1018            "应输出 data-runnable, got: {}",
1019            result.html
1020        );
1021        assert!(
1022            result.html.contains(r#"data-lang="python""#),
1023            "应输出 data-lang=python, got: {}",
1024            result.html
1025        );
1026        // 无 overrides 时 data-overrides 为空。
1027        assert!(
1028            result.html.contains(r#"data-overrides="""#),
1029            "无 overrides 时 data-overrides 应为空, got: {}",
1030            result.html
1031        );
1032        // data-source 携带 HTML 转义后的原始源码(单引号转义为 &#x27;)。
1033        assert!(
1034            result
1035                .html
1036                .contains(r#"data-source="print(&#x27;hi&#x27;)"#),
1037            "data-source 应含转义后的源码, got: {}",
1038            result.html
1039        );
1040        // 内部仍带高亮 code 与 language-python。
1041        assert!(
1042            result.html.contains(r#"<code class="language-python">"#),
1043            "应保留高亮 code, got: {}",
1044            result.html
1045        );
1046    }
1047
1048    #[test]
1049    fn render_markdown_runnable_block_with_overrides() {
1050        let result = render_markdown_enhanced(
1051            r#"```node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":false,"cpu_cores":1.0,"output_bytes":1024}
1052console.log(1)
1053```"#,
1054        );
1055        assert!(result.html.contains(r#"data-lang="node""#));
1056        // overrides JSON 应被 HTML 转义后放入属性(双引号变 &quot;),不得出现裸引号越界。
1057        // 字段顺序按 serde 派生默认(字母序),cpu_cores 在前。
1058        assert!(
1059            result
1060                .html
1061                .contains("data-overrides=\"{&quot;cpu_cores&quot;:1.0"),
1062            "overrides 应 HTML 转义, got: {}",
1063            result.html
1064        );
1065        // 安全:不得出现可越出属性边界的裸双引号 JSON。
1066        assert!(
1067            !result.html.contains(r#"data-overrides="{"timeout""#),
1068            "overrides 裸引号越界, got: {}",
1069            result.html
1070        );
1071    }
1072
1073    #[test]
1074    fn render_markdown_runnable_block_alias_normalized_to_canonical() {
1075        // 关键契约:parse_fence_info 在 markdown 渲染期就把别名归一为 canonical key,
1076        // 故 `js runnable` 渲染出的 data-lang 是 "node"(而非 "js")。
1077        // 这保证阅读器 CodeRunner 拿到的 language 是 canonical,StartExec 的
1078        // LANGUAGES.get 能直接命中,无需前端再做别名映射。
1079        let cases = [
1080            ("js", "node"),
1081            ("javascript", "node"),
1082            ("rs", "rust"),
1083            ("ts", "bun"),
1084            ("typescript", "bun"),
1085            // 大小写不敏感。
1086            ("JavaScript", "node"),
1087            ("TypeScript", "bun"),
1088        ];
1089        for (alias, canonical) in cases {
1090            let src = format!("```{alias} runnable\nconsole.log(1)\n```");
1091            let result = render_markdown_enhanced(&src);
1092            assert!(
1093                result.html.contains(&format!(r#"data-lang="{canonical}""#)),
1094                "别名 {alias} 应归一为 data-lang={canonical}, got: {}",
1095                result.html
1096            );
1097            // 不应残留原始别名作为 data-lang。
1098            let bad = format!(r#"data-lang="{alias}""#);
1099            assert!(
1100                !result.html.contains(&bad),
1101                "data-lang 不应保留别名 {alias}, got: {}",
1102                result.html
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn render_markdown_runnable_block_bun_canonical() {
1109        // bun 自身是 canonical(不是别名),runnable 块以 bun 执行。
1110        let result = render_markdown_enhanced("```bun runnable\nconsole.log('hi')\n```");
1111        assert!(
1112            result.html.contains(r#"data-lang="bun""#),
1113            "got: {}",
1114            result.html
1115        );
1116    }
1117
1118    #[test]
1119    fn render_markdown_runnable_marker_on_unsupported_lang_ignored() {
1120        // 语言不在白名单(rust 已在白名单,改用 ruby):runnable 标记被忽略,输出普通代码块。
1121        let result = render_markdown_enhanced("```ruby runnable\nputs 'hi'\n```");
1122        assert!(
1123            !result.html.contains("data-runnable"),
1124            "不支持的语言不应挂 data-runnable, got: {}",
1125            result.html
1126        );
1127    }
1128
1129    #[test]
1130    fn render_markdown_plain_fence_without_runnable_no_data_attrs() {
1131        let result = render_markdown_enhanced("```python\nprint(1)\n```");
1132        assert!(!result.html.contains("data-runnable"));
1133        assert!(result
1134            .html
1135            .contains(r#"<pre><code class="language-python">"#));
1136    }
1137
1138    #[test]
1139    fn render_markdown_code_block_preserves_html_content() {
1140        let result = render_markdown_enhanced("```html\n<script>alert(1)</script>\n```");
1141        assert!(result.html.contains("<pre><code class=\"language-html\">"));
1142        assert!(!result.html.contains("<script>"));
1143        assert!(result
1144            .html
1145            .contains(r#"<span class="variable function js">alert</span>"#));
1146        assert!(result
1147            .html
1148            .contains(r#"<span class="constant numeric js">1</span>"#));
1149    }
1150
1151    #[test]
1152    fn render_markdown_data_uri_image_removed() {
1153        let result = render_markdown_enhanced("![alt](data:image/svg+xml,%3csvg%3e%3c/svg%3e)");
1154        // 出于 XSS 防护,文章正文不再保留 data URI src。
1155        assert!(
1156            !result.html.contains("data:image/svg+xml"),
1157            "data URI should be removed from img src, got: {}",
1158            result.html
1159        );
1160        assert!(result.html.contains("alt=\"alt\""));
1161    }
1162
1163    #[test]
1164    fn render_markdown_task_list() {
1165        // 端到端验证:pulldown-cmark 解析任务列表 → sanitizer 清理后 checkbox 不丢失。
1166        // 覆盖「编辑器写入 → 入库 → 服务端重渲染」链路的最终 HTML 形态。
1167        let result = render_markdown_enhanced("- [ ] 未完成\n- [x] 已完成\n");
1168        // 两个 checkbox 都应保留
1169        assert!(
1170            result.html.contains(r#"type="checkbox""#),
1171            "checkbox type 应保留, got: {}",
1172            result.html
1173        );
1174        // 已勾选项的 checked 属性应保留
1175        assert!(
1176            result.html.contains("checked"),
1177            "checked 属性应保留, got: {}",
1178            result.html
1179        );
1180        // 文本内容保留
1181        assert!(result.html.contains("未完成"));
1182        assert!(result.html.contains("已完成"));
1183    }
1184
1185    #[test]
1186    fn render_markdown_footnote_basic() {
1187        // 端到端:单个脚注引用 + 定义,验证自定义渲染器的完整输出。
1188        // pulldown-cmark (GFM 模式) 解析 → 自定义事件拦截渲染 → sanitizer 放行。
1189        let result = render_markdown_enhanced("正文[^a]\n\n[^a]: 脚注内容\n");
1190        // 脚注引用:上标 + 锚点跳转 + role 语义
1191        assert!(
1192            result
1193                .html
1194                .contains(r#"<sup class="fn-ref" id="fnref:a-1">"#),
1195            "脚注引用上标应含正确 id, got: {}",
1196            result.html
1197        );
1198        assert!(
1199            result.html.contains(r##"href="#fn:a""##)
1200                && result.html.contains(r#"role="doc-noteref""#),
1201            "引用链接应指向定义并带 noteref 角色, got: {}",
1202            result.html
1203        );
1204        // 脚注定义:<aside> + role + aria-labelledby
1205        assert!(
1206            result
1207                .html
1208                .contains(r#"<aside class="footnote-definition" id="fn:a""#)
1209                && result.html.contains(r#"role="doc-footnote""#),
1210            "定义应为 aside + doc-footnote 角色, got: {}",
1211            result.html
1212        );
1213        // back-link:单个引用显示 ↩
1214        assert!(
1215            result.html.contains("↩") && result.html.contains(r#"role="doc-backlink""#),
1216            "单个引用应有 ↩ back-link, got: {}",
1217            result.html
1218        );
1219        // back-link 指向引用位置
1220        assert!(
1221            result.html.contains(r##"href="#fnref:a-1""##),
1222            "back-link 应指向引用位置, got: {}",
1223            result.html
1224        );
1225        // 脚注正文保留
1226        assert!(result.html.contains("脚注内容"));
1227        // 不应残留 pulldown-cmark 默认的 <div class="footnote-definition">
1228        assert!(
1229            !result.html.contains(r#"<div class="footnote-definition""#),
1230            "不应使用默认 div, got: {}",
1231            result.html
1232        );
1233    }
1234
1235    #[test]
1236    fn render_markdown_footnote_multiple_refs() {
1237        // 同一脚注被多次引用:每个引用独立 id,定义末尾输出 N 个 back-link(↩、↩²、↩³)。
1238        let result = render_markdown_enhanced("第一处[^x],第二处[^x],第三处[^x]\n\n[^x]: 注\n");
1239        // 3 个引用,各有独立 id 后缀
1240        assert!(
1241            result.html.contains(r#"id="fnref:x-1""#)
1242                && result.html.contains(r#"id="fnref:x-2""#)
1243                && result.html.contains(r#"id="fnref:x-3""#),
1244            "3 次引用应有 3 个独立 id, got: {}",
1245            result.html
1246        );
1247        // back-link 数量 = 引用次数(3 个)
1248        let backref_count = result.html.matches(r#"class="fn-backref""#).count();
1249        assert_eq!(
1250            backref_count, 3,
1251            "3 次引用应产出 3 个 back-link, got: {}",
1252            result.html
1253        );
1254        // 首个 back-link 无数字上标(↩),后续带数字(↩²、↩³)
1255        assert!(
1256            result.html.contains(r#">↩</a>"#),
1257            "首个 back-link 应为裸 ↩, got: {}",
1258            result.html
1259        );
1260        assert!(
1261            result
1262                .html
1263                .contains("↩<sup class=\"fn-backref-num\">2</sup>")
1264                && result
1265                    .html
1266                    .contains("↩<sup class=\"fn-backref-num\">3</sup>"),
1267            "第 2、3 个 back-link 应带数字上标, got: {}",
1268            result.html
1269        );
1270        // 每个 back-link 指向不同引用位置
1271        assert!(
1272            result.html.contains(r##"href="#fnref:x-1""##)
1273                && result.html.contains(r##"href="#fnref:x-2""##)
1274                && result.html.contains(r##"href="#fnref:x-3""##),
1275            "3 个 back-link 应分别指向 3 个引用位置, got: {}",
1276            result.html
1277        );
1278    }
1279
1280    #[test]
1281    fn render_markdown_footnote_numbering_order() {
1282        // 多个不同脚注:编号按 label 首次出现顺序分配(1、2、3…),与定义位置无关。
1283        let result =
1284            render_markdown_enhanced("先引用第二个[^b],再第一个[^a]\n\n[^b]: B注\n\n[^a]: A注\n");
1285        // b 先在正文被引用 → 编号 1;a 后被引用 → 编号 2。
1286        // 分别断言各自的引用链接块(由 id 唯一定位)。
1287        // b 的引用:id=fnref:b-1,aria-label=脚注 1
1288        assert!(
1289            result.html.contains(r#"id="fnref:b-1""#)
1290                && result.html.contains(r#"aria-label="脚注 1""#),
1291            "b(先出现)引用编号应为 1, got: {}",
1292            result.html
1293        );
1294        // a 的引用:id=fnref:a-1,aria-label=脚注 2
1295        assert!(
1296            result.html.contains(r#"id="fnref:a-1""#)
1297                && result.html.contains(r#"aria-label="脚注 2""#),
1298            "a(后出现)引用编号应为 2, got: {}",
1299            result.html
1300        );
1301        // 两个定义都应存在
1302        assert!(
1303            result.html.contains(r#"id="fn:b""#) && result.html.contains(r#"id="fn:a""#),
1304            "两个脚注定义都应存在, got: {}",
1305            result.html
1306        );
1307    }
1308
1309    #[test]
1310    fn render_markdown_footnote_id_safety() {
1311        // label 含空格/标点:id 应被清洗,ref↔def 双向匹配,不含破坏属性的字符。
1312        let result = render_markdown_enhanced("引文[^my note]\n\n[^my note]: 内容\n");
1313        // 空格应被转成 -,ref 与 def 用同一个清洗后 id
1314        assert!(
1315            result.html.contains(r##"href="#fn:my-note""##),
1316            "ref href 应用清洗后 id, got: {}",
1317            result.html
1318        );
1319        assert!(
1320            result.html.contains(r#"id="fn:my-note""#),
1321            "def id 应用清洗后 id, got: {}",
1322            result.html
1323        );
1324        // 不应含未转义的空格在 id/href 属性值中(会破坏属性或 URL)
1325        assert!(
1326            !result.html.contains("fn:my note"),
1327            "id 不应含空格, got: {}",
1328            result.html
1329        );
1330    }
1331
1332    #[test]
1333    fn render_markdown_footnote_dangling_ref() {
1334        // GFM 模式下,未定义的悬空引用 [^missing] 被当作字面文本(不发 FootnoteReference 事件)。
1335        // 这是 GFM 与 OLD 模式的关键差异:OLD 把它渲染成 dangling link,GFM 保持字面。
1336        // 参见 pulldown-cmark lib.rs:712-713 注释。
1337        let result = render_markdown_enhanced("这个[^missing]没有定义\n");
1338        // 字面保留 [^missing],不渲染成上标
1339        assert!(
1340            result.html.contains("[^missing]"),
1341            "GFM 模式下悬空引用应字面保留, got: {}",
1342            result.html
1343        );
1344        assert!(
1345            !result.html.contains("fn-ref"),
1346            "悬空引用不应渲染成脚注上标, got: {}",
1347            result.html
1348        );
1349        assert!(
1350            !result.html.contains("footnote-definition"),
1351            "悬空引用不应有定义块, got: {}",
1352            result.html
1353        );
1354    }
1355
1356    #[test]
1357    fn render_markdown_footnote_gfm_mode() {
1358        // GFM 模式验证:定义续行需缩进。未缩进的续行不会被纳入脚注定义。
1359        // 这是 GFM 与 OLD 模式的关键差异——确认我们走的是 GFM。
1360        // 用一个 GFM 下会严格解析的输入:定义后紧跟的未缩进段落应独立于脚注。
1361        let result = render_markdown_enhanced("正文[^g]\n\n[^g]: 脚注第一行\n独立段落\n");
1362        // 脚注定义存在
1363        assert!(
1364            result.html.contains(r#"id="fn:g""#),
1365            "脚注定义应存在, got: {}",
1366            result.html
1367        );
1368        // GFM 模式下「独立段落」是独立的 <p>,不在脚注定义内
1369        assert!(
1370            result.html.contains("独立段落"),
1371            "独立段落内容应保留, got: {}",
1372            result.html
1373        );
1374    }
1375
1376    // ---- footnote_id 单元测试 ----
1377
1378    #[test]
1379    fn footnote_id_preserves_alphanumeric() {
1380        assert_eq!(footnote_id("abc123"), "abc123");
1381        assert_eq!(footnote_id("a_b-c"), "a_b-c");
1382    }
1383
1384    #[test]
1385    fn footnote_id_preserves_non_ascii() {
1386        // 中文、emoji 等非 ASCII 字符原样保留(HTML id 允许任意非空白字符)。
1387        assert_eq!(footnote_id("参考文献1"), "参考文献1");
1388    }
1389
1390    #[test]
1391    fn footnote_id_replaces_spaces_and_punctuation() {
1392        // 空格、标点转 -,连续合并。
1393        assert_eq!(footnote_id("my note"), "my-note");
1394        assert_eq!(footnote_id("a b!c?d"), "a-b-c-d");
1395    }
1396
1397    #[test]
1398    fn footnote_id_deterministic() {
1399        // 同一 label 多次调用必产生同一 id(ref↔def 双向一致的前提)。
1400        for label in ["a", "my note", "参考文献", "a!b@c#"] {
1401            assert_eq!(
1402                footnote_id(label),
1403                footnote_id(label),
1404                "label {:?} 不确定",
1405                label
1406            );
1407        }
1408    }
1409
1410    #[test]
1411    fn footnote_id_no_attribute_breaking_chars() {
1412        // 产出的 id 不得含破坏 HTML 属性或 URL 的字符:" ' < > & 空格。
1413        for label in ["a\"b", "x'y", "a<b>", "c&d", "e f", "a!@#$%^&*()b"] {
1414            let id = footnote_id(label);
1415            assert!(
1416                !id.contains('"'),
1417                "id {:?} 含双引号 (label {:?})",
1418                id,
1419                label
1420            );
1421            assert!(
1422                !id.contains('\''),
1423                "id {:?} 含单引号 (label {:?})",
1424                id,
1425                label
1426            );
1427            assert!(!id.contains('<'), "id {:?} 含 < (label {:?})", id, label);
1428            assert!(!id.contains('>'), "id {:?} 含 > (label {:?})", id, label);
1429            assert!(!id.contains('&'), "id {:?} 含 & (label {:?})", id, label);
1430            assert!(!id.contains(' '), "id {:?} 含空格 (label {:?})", id, label);
1431        }
1432    }
1433
1434    #[test]
1435    fn footnote_id_empty_fallback() {
1436        // 全特殊字符的极端情况回退为 "fn"。
1437        assert_eq!(footnote_id("!@#$"), "fn");
1438        assert_eq!(footnote_id("!!!"), "fn");
1439    }
1440
1441    #[test]
1442    fn render_markdown_inline_math() {
1443        // $...$ 内联公式:pulldown-cmark (ENABLE_MATH) 解析 → katex 渲染成 span。
1444        // sanitizer 放行 span 的 class/style,KaTeX 输出应原样保留。
1445        let result = render_markdown_enhanced("公式 $E = mc^2$ 很重要");
1446        assert!(
1447            result.html.contains("katex"),
1448            "内联公式应渲染为 katex span, got: {}",
1449            result.html
1450        );
1451        // 前后文本保留
1452        assert!(result.html.contains("公式"));
1453        assert!(result.html.contains("很重要"));
1454    }
1455
1456    #[test]
1457    fn render_markdown_display_math() {
1458        // $$...$$ 块级公式:应产出 <p class="math-display"> + katex-display。
1459        let result = render_markdown_enhanced("$$\\frac{a}{b}$$");
1460        assert!(
1461            result.html.contains("math-display"),
1462            "块级公式应用 math-display 段落包裹, got: {}",
1463            result.html
1464        );
1465        assert!(
1466            result.html.contains("katex-display"),
1467            "KaTeX 块级输出应含 katex-display, got: {}",
1468            result.html
1469        );
1470    }
1471    #[test]
1472    fn render_markdown_sqrt_and_matrix_preserves_svg() {
1473        // \sqrt 与 \begin{pmatrix} 等 LaTeX 渲染需依赖 KaTeX 产生的 SVG 根号线与矩阵括号/竖线,
1474        // 验证经过 sanitizer clean_html 后 <svg> 与 <path> 不会被误丢。
1475        let result = render_markdown_enhanced(
1476            "$$\\sqrt{\\pi} + \\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$",
1477        );
1478        assert!(
1479            result.html.contains("<svg"),
1480            "KaTeX 根号/矩阵渲染的 <svg> 应保留, got: {}",
1481            result.html
1482        );
1483        assert!(
1484            result.html.contains("<path"),
1485            "KaTeX 根号/矩阵渲染的 <path> 应保留, got: {}",
1486            result.html
1487        );
1488    }
1489
1490    #[test]
1491    fn render_markdown_inline_math_in_heading() {
1492        // 标题内的 $...$ 按内联公式渲染(不应崩,也不应产生块级 <p>)。
1493        let result = render_markdown_enhanced("## 勾股 $a^2 + b^2 = c^2$ 定理");
1494        assert!(
1495            result.html.contains("katex"),
1496            "标题内联公式应渲染, got: {}",
1497            result.html
1498        );
1499        // 标题里不应出现块级公式的 <p class="math-display">
1500        assert!(
1501            !result.html.contains("math-display"),
1502            "标题内不应有块级公式包裹, got: {}",
1503            result.html
1504        );
1505    }
1506
1507    #[test]
1508    fn render_markdown_bad_math_does_not_break() {
1509        // 坏 TeX 不应中断整篇渲染:throw_on_error=false 回退到错误 span。
1510        let result = render_markdown_enhanced("正常文本 $\\undefinedmacro{$ 后续文本");
1511        assert!(
1512            result.html.contains("正常文本"),
1513            "坏公式不应破坏前文, got: {}",
1514            result.html
1515        );
1516        assert!(
1517            result.html.contains("后续文本"),
1518            "坏公式不应破坏后文, got: {}",
1519            result.html
1520        );
1521    }
1522
1523    #[test]
1524    fn render_markdown_mermaid_block_not_highlighted() {
1525        // mermaid 块跳过 syntect 高亮:源码应是转义纯文本,不被 <span> 包裹。
1526        // 前端 mermaid.ts 用 textContent 无损提取渲染成 SVG。
1527        let result = render_markdown_enhanced("```mermaid\ngraph LR\n    A --> B\n```");
1528        assert!(
1529            result.html.contains(r#"class="language-mermaid""#),
1530            "应保留 language-mermaid class, got: {}",
1531            result.html
1532        );
1533        // 不应被 syntect 高亮(无 text plain span)。
1534        assert!(
1535            !result.html.contains("text plain"),
1536            "mermaid 源码不应被 syntect 高亮, got: {}",
1537            result.html
1538        );
1539        // 源码内容保留(HTML 转义后)。
1540        assert!(result.html.contains("graph LR"));
1541    }
1542}