1pub fn to_html(text: &str) -> String {
19 let lines: Vec<&str> = text.lines().collect();
20 blocks_to_html(&lines, 0)
21}
22
23const MAX_DEPTH: u32 = 8;
26
27fn esc(s: &str) -> String {
28 let mut out = String::with_capacity(s.len());
29 for ch in s.chars() {
30 esc_char(ch, &mut out);
31 }
32 out
33}
34
35fn esc_char(ch: char, out: &mut String) {
36 match ch {
37 '&' => out.push_str("&"),
38 '<' => out.push_str("<"),
39 '>' => out.push_str(">"),
40 '"' => out.push_str("""),
41 '\'' => out.push_str("'"),
42 _ => out.push(ch),
43 }
44}
45
46fn blocks_to_html(lines: &[&str], depth: u32) -> String {
47 let mut out = String::new();
48 let mut i = 0;
49 while i < lines.len() {
50 let trimmed = lines[i].trim_start();
51 if trimmed.is_empty() {
52 i += 1;
53 continue;
54 }
55 if trimmed.starts_with("```") {
56 let mut j = i + 1;
58 let mut code = String::new();
59 while j < lines.len() && !lines[j].trim_start().starts_with("```") {
60 code.push_str(lines[j]);
61 code.push('\n');
62 j += 1;
63 }
64 out.push_str(&format!("<pre><code>{}</code></pre>", esc(&code)));
65 i = j + 1;
66 continue;
67 }
68 if let Some((level, content)) = heading(trimmed) {
69 out.push_str(&format!("<h{level}>{}</h{level}>", inline(content, depth)));
70 i += 1;
71 continue;
72 }
73 if is_rule(trimmed) {
74 out.push_str("<hr>");
75 i += 1;
76 continue;
77 }
78 if trimmed.starts_with('>') && depth < MAX_DEPTH {
79 let mut inner: Vec<&str> = Vec::new();
80 while i < lines.len() {
81 let t = lines[i].trim_start();
82 let Some(stripped) = t.strip_prefix('>') else { break };
83 inner.push(stripped.strip_prefix(' ').unwrap_or(stripped));
84 i += 1;
85 }
86 out.push_str(&format!("<blockquote>{}</blockquote>", blocks_to_html(&inner, depth + 1)));
87 continue;
88 }
89 if unordered_item(trimmed).is_some() {
90 out.push_str("<ul>");
91 while i < lines.len() {
92 let Some(item) = unordered_item(lines[i].trim_start()) else { break };
93 out.push_str(&format!("<li>{}</li>", inline(item, depth)));
94 i += 1;
95 }
96 out.push_str("</ul>");
97 continue;
98 }
99 if ordered_item(trimmed).is_some() {
100 out.push_str("<ol>");
101 while i < lines.len() {
102 let Some(item) = ordered_item(lines[i].trim_start()) else { break };
103 out.push_str(&format!("<li>{}</li>", inline(item, depth)));
104 i += 1;
105 }
106 out.push_str("</ol>");
107 continue;
108 }
109 let mut parts: Vec<String> = Vec::new();
111 while i < lines.len() {
112 let t = lines[i].trim_start();
113 if t.is_empty() || starts_block(t) {
114 break;
115 }
116 parts.push(inline(lines[i].trim_end(), depth));
117 i += 1;
118 }
119 out.push_str(&format!("<p>{}</p>", parts.join("<br>")));
120 }
121 out
122}
123
124fn starts_block(trimmed: &str) -> bool {
126 trimmed.starts_with("```")
127 || trimmed.starts_with('>')
128 || heading(trimmed).is_some()
129 || is_rule(trimmed)
130 || unordered_item(trimmed).is_some()
131 || ordered_item(trimmed).is_some()
132}
133
134fn heading(trimmed: &str) -> Option<(usize, &str)> {
136 let level = trimmed.bytes().take_while(|&b| b == b'#').count();
137 if (1..=6).contains(&level) {
138 if let Some(content) = trimmed[level..].strip_prefix(' ') {
139 return Some((level, content.trim()));
140 }
141 }
142 None
143}
144
145fn is_rule(trimmed: &str) -> bool {
147 let t: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
148 t.len() >= 3 && (t.chars().all(|c| c == '-') || t.chars().all(|c| c == '*') || t.chars().all(|c| c == '_'))
149}
150
151fn unordered_item(trimmed: &str) -> Option<&str> {
153 for marker in ["- ", "* ", "+ "] {
154 if let Some(rest) = trimmed.strip_prefix(marker) {
155 return Some(rest);
156 }
157 }
158 None
159}
160
161fn ordered_item(trimmed: &str) -> Option<&str> {
163 let digits = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
164 if digits == 0 {
165 return None;
166 }
167 trimmed[digits..].strip_prefix(". ")
168}
169
170fn emphasizable(inner: &str) -> bool {
173 !inner.is_empty() && inner.trim() == inner
174}
175
176fn safe_url(url: &str) -> bool {
178 url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
179}
180
181fn inline(text: &str, depth: u32) -> String {
184 if depth > MAX_DEPTH {
185 return esc(text);
186 }
187 let mut out = String::new();
188 let mut i = 0;
189 while i < text.len() {
190 let rest = &text[i..];
191 if let Some(after) = rest.strip_prefix('`') {
192 if let Some(n) = after.find('`') {
193 out.push_str(&format!("<code>{}</code>", esc(&after[..n])));
194 i += n + 2;
195 continue;
196 }
197 }
198 if let Some(after) = rest.strip_prefix("**") {
199 if let Some(mut n) = after.find("**") {
200 if after[n..].starts_with("***") {
203 n += 1;
204 }
205 if emphasizable(&after[..n]) {
206 out.push_str(&format!("<strong>{}</strong>", inline(&after[..n], depth + 1)));
207 i += n + 4;
208 continue;
209 }
210 }
211 }
212 if let Some(after) = rest.strip_prefix('*') {
213 if let Some(n) = after.find('*') {
214 if emphasizable(&after[..n]) {
215 out.push_str(&format!("<em>{}</em>", inline(&after[..n], depth + 1)));
216 i += n + 2;
217 continue;
218 }
219 }
220 }
221 if rest.starts_with('[') {
222 if let Some(close) = rest.find("](") {
223 if let Some(end) = rest[close + 2..].find(')') {
224 let label = &rest[1..close];
225 let url = &rest[close + 2..close + 2 + end];
226 if safe_url(url) {
227 out.push_str(&format!(r#"<a href="{}">{}</a>"#, esc(url), inline(label, depth + 1)));
228 i += close + 2 + end + 1;
229 continue;
230 }
231 }
232 }
233 }
234 let ch = rest.chars().next().expect("rest is non-empty inside the loop");
237 esc_char(ch, &mut out);
238 i += ch.len_utf8();
239 }
240 out
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn hostile_html_is_escaped_everywhere() {
249 assert_eq!(to_html("<script>alert(1)</script>"), "<p><script>alert(1)</script></p>");
250 assert_eq!(to_html("# <b>hi</b>"), "<h1><b>hi</b></h1>");
251 assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code><script>x</script>\n</code></pre>");
252 assert_eq!(to_html("`<i>`"), "<p><code><i></code></p>");
253 }
254
255 #[test]
256 fn unsafe_link_schemes_stay_literal_text() {
257 let js = to_html("[x](javascript:alert(1))");
258 assert!(!js.contains("<a "), "{js}");
259 assert!(js.contains("javascript:alert(1)"));
260 let ok = to_html("[docs](https://example.com/a?b=1)");
261 assert_eq!(ok, r#"<p><a href="https://example.com/a?b=1">docs</a></p>"#);
262 }
263
264 #[test]
265 fn headings_paragraphs_and_hard_breaks() {
266 assert_eq!(to_html("## Title"), "<h2>Title</h2>");
267 assert_eq!(to_html("####### seven"), "<p>####### seven</p>");
268 assert_eq!(to_html("line one\nline two"), "<p>line one<br>line two</p>");
269 assert_eq!(to_html("para one\n\npara two"), "<p>para one</p><p>para two</p>");
270 }
271
272 #[test]
273 fn emphasis_code_and_snake_case_survival() {
274 assert_eq!(
275 to_html("**bold** and *em* and `code`"),
276 "<p><strong>bold</strong> and <em>em</em> and <code>code</code></p>"
277 );
278 assert_eq!(to_html("**outer *inner***"), "<p><strong>outer <em>inner</em></strong></p>");
279 assert_eq!(to_html("keep snake_case and __this__ literal"), "<p>keep snake_case and __this__ literal</p>");
280 assert_eq!(to_html("a * b stays literal"), "<p>a * b stays literal</p>");
281 }
282
283 #[test]
284 fn lists_blockquotes_and_rules() {
285 assert_eq!(to_html("- a\n- b"), "<ul><li>a</li><li>b</li></ul>");
286 assert_eq!(to_html("1. a\n2. b"), "<ol><li>a</li><li>b</li></ol>");
287 assert_eq!(to_html("> quoted\n> more"), "<blockquote><p>quoted<br>more</p></blockquote>");
288 assert_eq!(to_html("---"), "<hr>");
289 }
290
291 #[test]
292 fn unclosed_fence_runs_to_the_end() {
293 assert_eq!(to_html("```\ncode"), "<pre><code>code\n</code></pre>");
294 }
295
296 #[test]
297 fn multibyte_text_is_preserved() {
298 assert_eq!(to_html("héllo — **wörld** 🚀"), "<p>héllo — <strong>wörld</strong> 🚀</p>");
299 }
300}