yggdrasil/src/api/markdown.rs
xfy 445b92f407 fix(mermaid): 改用 script 标签加载 IIFE bundle,修正全局变量取值
根因:mermaid bundle 是 IIFE 格式(与项目其他前端库一致),挂全局变量
而非 ES module export。但 mermaid.ts 错用了 import() 取 .default——
IIFE 无 export,mod.default 是 undefined,导致 mermaid.initialize 调用
TypeError,流程图永远不渲染(数学公式不受影响,因为是服务端渲染)。

修复:
- mermaid.ts: loadMermaidBundle 改用动态注入 <script src=/mermaid/mermaid.js>
  标签,加载后从 window.MermaidRenderer 取(与 codemirror/lightbox 的
  Reflect::get(window,...) 模式一致)
- mermaid-renderer/index.ts: 显式 globalThis.MermaidRenderer = mermaid,
  避免 vite IIFE 把 export default 编译成 .default 多一层
- markdown.rs: mermaid 代码块跳过 syntect 高亮(无语法定义且会包 <span>
  污染 textContent),直接输出转义纯源码

前端 28 测试 + Rust 557 测试 + 双 target 编译通过。
2026-07-16 15:36:07 +08:00

946 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Markdown 渲染与目录生成。
//!
//! 使用 pulldown-cmark 解析 Markdown为标题生成锚点与目录TOC
//! 代码块调用 `highlight` 模块进行语法高亮,最终通过 sanitizer 清理 HTML。
//! 仅在 `feature = "server"` 时执行实际渲染。
#![allow(clippy::unused_unit, deprecated)]
#[cfg(feature = "server")]
/// 对外暴露的 HTML 清理函数,委托给 sanitizer 模块。
pub fn clean_html(input: &str) -> String {
crate::api::sanitizer::clean_html(input)
}
#[cfg(feature = "server")]
/// 将标题纯文本转义,用于安全地拼进 TOC 的 `aria-label="..."` 与 `<a>` 正文。
///
/// 复用 `utils::html::escape_html`(转义 `& < > " '`),避免在仓库内
/// 维护第二份转义实现。原先用 `clean_html` 处理属性上下文会漏掉 `"`,标题形如
/// `" onmouseover="alert(1)` 会越出属性边界。
fn escape_heading_text(s: &str) -> String {
crate::utils::html::escape_html(s)
}
#[derive(Debug, Clone)]
#[cfg(feature = "server")]
/// Markdown 渲染结果。
pub struct RenderedContent {
/// 清理后的正文 HTML。
pub html: String,
/// 目录 HTML无标题时为空字符串
pub toc_html: String,
}
#[cfg(feature = "server")]
/// 增强版 Markdown 渲染:生成 TOC、标题锚点与语法高亮代码块。
pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
use std::fmt::Write as _;
// 两遍遍历使用相同的 Options 与同一份解析结果,避免 TOC 收集与正文渲染对
// Markdown 扩展语法(表格、删除线、脚注等)的处理不一致。
let opts = Options::all();
// pulldown-cmark 只解析一次collect 成 Vec<Event> 后两遍遍历复用。
// 旧实现对同一份 md 调用两次 Parser::new_ext等于两倍的 tokenize + 解析 CPU。
// Event 内含 CowStr借用 md 切片collect 后仍可重复借用。
let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
// 1. 第一遍收集标题level, text, id用 iter() 借用不消费 events。
// (level, text, id)
let mut headings: Vec<(u8, String, String)> = Vec::new();
let mut current_heading: Option<(u8, String)> = None;
for event in &events {
match event {
Event::Start(Tag::Heading { level, .. }) => {
let lvl = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
current_heading = Some((lvl, String::new()));
}
Event::Text(text) => {
if let Some((_, ref mut content)) = current_heading {
content.push_str(text);
}
}
Event::Code(code) => {
if let Some((_, ref mut content)) = current_heading {
content.push_str(code);
}
}
Event::End(TagEnd::Heading(_)) => {
if let Some((lvl, text)) = current_heading.take() {
let id = slugify_heading(&text);
headings.push((lvl, text, id));
}
}
_ => {}
}
}
// 2. Generate TOC HTML
let toc_html = generate_toc_html(&headings);
// 3. 第二遍:生成 HTML用 into_iter() 消费 events非标题事件需 move 进 push_html
// HTML 输出通常比 md 长(标签包裹),按 md 长度 + 256 预分配,避免 String::new 的多次 realloc。
let mut html = String::with_capacity(md.len() + 256);
let mut heading_idx = 0;
let mut in_heading = false;
let mut in_codeblock = false;
let mut code_lang: Option<String> = None;
// 可运行代码块的 (lang, html-escaped overrides JSON)。
// 为 None 表示普通代码块。原始源码在 End 处从 code_buffer 转义后存入 data-source
// 供阅读器无损提取(避免从高亮 HTML 反解)。
let mut code_runnable: Option<(String, String)> = None;
let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new();
for event in events {
match event {
Event::Start(Tag::Heading { level, .. }) => {
// 先把累积的普通事件刷入 HTML再开始新标题。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
in_heading = true;
if heading_idx < headings.len() {
let (_, _, ref id) = headings[heading_idx];
let tag = match level {
HeadingLevel::H1 => "h1",
HeadingLevel::H2 => "h2",
HeadingLevel::H3 => "h3",
HeadingLevel::H4 => "h4",
HeadingLevel::H5 => "h5",
HeadingLevel::H6 => "h6",
};
// write! 直写目标 String零中间分配format! 会先分配临时 String 再 push_str
let _ = write!(html, "<{tag} id=\"{id}\">");
}
}
Event::End(TagEnd::Heading(level)) => {
if heading_idx < headings.len() {
let (_, _, ref id) = headings[heading_idx];
let tag = match level {
HeadingLevel::H1 => "h1",
HeadingLevel::H2 => "h2",
HeadingLevel::H3 => "h3",
HeadingLevel::H4 => "h4",
HeadingLevel::H5 => "h5",
HeadingLevel::H6 => "h6",
};
let _ = write!(
html,
"<a class=\"anchor\" aria-hidden=\"true\" href=\"#{id}\">#</a></{tag}>"
);
heading_idx += 1;
}
in_heading = false;
}
Event::Start(Tag::CodeBlock(kind)) => {
// 代码块开始前同样先刷入未处理的普通事件。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
in_codeblock = true;
code_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
if lang.is_empty() {
None
} else {
Some(lang.to_string())
}
}
_ => None,
};
// 解析围栏 info识别 `runnable` 标记与可选 ResourceLimits JSON 覆盖。
// 仅在「标记为 runnable 且语言受支持」时挂 data-*,供阅读器扫描挂载运行器。
code_runnable = code_lang.as_deref().and_then(|info| {
let (lang, runnable, overrides) =
crate::api::code_runner::languages::parse_fence_info(info);
if runnable && crate::api::code_runner::languages::is_supported_lang(&lang) {
// overrides 序列化为 JSON 后 HTML 转义,避免属性注入。
let ov_json = overrides
.map(|o| serde_json::to_string(&o).unwrap_or_default())
.unwrap_or_default();
let overrides_escaped = crate::utils::html::escape_html(&ov_json);
Some((lang, overrides_escaped))
} else {
None
}
});
code_buffer.clear();
}
Event::Text(text) if in_codeblock => {
code_buffer.push_str(&text);
}
Event::End(TagEnd::CodeBlock) => {
// mermaid 代码块:前端 yggdrasil-core 扫描 language-mermaid 渲染成 SVG
// 源码不应被 syntect 高亮(无语法定义,且会包 <span> 污染 textContent 提取)。
// 直接输出转义后的纯源码,前端 textContent 无损拿到原始 mermaid 文本。
let is_mermaid = code_lang
.as_deref()
.map(|l| l.split_whitespace().next() == Some("mermaid"))
.unwrap_or(false);
if is_mermaid {
let escaped = crate::utils::html::escape_html(&code_buffer);
html.push_str(r#"<pre><code class="language-mermaid">"#);
html.push_str(&escaped);
html.push_str("</code></pre>");
in_codeblock = false;
continue;
}
// 使用 syntect 对代码块进行服务端语法高亮。
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
// 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides / data-source
// 阅读者post_content.rs客户端扫描这些标记原地挂载 CodeRunner 组件。
// data-source 为 HTML 转义后的原始源码,供阅读器无损提取(避免反解高亮 HTML
if let Some((lang, overrides_escaped)) = code_runnable.take() {
let source_escaped = crate::utils::html::escape_html(&code_buffer);
let _ = write!(
html,
r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}" data-source="{source_escaped}"><code class="language-{lang}">"#
);
} else {
html.push_str("<pre><code");
if let Some(lang) = &code_lang {
// 围栏语言可能含 info 修饰(如 `python runnable {...}`
// 高亮的 language-xxx 取纯语言 token首个空白前
let clean_lang = lang.split_whitespace().next().unwrap_or("");
if !clean_lang.is_empty() {
let _ = write!(html, " class=\"language-{clean_lang}\"");
}
}
html.push('>');
}
html.push_str(&highlighted);
html.push_str("</code></pre>");
in_codeblock = false;
}
Event::InlineMath(tex) => {
// 先刷出累积的普通事件,再注入 KaTeX 渲染结果。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
// 标题内的公式按内联渲染(标题不应用块级公式)。
html.push_str(&crate::api::katex::render_inline(&tex));
}
Event::DisplayMath(tex) => {
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
// 块级公式独占一行:用 <p class="math-display"> 包裹 KaTeX 输出。
// KaTeX 自身已产出 .katex-display外层 <p> 负责段间距与居中容器。
html.push_str("<p class=\"math-display\">");
html.push_str(&crate::api::katex::render_display(&tex));
html.push_str("</p>");
}
_ => {
if in_heading {
// 标题内部只保留文本与行内代码,避免嵌套块级元素。
match event {
Event::Text(text) => html.push_str(&clean_html(&text)),
Event::Code(code) => {
html.push_str("<code>");
html.push_str(&clean_html(&code));
html.push_str("</code>");
}
_ => {}
}
} else if !in_codeblock {
non_heading_events.push(event);
}
}
}
}
// Flush remaining non-heading events
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
}
let html = wrap_images_with_blur(&html);
RenderedContent {
html: clean_html(&html),
toc_html,
}
}
/// 把 HTML 里的 /uploads/ 图片转成 blur-up 双层结构。
///
/// 仅处理 src 以 /uploads/ 开头的 img外链图保持原样。
/// 对每个匹配的 img
/// 1. 提取 src解析出 rel_path去 /uploads/ 前缀和 query
/// 2. 查 get_image_dimensions 拿真实宽高,算 --ar如 "16:9"
/// 3. 生成 `<span class="blur-img" style="--ar:..">` 包裹两层 img
#[cfg(feature = "server")]
fn wrap_images_with_blur(html: &str) -> String {
wrap_images_with_blur_with(html, crate::api::image::get_image_dimensions)
}
/// `wrap_images_with_blur` 的纯函数核心,接受 dimensions 查询闭包以便单测。
///
/// `dims_fn(rel_path) -> Option<(u32, u32)>` 注入真实或测试用的 dimensions 来源,
/// 使本函数不依赖文件系统——测试可注入已知宽高,生产注入 get_image_dimensions。
#[cfg(feature = "server")]
fn wrap_images_with_blur_with<F>(html: &str, dims_fn: F) -> String
where
F: Fn(&str) -> Option<(u32, u32)>,
{
use regex::Regex;
use std::sync::LazyLock;
// 匹配 pulldown-cmark 产出的 <img src="..." alt="..." /> 或 <img src="..." alt="...">
// pulldown-cmark 格式可控src 在前alt 在后,属性用双引号
static IMG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"<img\s+src="(/uploads/[^"]+)"(?:\s+alt="([^"]*)")?\s*/?>"#).unwrap()
});
IMG_RE
.replace_all(html, |caps: &regex::Captures| {
let src = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
// 从 src 解析 rel_path去 /uploads/ 前缀 + 去 query
let rel_path = src
.strip_prefix("/uploads/")
.unwrap_or(src)
.split('?')
.next()
.unwrap_or("");
// 查 dimensions算 aspect-ratio
// 注意CSS aspect-ratio 用斜杠分隔width / height不是冒号
let ar_style = dims_fn(rel_path)
.map(|(w, h)| format!(" style=\"--ar:{} / {};\"", w, h))
.unwrap_or_default();
// alt 转义src/alt 来自 markdownpulldown-cmark 已转义过,这里直接用)
let alt_attr = if alt.is_empty() {
String::new()
} else {
format!(" alt=\"{}\"", alt)
};
format!(
"<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>",
ar = ar_style,
src = src,
alt_attr = alt_attr,
)
})
.to_string()
}
#[cfg(feature = "server")]
/// 根据标题层级生成嵌套目录 HTML。
fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
use std::fmt::Write as _;
if headings.is_empty() {
return String::new();
}
// TOC 大小按标题数估算(每个 li+a 约 64 字节起),避免 String::new 的多次 realloc。
let mut html = String::with_capacity(headings.len() * 64 + 16);
html.push_str("<ul>");
let mut stack: Vec<u8> = vec![headings[0].0];
for (i, (level, text, id)) in headings.iter().enumerate() {
let level = *level;
if i > 0 {
let prev_level = headings[i - 1].0;
if level > prev_level {
// 标题层级升高:打开新的嵌套列表。
for _ in prev_level..level {
html.push_str("<ul>");
stack.push(level);
}
} else if level < prev_level {
// 标题层级降低:关闭多余的嵌套列表。
while let Some(top) = stack.last() {
if *top > level {
html.push_str("</li></ul>");
stack.pop();
} else {
break;
}
}
html.push_str("</li>");
} else {
html.push_str("</li>");
}
}
// 标题 text 是 pulldown-cmark 收集的纯文本Text/Code 字面字符),不是 HTML 片段,
// 因此正文与属性两处都走 escape_heading_text转义 & < > " ')。原先用 clean_html
// 处理属性上下文会漏掉 `"`,标题中的双引号会越出 aria-label 边界。
let escaped_text = escape_heading_text(text);
let _ = write!(
html,
"<li><a href=\"#{id}\" aria-label=\"{escaped_text}\">{escaped_text}</a>"
);
}
// Close remaining lists
// 闭合所有残留的嵌套列表。
while stack.len() > 1 {
html.push_str("</li></ul>");
stack.pop();
}
html.push_str("</li></ul>");
html
}
#[cfg(feature = "server")]
/// 将标题文本转换为可用于锚点的 slug。
///
/// 汉字转无声调拼音(每字成词,用 `-` 分隔),与 [`crate::api::slug::slugify`]
/// 保持一致ASCII 字母数字保留,其余字符作为词分隔。结果为空时回退 `heading`。
fn slugify_heading(text: &str) -> String {
use pinyin::ToPinyin;
let mut slug = String::new();
let mut prev_dash = true;
for c in text.to_lowercase().chars() {
// 汉字优先转拼音;非汉字(含 ascii返回 None。
// 每个汉字成词,拼音后补 `-` 与后续内容分隔(连续汉字也会被分开)。
if let Some(py) = c.to_pinyin() {
slug.push_str(py.plain());
slug.push('-');
prev_dash = true;
} else if c.is_alphanumeric() {
slug.push(c);
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
if slug.ends_with('-') {
slug.pop();
}
if slug.is_empty() {
slug.push_str("heading");
}
slug
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn wrap_images_with_blur_wraps_uploads_image() {
// 注入返回 None 的 dims_fn,验证 --ar 缺省时的结构正确性。
// 不依赖 uploads/ 文件系统。
let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#;
let result = wrap_images_with_blur_with(html, |_| None);
assert!(
result.contains("blur-img-placeholder"),
"should have placeholder"
);
assert!(result.contains("blur-img-full"), "should have full layer");
assert!(result.contains("?w=20"), "placeholder should use ?w=20");
assert!(result.contains("?w=800"), "full should use ?w=800");
assert!(result.contains("data-src"), "full should use data-src");
}
#[test]
fn wrap_images_with_blur_skips_external_image() {
// 外链图不进入 dims_fn,保持原样。
let html = r#"<img src="https://example.com/img.png" alt="ext">"#;
let result = wrap_images_with_blur_with(html, |_| None);
assert!(
!result.contains("blur-img"),
"external image should not be wrapped"
);
}
#[test]
fn wrap_images_with_blur_uses_slash_in_aspect_ratio() {
// 注入已知 dimensions,验证 --ar 用斜杠分隔(如 "--ar:800 / 600;")。
// 此前依赖 uploads/ 真实文件,现已解耦。
let html = r#"<img src="/uploads/2026/06/18/abc.webp" alt="t">"#;
let result = wrap_images_with_blur_with(html, |_| Some((800, 600)));
assert!(result.contains("--ar:"), "should have --ar");
assert!(
result.contains(" / "),
"aspect-ratio must use slash separator, got: {}",
result
);
assert!(result.contains("--ar:800 / 600;"), "应含精确宽高");
}
#[test]
fn wrap_images_with_blur_omits_ar_when_no_dimensions() {
// dims_fn 返回 None 时不输出 --ar,避免空 style 属性。
let html = r#"<img src="/uploads/x.webp" alt="t">"#;
let result = wrap_images_with_blur_with(html, |_| None);
assert!(!result.contains("--ar"), "无 dimensions 不应有 --ar");
// 但包裹结构仍应生成
assert!(result.contains("blur-img"));
}
#[test]
fn wrap_images_with_blur_strips_query_from_rel_path() {
// rel_path 提取应去 query 后缀,dims_fn 收到的是去 query 的路径。
// dims_fn 是 Fn(replace_all 要求),用 RefCell 捕获传入值。
use std::cell::RefCell;
let html = r#"<img src="/uploads/2026/x.webp?w=100" alt="t">"#;
let received = RefCell::new(String::new());
let _ = wrap_images_with_blur_with(html, |p| {
*received.borrow_mut() = p.to_string();
Some((100, 100))
});
assert_eq!(
received.borrow().as_str(),
"2026/x.webp",
"rel_path 应去 query"
);
}
#[test]
fn wrap_images_with_blur_preserves_alt() {
let html = r#"<img src="/uploads/x.webp" alt="描述">"#;
let result = wrap_images_with_blur_with(html, |_| Some((10, 10)));
assert!(result.contains(r#"alt="描述""#), "placeholder 应保留 alt");
assert!(
result.matches(r#"alt="描述""#).count() == 2,
"两层 img 都应带 alt"
);
}
#[test]
fn wrap_images_with_blur_omits_alt_attr_when_empty() {
let html = r#"<img src="/uploads/x.webp">"#;
let result = wrap_images_with_blur_with(html, |_| None);
assert!(!result.contains("alt=\""), "无 alt 时不应生成空 alt 属性");
}
#[test]
fn full_pipeline_wrap_then_clean_preserves_slash() {
// 模拟完整渲染管线:wrap → clean_html,验证 sanitizer 不破坏斜杠。
// 注入确定 dimensions,脱离文件系统依赖。
let html = r#"<img src="/uploads/2026/06/18/abc.webp" alt="t">"#;
let wrapped = wrap_images_with_blur_with(html, |_| Some((800, 600)));
let cleaned = clean_html(&wrapped);
assert!(
cleaned.contains(" / "),
"clean_html must preserve slash in --ar, got: {}",
cleaned
);
}
#[test]
fn slugify_heading_simple() {
assert_eq!(slugify_heading("Hello World"), "hello-world");
}
#[test]
fn slugify_heading_special_chars() {
assert_eq!(slugify_heading("What's new? (2024)"), "what-s-new-2024");
}
#[test]
fn slugify_heading_chinese() {
// 汉字逐字转拼音,用 `-` 分隔。
assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
}
#[test]
fn slugify_heading_mixed_chinese_ascii() {
// Rust 入门指南 → rust + ru-men-zhi-nan词之间用 `-` 分隔。
assert_eq!(slugify_heading("Rust 入门指南"), "rust-ru-men-zhi-nan");
}
#[test]
fn slugify_heading_collapses_dashes() {
assert_eq!(slugify_heading("a--b"), "a-b");
}
#[test]
fn slugify_heading_strips_trailing_dash() {
assert_eq!(slugify_heading("hello!"), "hello");
}
#[test]
fn slugify_heading_empty_returns_heading() {
assert_eq!(slugify_heading(""), "heading");
}
#[test]
fn clean_html_allows_safe_tags() {
let input = "<p>Hello <strong>world</strong></p>";
assert_eq!(clean_html(input), input);
}
#[test]
fn clean_html_removes_script() {
let input = "<script>alert('xss')</script><p>safe</p>";
let result = clean_html(input);
assert!(!result.contains("script"));
assert!(result.contains("safe"));
}
#[test]
fn clean_html_allows_id_attribute() {
let input = "<h2 id=\"my-heading\">Title</h2>";
let result = clean_html(input);
assert!(result.contains("id=\"my-heading\""));
}
#[test]
fn clean_html_allows_class_attribute() {
let input = "<span class=\"highlight\">text</span>";
let result = clean_html(input);
assert!(result.contains("class=\"highlight\""));
}
#[test]
fn generate_toc_html_empty() {
assert_eq!(generate_toc_html(&[]), "");
}
#[test]
fn generate_toc_html_single_heading() {
let headings = vec![(2u8, "Title".to_string(), "title".to_string())];
let html = generate_toc_html(&headings);
assert!(html.contains("href=\"#title\""));
assert!(html.contains("<ul>"));
assert!(html.contains("</ul>"));
}
#[test]
fn generate_toc_html_nested() {
let headings = vec![
(2u8, "Chapter".to_string(), "chapter".to_string()),
(3u8, "Section".to_string(), "section".to_string()),
];
let html = generate_toc_html(&headings);
assert!(html.contains("href=\"#chapter\""));
assert!(html.contains("href=\"#section\""));
let ul_count = html.matches("<ul>").count();
assert_eq!(ul_count, 2);
}
#[test]
fn generate_toc_html_escapes_quote_in_attr() {
// 标题中的双引号不得越出 aria-label 属性边界。
let headings = vec![(
2u8,
"\" onmouseover=\"alert(1)".to_string(),
"heading".to_string(),
)];
let html = generate_toc_html(&headings);
// aria-label 中的双引号被转义为 &quot;,无法越出属性边界注入新属性。
assert!(
html.contains("aria-label=\"&quot; onmouseover=&quot;alert(1)\""),
"aria-label 应转义内部双引号got: {html}"
);
// 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。
// 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。
let attr_injection = "\" onmouseover=\"";
let injected = html.matches(attr_injection).count();
// 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。
// 注意 aria-label 内部的双引号已变成 &quot;,因此裸的 `" onmouseover="` 不应存在。
assert_eq!(
injected, 0,
"不应存在未转义的属性边界 `\" onmouseover=\"`got: {html}"
);
}
#[test]
fn generate_toc_html_escapes_ampersand_in_attr() {
let headings = vec![(2u8, "A & B".to_string(), "heading".to_string())];
let html = generate_toc_html(&headings);
assert!(
html.contains("aria-label=\"A &amp; B\""),
"& 应在属性中转义got: {html}"
);
}
#[test]
fn generate_toc_html_escapes_less_than_in_attr() {
// `<` 在属性与正文中都应被转义,避免被误解析为标签起始。
let headings = vec![(2u8, "a < b".to_string(), "heading".to_string())];
let html = generate_toc_html(&headings);
assert!(
html.contains("aria-label=\"a &lt; b\""),
"< 应在属性中转义got: {html}"
);
assert!(!html.contains("a < b"));
}
#[test]
fn render_markdown_simple_paragraph() {
let result = render_markdown_enhanced("Hello **world**");
assert!(result.html.contains("<strong>world</strong>"));
assert!(result.toc_html.is_empty());
}
#[test]
fn render_markdown_with_heading_generates_toc() {
let result = render_markdown_enhanced("## My Heading\n\nSome text.");
assert!(result.toc_html.contains("My Heading"));
assert!(result.html.contains("id=\"my-heading\""));
}
#[test]
fn render_markdown_empty() {
let result = render_markdown_enhanced("");
assert_eq!(result.html, "");
assert_eq!(result.toc_html, "");
}
#[test]
fn render_markdown_code_block() {
let result = render_markdown_enhanced("```rust\nfn main() {}\n```");
assert!(result.html.contains(r#"<pre><code class="language-rust">"#));
assert!(result
.html
.contains(r#"<span class="entity name function rust">main</span>"#));
assert!(result
.html
.contains(r#"<span class="storage type function rust">fn</span>"#));
}
#[test]
fn render_markdown_code_block_without_language() {
let result = render_markdown_enhanced("```\nplain text\n```");
assert!(result.html.contains("<pre><code>"));
assert!(!result.html.contains("class=\"language-"));
assert!(result.html.contains("plain text"));
}
#[test]
fn render_markdown_runnable_block_emits_data_attrs() {
// `python runnable` 围栏pre 上挂 data-runnable / data-lang / data-overrides / data-source
// 阅读器据此原地挂载 CodeRunner 组件。
let result = render_markdown_enhanced("```python runnable\nprint('hi')\n```");
assert!(
result.html.contains(r#"data-runnable="true""#),
"应输出 data-runnable, got: {}",
result.html
);
assert!(
result.html.contains(r#"data-lang="python""#),
"应输出 data-lang=python, got: {}",
result.html
);
// 无 overrides 时 data-overrides 为空。
assert!(
result.html.contains(r#"data-overrides="""#),
"无 overrides 时 data-overrides 应为空, got: {}",
result.html
);
// data-source 携带 HTML 转义后的原始源码(单引号转义为 &#x27;)。
assert!(
result
.html
.contains(r#"data-source="print(&#x27;hi&#x27;)"#),
"data-source 应含转义后的源码, got: {}",
result.html
);
// 内部仍带高亮 code 与 language-python。
assert!(
result.html.contains(r#"<code class="language-python">"#),
"应保留高亮 code, got: {}",
result.html
);
}
#[test]
fn render_markdown_runnable_block_with_overrides() {
let result = render_markdown_enhanced(
r#"```node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":false,"cpu_cores":1.0,"output_bytes":1024}
console.log(1)
```"#,
);
assert!(result.html.contains(r#"data-lang="node""#));
// overrides JSON 应被 HTML 转义后放入属性(双引号变 &quot;),不得出现裸引号越界。
// 字段顺序按 serde 派生默认字母序cpu_cores 在前。
assert!(
result
.html
.contains("data-overrides=\"{&quot;cpu_cores&quot;:1.0"),
"overrides 应 HTML 转义, got: {}",
result.html
);
// 安全:不得出现可越出属性边界的裸双引号 JSON。
assert!(
!result.html.contains(r#"data-overrides="{"timeout""#),
"overrides 裸引号越界, got: {}",
result.html
);
}
#[test]
fn render_markdown_runnable_marker_on_unsupported_lang_ignored() {
// 语言不在白名单(rust 已在白名单,改用 ruby)runnable 标记被忽略,输出普通代码块。
let result = render_markdown_enhanced("```ruby runnable\nputs 'hi'\n```");
assert!(
!result.html.contains("data-runnable"),
"不支持的语言不应挂 data-runnable, got: {}",
result.html
);
}
#[test]
fn render_markdown_plain_fence_without_runnable_no_data_attrs() {
let result = render_markdown_enhanced("```python\nprint(1)\n```");
assert!(!result.html.contains("data-runnable"));
assert!(result
.html
.contains(r#"<pre><code class="language-python">"#));
}
#[test]
fn render_markdown_code_block_preserves_html_content() {
let result = render_markdown_enhanced("```html\n<script>alert(1)</script>\n```");
assert!(result.html.contains("<pre><code class=\"language-html\">"));
assert!(!result.html.contains("<script>"));
assert!(result
.html
.contains(r#"<span class="variable function js">alert</span>"#));
assert!(result
.html
.contains(r#"<span class="constant numeric js">1</span>"#));
}
#[test]
fn render_markdown_data_uri_image_removed() {
let result = render_markdown_enhanced("![alt](data:image/svg+xml,%3csvg%3e%3c/svg%3e)");
// 出于 XSS 防护,文章正文不再保留 data URI src。
assert!(
!result.html.contains("data:image/svg+xml"),
"data URI should be removed from img src, got: {}",
result.html
);
assert!(result.html.contains("alt=\"alt\""));
}
#[test]
fn render_markdown_task_list() {
// 端到端验证pulldown-cmark 解析任务列表 → sanitizer 清理后 checkbox 不丢失。
// 覆盖「编辑器写入 → 入库 → 服务端重渲染」链路的最终 HTML 形态。
let result = render_markdown_enhanced("- [ ] 未完成\n- [x] 已完成\n");
// 两个 checkbox 都应保留
assert!(
result.html.contains(r#"type="checkbox""#),
"checkbox type 应保留, got: {}",
result.html
);
// 已勾选项的 checked 属性应保留
assert!(
result.html.contains("checked"),
"checked 属性应保留, got: {}",
result.html
);
// 文本内容保留
assert!(result.html.contains("未完成"));
assert!(result.html.contains("已完成"));
}
#[test]
fn render_markdown_inline_math() {
// $...$ 内联公式pulldown-cmark (ENABLE_MATH) 解析 → katex 渲染成 span。
// sanitizer 放行 span 的 class/styleKaTeX 输出应原样保留。
let result = render_markdown_enhanced("公式 $E = mc^2$ 很重要");
assert!(
result.html.contains("katex"),
"内联公式应渲染为 katex span, got: {}",
result.html
);
// 前后文本保留
assert!(result.html.contains("公式"));
assert!(result.html.contains("很重要"));
}
#[test]
fn render_markdown_display_math() {
// $$...$$ 块级公式:应产出 <p class="math-display"> + katex-display。
let result = render_markdown_enhanced("$$\\frac{a}{b}$$");
assert!(
result.html.contains("math-display"),
"块级公式应用 math-display 段落包裹, got: {}",
result.html
);
assert!(
result.html.contains("katex-display"),
"KaTeX 块级输出应含 katex-display, got: {}",
result.html
);
}
#[test]
fn render_markdown_inline_math_in_heading() {
// 标题内的 $...$ 按内联公式渲染(不应崩,也不应产生块级 <p>)。
let result = render_markdown_enhanced("## 勾股 $a^2 + b^2 = c^2$ 定理");
assert!(
result.html.contains("katex"),
"标题内联公式应渲染, got: {}",
result.html
);
// 标题里不应出现块级公式的 <p class="math-display">
assert!(
!result.html.contains("math-display"),
"标题内不应有块级公式包裹, got: {}",
result.html
);
}
#[test]
fn render_markdown_bad_math_does_not_break() {
// 坏 TeX 不应中断整篇渲染throw_on_error=false 回退到错误 span。
let result = render_markdown_enhanced("正常文本 $\\undefinedmacro{$ 后续文本");
assert!(
result.html.contains("正常文本"),
"坏公式不应破坏前文, got: {}",
result.html
);
assert!(
result.html.contains("后续文本"),
"坏公式不应破坏后文, got: {}",
result.html
);
}
#[test]
fn render_markdown_mermaid_block_not_highlighted() {
// mermaid 块跳过 syntect 高亮:源码应是转义纯文本,不被 <span> 包裹。
// 前端 mermaid.ts 用 textContent 无损提取渲染成 SVG。
let result = render_markdown_enhanced("```mermaid\ngraph LR\n A --> B\n```");
assert!(
result.html.contains(r#"class="language-mermaid""#),
"应保留 language-mermaid class, got: {}",
result.html
);
// 不应被 syntect 高亮(无 text plain span
assert!(
!result.html.contains("text plain"),
"mermaid 源码不应被 syntect 高亮, got: {}",
result.html
);
// 源码内容保留HTML 转义后)。
assert!(result.html.contains("graph LR"));
}
}