1#![allow(clippy::unused_unit, deprecated)]
8
9#[cfg(feature = "server")]
10pub(crate) fn clean_html(input: &str) -> String {
12 crate::api::sanitizer::clean_html(input)
13}
14
15#[cfg(feature = "server")]
16fn escape_heading_text(s: &str) -> String {
22 crate::utils::html::escape_html(s)
23}
24
25#[derive(Debug, Clone)]
26#[cfg(feature = "server")]
27pub struct RenderedContent {
29 pub html: String,
31 pub toc_html: String,
33}
34
35#[cfg(feature = "server")]
36pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
38 use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
39 use std::fmt::Write as _;
40
41 let mut opts = Options::all();
52 opts.remove(Options::ENABLE_OLD_FOOTNOTES);
53 opts.insert(Options::ENABLE_FOOTNOTES);
54
55 let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
59
60 let mut headings: Vec<(u8, String, String)> = Vec::new();
63 let mut current_heading: Option<(u8, String)> = None;
64
65 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 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 let fn_num: HashMap<&String, usize> = fn_order
120 .iter()
121 .enumerate()
122 .map(|(i, label)| (label, i + 1))
123 .collect();
124
125 let toc_html = generate_toc_html(&headings);
127
128 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 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 let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
144 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 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 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 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 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 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 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 let highlighted =
246 crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
247 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 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 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 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 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 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 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 let num = fn_num.get(&label).copied().unwrap_or(n);
308 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 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.len() + 1
325 });
326 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 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 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 if ref_count > 0 {
349 let _ = write!(
351 html,
352 r##"<a href="#fnref:{id}-1" class="fn-backref" role="doc-backlink" aria-label="返回正文">↩</a>"##
353 );
354 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 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 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 let html = wrap_tables(&html);
393 RenderedContent {
394 html: clean_html(&html),
395 toc_html,
396 }
397}
398
399#[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#[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 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: ®ex::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 let rel_path = src
437 .strip_prefix("/uploads/")
438 .unwrap_or(src)
439 .split('?')
440 .next()
441 .unwrap_or("");
442
443 let ar_style = dims_fn(rel_path)
446 .map(|(w, h)| format!(" style=\"--ar:{} / {};\"", w, h))
447 .unwrap_or_default();
448
449 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")]
467fn 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: ®ex::Captures| {
487 format!("<div class=\"table-wrap\">{}</div>", &caps[0])
488 })
489 .to_string()
490}
491
492#[cfg(feature = "server")]
493fn 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 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 for _ in prev_level..level {
514 html.push_str("<ul>");
515 stack.push(level);
516 }
517 } else if level < prev_level {
518 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 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 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")]
555fn 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 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")]
593fn footnote_id(label: &str) -> String {
603 let mut out = String::with_capacity(label.len());
604 let mut prev_dash = true; 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 out.push(c);
613 prev_dash = false;
614 } else if !prev_dash {
615 out.push('-');
617 prev_dash = true;
618 }
619 }
620
621 while out.ends_with('-') {
623 out.pop();
624 }
625
626 if out.is_empty() {
627 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 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 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 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 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 assert!(result.contains("blur-img"));
688 }
689
690 #[test]
691 fn wrap_images_with_blur_strips_query_from_rel_path() {
692 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 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 #[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 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 assert!(result.contains("\n<p>间隔</p>\n"));
777 }
778
779 #[test]
780 fn wrap_tables_handles_multiline_table() {
781 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 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 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 assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
844 }
845
846 #[test]
847 fn slugify_heading_mixed_chinese_ascii() {
848 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 let headings = vec![(
926 2u8,
927 "\" onmouseover=\"alert(1)".to_string(),
928 "heading".to_string(),
929 )];
930 let html = generate_toc_html(&headings);
931 assert!(
933 html.contains("aria-label=\"" onmouseover="alert(1)\""),
934 "aria-label 应转义内部双引号,got: {html}"
935 );
936 let attr_injection = "\" onmouseover=\"";
939 let injected = html.matches(attr_injection).count();
940 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 & B\""),
954 "& 应在属性中转义,got: {html}"
955 );
956 }
957
958 #[test]
959 fn generate_toc_html_escapes_less_than_in_attr() {
960 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 < 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 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 assert!(
1028 result.html.contains(r#"data-overrides="""#),
1029 "无 overrides 时 data-overrides 应为空, got: {}",
1030 result.html
1031 );
1032 assert!(
1034 result
1035 .html
1036 .contains(r#"data-source="print('hi')"#),
1037 "data-source 应含转义后的源码, got: {}",
1038 result.html
1039 );
1040 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 assert!(
1059 result
1060 .html
1061 .contains("data-overrides=\"{"cpu_cores":1.0"),
1062 "overrides 应 HTML 转义, got: {}",
1063 result.html
1064 );
1065 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 let cases = [
1080 ("js", "node"),
1081 ("javascript", "node"),
1082 ("rs", "rust"),
1083 ("ts", "bun"),
1084 ("typescript", "bun"),
1085 ("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 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 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 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("");
1154 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 let result = render_markdown_enhanced("- [ ] 未完成\n- [x] 已完成\n");
1168 assert!(
1170 result.html.contains(r#"type="checkbox""#),
1171 "checkbox type 应保留, got: {}",
1172 result.html
1173 );
1174 assert!(
1176 result.html.contains("checked"),
1177 "checked 属性应保留, got: {}",
1178 result.html
1179 );
1180 assert!(result.html.contains("未完成"));
1182 assert!(result.html.contains("已完成"));
1183 }
1184
1185 #[test]
1186 fn render_markdown_footnote_basic() {
1187 let result = render_markdown_enhanced("正文[^a]\n\n[^a]: 脚注内容\n");
1190 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 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 assert!(
1215 result.html.contains("↩") && result.html.contains(r#"role="doc-backlink""#),
1216 "单个引用应有 ↩ back-link, got: {}",
1217 result.html
1218 );
1219 assert!(
1221 result.html.contains(r##"href="#fnref:a-1""##),
1222 "back-link 应指向引用位置, got: {}",
1223 result.html
1224 );
1225 assert!(result.html.contains("脚注内容"));
1227 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 let result = render_markdown_enhanced("第一处[^x],第二处[^x],第三处[^x]\n\n[^x]: 注\n");
1239 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 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 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 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 let result =
1284 render_markdown_enhanced("先引用第二个[^b],再第一个[^a]\n\n[^b]: B注\n\n[^a]: A注\n");
1285 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 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 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 let result = render_markdown_enhanced("引文[^my note]\n\n[^my note]: 内容\n");
1313 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 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 let result = render_markdown_enhanced("这个[^missing]没有定义\n");
1338 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 let result = render_markdown_enhanced("正文[^g]\n\n[^g]: 脚注第一行\n独立段落\n");
1362 assert!(
1364 result.html.contains(r#"id="fn:g""#),
1365 "脚注定义应存在, got: {}",
1366 result.html
1367 );
1368 assert!(
1370 result.html.contains("独立段落"),
1371 "独立段落内容应保留, got: {}",
1372 result.html
1373 );
1374 }
1375
1376 #[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 assert_eq!(footnote_id("参考文献1"), "参考文献1");
1388 }
1389
1390 #[test]
1391 fn footnote_id_replaces_spaces_and_punctuation() {
1392 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 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 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 assert_eq!(footnote_id("!@#$"), "fn");
1438 assert_eq!(footnote_id("!!!"), "fn");
1439 }
1440
1441 #[test]
1442 fn render_markdown_inline_math() {
1443 let result = render_markdown_enhanced("公式 $E = mc^2$ 很重要");
1446 assert!(
1447 result.html.contains("katex"),
1448 "内联公式应渲染为 katex span, got: {}",
1449 result.html
1450 );
1451 assert!(result.html.contains("公式"));
1453 assert!(result.html.contains("很重要"));
1454 }
1455
1456 #[test]
1457 fn render_markdown_display_math() {
1458 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 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 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 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 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 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 assert!(
1535 !result.html.contains("text plain"),
1536 "mermaid 源码不应被 syntect 高亮, got: {}",
1537 result.html
1538 );
1539 assert!(result.html.contains("graph LR"));
1541 }
1542}