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