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")]
36fn 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")]
49pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
51 use pulldown_cmark::{Event, HeadingLevel, Options, Tag, TagEnd};
52 use std::fmt::Write as _;
53
54 let mut opts = Options::all();
65 opts.remove(Options::ENABLE_OLD_FOOTNOTES);
66 opts.insert(Options::ENABLE_FOOTNOTES);
67
68 let events: Vec<Event> = pulldown_cmark::Parser::new_ext(md, opts).collect();
72
73 let mut headings: Vec<(u8, String, String)> = Vec::new();
76 let mut current_heading: Option<(u8, String)> = None;
77
78 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 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 let fn_num: HashMap<&String, usize> = fn_order
133 .iter()
134 .enumerate()
135 .map(|(i, label)| (label, i + 1))
136 .collect();
137
138 let toc_html = generate_toc_html(&headings);
140
141 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 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 let mut fn_ref_seen: HashMap<String, usize> = HashMap::new();
157 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 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 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 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 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 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 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 let highlighted =
245 crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
246 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 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 let rendered = crate::api::katex::render_inline(&tex);
276 if in_heading {
277 html.push_str(&rendered);
279 } else {
280 non_heading_events.push(Event::InlineHtml(rendered.into()));
281 }
282 }
283 Event::DisplayMath(tex) => {
284 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 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 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 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.len() + 1
332 });
333 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 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 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 if ref_count > 0 {
356 let _ = write!(
358 html,
359 r##"<a href="#fnref:{id}-1" class="fn-backref" role="doc-backlink" aria-label="返回正文">↩</a>"##
360 );
361 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 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 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 let html = wrap_tables(&html);
400 RenderedContent {
401 html: clean_html(&html),
402 toc_html,
403 }
404}
405
406#[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#[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 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: ®ex::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 let rel_path = src
444 .strip_prefix("/uploads/")
445 .unwrap_or(src)
446 .split('?')
447 .next()
448 .unwrap_or("");
449
450 let ar_style = dims_fn(rel_path)
453 .map(|(w, h)| format!(" style=\"--ar:{} / {};\"", w, h))
454 .unwrap_or_default();
455
456 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")]
474fn 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: ®ex::Captures| {
494 format!("<div class=\"table-wrap\">{}</div>", &caps[0])
495 })
496 .into_owned()
497}
498
499#[cfg(feature = "server")]
500fn 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 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 for _ in prev_level..level {
521 html.push_str("<ul>");
522 stack.push(level);
523 }
524 } else if level < prev_level {
525 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 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 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")]
562fn 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 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")]
600fn footnote_id(label: &str) -> String {
610 let mut out = String::with_capacity(label.len());
611 let mut prev_dash = true; 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 out.push(c);
620 prev_dash = false;
621 } else if !prev_dash {
622 out.push('-');
624 prev_dash = true;
625 }
626 }
627
628 while out.ends_with('-') {
630 out.pop();
631 }
632
633 if out.is_empty() {
634 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 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 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 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 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 assert!(result.contains("blur-img"));
695 }
696
697 #[test]
698 fn wrap_images_with_blur_strips_query_from_rel_path() {
699 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 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 #[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 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 assert!(result.contains("\n<p>间隔</p>\n"));
784 }
785
786 #[test]
787 fn wrap_tables_handles_multiline_table() {
788 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 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 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 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 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 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 assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
908 }
909
910 #[test]
911 fn slugify_heading_mixed_chinese_ascii() {
912 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 let headings = vec![(
990 2u8,
991 "\" onmouseover=\"alert(1)".to_string(),
992 "heading".to_string(),
993 )];
994 let html = generate_toc_html(&headings);
995 assert!(
997 html.contains("aria-label=\"" onmouseover="alert(1)\""),
998 "aria-label 应转义内部双引号,got: {html}"
999 );
1000 let attr_injection = "\" onmouseover=\"";
1003 let injected = html.matches(attr_injection).count();
1004 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 & B\""),
1018 "& 应在属性中转义,got: {html}"
1019 );
1020 }
1021
1022 #[test]
1023 fn generate_toc_html_escapes_less_than_in_attr() {
1024 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 < 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 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 assert!(
1092 result.html.contains(r#"data-overrides="""#),
1093 "无 overrides 时 data-overrides 应为空, got: {}",
1094 result.html
1095 );
1096 assert!(
1098 result
1099 .html
1100 .contains(r#"data-source="print('hi')"#),
1101 "data-source 应含转义后的源码, got: {}",
1102 result.html
1103 );
1104 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 assert!(
1123 result
1124 .html
1125 .contains("data-overrides=\"{"cpu_cores":1.0"),
1126 "overrides 应 HTML 转义, got: {}",
1127 result.html
1128 );
1129 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 let cases = [
1144 ("js", "node"),
1145 ("javascript", "node"),
1146 ("rs", "rust"),
1147 ("ts", "bun"),
1148 ("typescript", "bun"),
1149 ("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 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 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 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("");
1218 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 let result = render_markdown_enhanced("- [ ] 未完成\n- [x] 已完成\n");
1232 assert!(
1234 result.html.contains(r#"type="checkbox""#),
1235 "checkbox type 应保留, got: {}",
1236 result.html
1237 );
1238 assert!(
1240 result.html.contains("checked"),
1241 "checked 属性应保留, got: {}",
1242 result.html
1243 );
1244 assert!(result.html.contains("未完成"));
1246 assert!(result.html.contains("已完成"));
1247 }
1248
1249 #[test]
1250 fn render_markdown_footnote_basic() {
1251 let result = render_markdown_enhanced("正文[^a]\n\n[^a]: 脚注内容\n");
1254 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 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 assert!(
1279 result.html.contains("↩") && result.html.contains(r#"role="doc-backlink""#),
1280 "单个引用应有 ↩ back-link, got: {}",
1281 result.html
1282 );
1283 assert!(
1285 result.html.contains(r##"href="#fnref:a-1""##),
1286 "back-link 应指向引用位置, got: {}",
1287 result.html
1288 );
1289 assert!(result.html.contains("脚注内容"));
1291 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 let result = render_markdown_enhanced("第一处[^x],第二处[^x],第三处[^x]\n\n[^x]: 注\n");
1303 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 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 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 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 let result =
1348 render_markdown_enhanced("先引用第二个[^b],再第一个[^a]\n\n[^b]: B注\n\n[^a]: A注\n");
1349 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 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 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 let result = render_markdown_enhanced("引文[^my note]\n\n[^my note]: 内容\n");
1377 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 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 let result = render_markdown_enhanced("这个[^missing]没有定义\n");
1402 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 let result = render_markdown_enhanced("正文[^g]\n\n[^g]: 脚注第一行\n独立段落\n");
1426 assert!(
1428 result.html.contains(r#"id="fn:g""#),
1429 "脚注定义应存在, got: {}",
1430 result.html
1431 );
1432 assert!(
1434 result.html.contains("独立段落"),
1435 "独立段落内容应保留, got: {}",
1436 result.html
1437 );
1438 }
1439
1440 #[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 assert_eq!(footnote_id("参考文献1"), "参考文献1");
1452 }
1453
1454 #[test]
1455 fn footnote_id_replaces_spaces_and_punctuation() {
1456 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 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 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 assert_eq!(footnote_id("!@#$"), "fn");
1502 assert_eq!(footnote_id("!!!"), "fn");
1503 }
1504
1505 #[test]
1506 fn render_markdown_inline_math() {
1507 let result = render_markdown_enhanced("公式 $E = mc^2$ 很重要");
1510 assert!(
1511 result.html.contains("katex"),
1512 "内联公式应渲染为 katex span, got: {}",
1513 result.html
1514 );
1515 assert!(result.html.contains("公式"));
1517 assert!(result.html.contains("很重要"));
1518 }
1519
1520 #[test]
1521 fn render_markdown_inline_math_text_mode_middle_dot() {
1522 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 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 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 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 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 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 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 assert!(
1618 !result.html.contains("text plain"),
1619 "mermaid 源码不应被 syntect 高亮, got: {}",
1620 result.html
1621 );
1622 assert!(result.html.contains("graph LR"));
1624 }
1625}