yggdrasil/api/comments/
markdown.rs1#![allow(clippy::unused_unit, deprecated)]
7
8#[cfg(feature = "server")]
12pub(crate) fn clean_comment_html(input: &str) -> String {
13 crate::api::sanitizer::clean_comment_html(input)
14}
15
16#[cfg(feature = "server")]
22pub fn render_comment_markdown(md: &str) -> String {
23 use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd};
24
25 let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_MATH;
26 let parser = pulldown_cmark::Parser::new_ext(md, opts);
27
28 let mut events: Vec<Event> = Vec::new();
29 let mut in_codeblock = false;
30 let mut code_lang: Option<String> = None;
31 let mut code_buffer = String::new();
32
33 for event in parser {
35 match event {
36 Event::InlineMath(tex) => {
37 let html = crate::api::katex::render_inline(&tex);
39 events.push(Event::Html(html.into()));
40 }
41 Event::DisplayMath(tex) => {
42 let html = crate::api::katex::render_display(&tex);
45 events.push(Event::Html(html.into()));
46 }
47 Event::Start(Tag::Heading { .. }) => {
48 events.push(Event::Start(Tag::Strong));
50 }
51 Event::End(TagEnd::Heading(_)) => {
52 events.push(Event::End(TagEnd::Strong));
53 }
54 Event::Start(Tag::CodeBlock(kind)) => {
55 in_codeblock = true;
56 code_lang = match kind {
57 CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
58 _ => None,
59 };
60 code_buffer.clear();
61 }
62 Event::Text(text) if in_codeblock => {
63 code_buffer.push_str(&text);
64 }
65 Event::End(TagEnd::CodeBlock) => {
66 let html = if let Some(ref lang) = code_lang {
68 let highlighted =
69 crate::highlight::server::highlight_code(&code_buffer, Some(lang));
70 format!("<pre><code>{}</code></pre>", highlighted)
71 } else {
72 format!(
73 "<pre><code>{}</code></pre>",
74 crate::utils::html::escape_html(&code_buffer)
75 )
76 };
77 events.push(Event::Html(html.into()));
78 in_codeblock = false;
79 }
80 _ if !in_codeblock => {
81 events.push(event);
82 }
83 _ => {}
84 }
85 }
86
87 let mut html = String::new();
88 pulldown_cmark::html::push_html(&mut html, events.into_iter());
89 clean_comment_html(&html)
90}
91
92#[cfg(all(test, feature = "server"))]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn render_comment_heading_converted_to_strong() {
98 let result = render_comment_markdown("## Hello World");
99 assert!(result.contains("<strong>Hello World</strong>"));
100 assert!(!result.contains("<h2>"));
101 }
102
103 #[test]
104 fn render_comment_heading_all_levels() {
105 for md in &[
106 "# H1",
107 "## H2",
108 "### H3",
109 "#### H4",
110 "##### H5",
111 "###### H6",
112 ] {
113 let result = render_comment_markdown(md);
114 assert!(
115 result.contains("<strong>"),
116 "heading not converted for: {}",
117 md
118 );
119 }
120 }
121
122 #[test]
123 fn render_comment_paragraph() {
124 let result = render_comment_markdown("Hello **world**");
125 assert!(result.contains("<strong>world</strong>"));
126 }
127
128 #[test]
129 fn render_comment_code_block_with_language() {
130 let result = render_comment_markdown("```rust\nfn main() {}\n```");
131 assert!(result.contains("<pre><code>"));
132 assert!(result.contains("main"));
133 }
134
135 #[test]
136 fn render_comment_code_block_without_language() {
137 let result = render_comment_markdown("```\nplain text\n```");
138 assert!(result.contains("<pre><code>"));
139 assert!(result.contains("plain text"));
140 }
141
142 #[test]
143 fn render_comment_code_block_without_language_escapes_html() {
144 let result = render_comment_markdown("```\n<div>alert('xss')</div>\n```");
145 assert!(result.contains("<div>"));
146 assert!(!result.contains("<div>"));
147 }
148
149 #[test]
150 fn render_comment_strips_script() {
151 let result = render_comment_markdown("<script>alert('xss')</script>");
152 assert!(!result.contains("script"));
153 }
154
155 #[test]
156 fn render_comment_renders_img() {
157 let result = render_comment_markdown("");
159 assert!(result.contains("<img"), "应渲染 img: {result}");
160 assert!(
161 result.contains(r#"src="/uploads/2026/08/a.webp""#),
162 "src 保留: {result}"
163 );
164 assert!(result.contains(r#"alt="截图""#), "alt 保留: {result}");
165
166 let result = render_comment_markdown("");
167 assert!(
168 result.contains(r#"src="https://example.com/img.png""#),
169 "https 图床保留: {result}"
170 );
171 }
172
173 #[test]
174 fn render_comment_img_unsafe_src_removed() {
175 let result = render_comment_markdown(")");
177 assert!(
178 !result.contains("javascript:"),
179 "危险 src 必须清除: {result}"
180 );
181 }
182
183 #[test]
184 fn render_comment_link_has_nofollow() {
185 let result = render_comment_markdown("[link](https://example.com)");
186 assert!(result.contains("nofollow"));
187 assert!(result.contains("noopener"));
188 }
189
190 #[test]
191 fn render_comment_link_javascript_removed() {
192 let result = render_comment_markdown("[click](javascript:alert(1))");
193 assert!(result.contains("click"));
194 assert!(!result.contains("javascript:"));
195 }
196
197 #[test]
198 fn render_comment_onerror_attribute_removed() {
199 let result = render_comment_markdown("<div onerror=\"alert(1)\">text</div>");
200 assert!(result.contains("text"));
201 assert!(!result.contains("onerror"));
202 }
203
204 #[test]
205 fn render_comment_link_data_uri_removed() {
206 let result = render_comment_markdown("[click](data:text/html,<script>alert(1)</script>)");
207 assert!(result.contains("click"));
208 assert!(!result.contains("data:"));
209 }
210
211 #[test]
212 fn render_comment_code_block_escapes_html_entities() {
213 let result = render_comment_markdown("```\n&\n```");
214 assert!(result.contains("&amp;"));
215 }
216
217 #[test]
218 fn render_comment_no_id_attribute() {
219 let result = render_comment_markdown("<div id=\"test\">text</div>");
220 assert!(!result.contains("id="));
221 }
222
223 #[test]
224 fn render_comment_table() {
225 let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |");
226 assert!(result.contains("<table>"));
227 }
228
229 #[test]
230 fn render_comment_strikethrough() {
231 let result = render_comment_markdown("~~deleted~~");
232 assert!(result.contains("<del>deleted</del>"));
233 }
234
235 #[test]
236 fn render_comment_inline_code() {
237 let result = render_comment_markdown("Use `println!` to print");
238 assert!(result.contains("<code>println!</code>"));
239 }
240
241 #[test]
242 fn clean_comment_html_removes_details_summary() {
243 let result =
244 clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
245 assert!(!result.contains("details"));
246 assert!(!result.contains("summary"));
247 }
248
249 #[test]
250 fn clean_comment_html_removes_data_uri() {
251 let result =
252 clean_comment_html("<a href=\"data:text/html,<script>alert(1)</script>\">click</a>");
253 assert!(!result.contains("data:"));
254 }
255
256 #[test]
257 fn render_comment_empty() {
258 let result = render_comment_markdown("");
259 assert!(result.is_empty());
260 }
261
262 #[test]
263 fn render_comment_heading_with_inline_code() {
264 let result = render_comment_markdown("## Using `foo()`");
265 assert!(result.contains("<strong>"));
266 assert!(result.contains("<code>foo()</code>"));
267 assert!(!result.contains("<h2>"));
268 }
269
270 #[test]
271 fn render_comment_inline_math() {
272 let result = render_comment_markdown("方程 $a^2 + b^2 = c^2$ 是勾股定理");
274 assert!(
275 result.contains("katex"),
276 "评论内联公式应渲染为 katex span, got: {}",
277 result
278 );
279 assert!(result.contains("方程"));
280 assert!(result.contains("勾股定理"));
281 }
282
283 #[test]
284 fn render_comment_display_math() {
285 let result = render_comment_markdown("$$\\int_0^1 x\\,dx$$");
287 assert!(
288 result.contains("katex-display"),
289 "评论块级公式应含 katex-display, got: {}",
290 result
291 );
292 }
293
294 #[test]
295 fn render_comment_math_span_style_preserved() {
296 let result = render_comment_markdown("$x^2$");
299 assert!(
300 result.contains("style=\""),
301 "评论 katex span 的 style 应保留, got: {}",
302 result
303 );
304 }
305}