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_no_img_tags() {
157 let result = render_comment_markdown("");
158 assert!(!result.contains("<img"));
159 }
160
161 #[test]
162 fn render_comment_link_has_nofollow() {
163 let result = render_comment_markdown("[link](https://example.com)");
164 assert!(result.contains("nofollow"));
165 assert!(result.contains("noopener"));
166 }
167
168 #[test]
169 fn render_comment_link_javascript_removed() {
170 let result = render_comment_markdown("[click](javascript:alert(1))");
171 assert!(result.contains("click"));
172 assert!(!result.contains("javascript:"));
173 }
174
175 #[test]
176 fn render_comment_onerror_attribute_removed() {
177 let result = render_comment_markdown("<div onerror=\"alert(1)\">text</div>");
178 assert!(result.contains("text"));
179 assert!(!result.contains("onerror"));
180 }
181
182 #[test]
183 fn render_comment_link_data_uri_removed() {
184 let result = render_comment_markdown("[click](data:text/html,<script>alert(1)</script>)");
185 assert!(result.contains("click"));
186 assert!(!result.contains("data:"));
187 }
188
189 #[test]
190 fn render_comment_code_block_escapes_html_entities() {
191 let result = render_comment_markdown("```\n&\n```");
192 assert!(result.contains("&amp;"));
193 }
194
195 #[test]
196 fn render_comment_no_id_attribute() {
197 let result = render_comment_markdown("<div id=\"test\">text</div>");
198 assert!(!result.contains("id="));
199 }
200
201 #[test]
202 fn render_comment_table() {
203 let result = render_comment_markdown("| a | b |\n|---|---|\n| 1 | 2 |");
204 assert!(result.contains("<table>"));
205 }
206
207 #[test]
208 fn render_comment_strikethrough() {
209 let result = render_comment_markdown("~~deleted~~");
210 assert!(result.contains("<del>deleted</del>"));
211 }
212
213 #[test]
214 fn render_comment_inline_code() {
215 let result = render_comment_markdown("Use `println!` to print");
216 assert!(result.contains("<code>println!</code>"));
217 }
218
219 #[test]
220 fn clean_comment_html_removes_details_summary() {
221 let result =
222 clean_comment_html("<details><summary>Click</summary><p>Content</p></details>");
223 assert!(!result.contains("details"));
224 assert!(!result.contains("summary"));
225 }
226
227 #[test]
228 fn clean_comment_html_removes_data_uri() {
229 let result =
230 clean_comment_html("<a href=\"data:text/html,<script>alert(1)</script>\">click</a>");
231 assert!(!result.contains("data:"));
232 }
233
234 #[test]
235 fn render_comment_empty() {
236 let result = render_comment_markdown("");
237 assert!(result.is_empty());
238 }
239
240 #[test]
241 fn render_comment_heading_with_inline_code() {
242 let result = render_comment_markdown("## Using `foo()`");
243 assert!(result.contains("<strong>"));
244 assert!(result.contains("<code>foo()</code>"));
245 assert!(!result.contains("<h2>"));
246 }
247
248 #[test]
249 fn render_comment_inline_math() {
250 let result = render_comment_markdown("方程 $a^2 + b^2 = c^2$ 是勾股定理");
252 assert!(
253 result.contains("katex"),
254 "评论内联公式应渲染为 katex span, got: {}",
255 result
256 );
257 assert!(result.contains("方程"));
258 assert!(result.contains("勾股定理"));
259 }
260
261 #[test]
262 fn render_comment_display_math() {
263 let result = render_comment_markdown("$$\\int_0^1 x\\,dx$$");
265 assert!(
266 result.contains("katex-display"),
267 "评论块级公式应含 katex-display, got: {}",
268 result
269 );
270 }
271
272 #[test]
273 fn render_comment_math_span_style_preserved() {
274 let result = render_comment_markdown("$x^2$");
277 assert!(
278 result.contains("style=\""),
279 "评论 katex span 的 style 应保留, got: {}",
280 result
281 );
282 }
283}