1pub fn to_html(text: &str) -> String {
19 let lines: Vec<&str> = text.lines().collect();
20 blocks_to_html(&lines, 0)
21}
22
23pub fn to_html_blocks(text: &str) -> Vec<(usize, String)> {
29 let lines: Vec<&str> = text.lines().collect();
30 let mut out = Vec::new();
31 let mut i = 0;
32 while i < lines.len() {
33 if lines[i].trim_start().is_empty() {
34 i += 1;
35 continue;
36 }
37 let (next, html) = one_block(&lines, i, 0);
38 out.push((i, html));
39 i = next;
40 }
41 out
42}
43
44const MAX_DEPTH: u32 = 8;
47
48fn esc(s: &str) -> String {
49 let mut out = String::with_capacity(s.len());
50 for ch in s.chars() {
51 esc_char(ch, &mut out);
52 }
53 out
54}
55
56fn esc_char(ch: char, out: &mut String) {
57 match ch {
58 '&' => out.push_str("&"),
59 '<' => out.push_str("<"),
60 '>' => out.push_str(">"),
61 '"' => out.push_str("""),
62 '\'' => out.push_str("'"),
63 _ => out.push(ch),
64 }
65}
66
67fn blocks_to_html(lines: &[&str], depth: u32) -> String {
68 let mut out = String::new();
69 let mut i = 0;
70 while i < lines.len() {
71 if lines[i].trim_start().is_empty() {
72 i += 1;
73 continue;
74 }
75 let (next, html) = one_block(lines, i, depth);
76 out.push_str(&html);
77 i = next;
78 }
79 out
80}
81
82fn one_block(lines: &[&str], start: usize, depth: u32) -> (usize, String) {
85 let mut i = start;
86 let trimmed = lines[i].trim_start();
87 if trimmed.starts_with("```") {
88 let mut j = i + 1;
90 let mut code = String::new();
91 while j < lines.len() && !lines[j].trim_start().starts_with("```") {
92 code.push_str(lines[j]);
93 code.push('\n');
94 j += 1;
95 }
96 return (j + 1, format!("<pre><code>{}</code></pre>", esc(&code)));
97 }
98 if let Some((level, content)) = heading(trimmed) {
99 return (i + 1, format!("<h{level}>{}</h{level}>", inline(content, depth)));
100 }
101 if is_rule(trimmed) {
102 return (i + 1, "<hr>".to_string());
103 }
104 if trimmed.starts_with('>') && depth < MAX_DEPTH {
105 let mut inner: Vec<&str> = Vec::new();
106 while i < lines.len() {
107 let t = lines[i].trim_start();
108 let Some(stripped) = t.strip_prefix('>') else { break };
109 inner.push(stripped.strip_prefix(' ').unwrap_or(stripped));
110 i += 1;
111 }
112 return (i, format!("<blockquote>{}</blockquote>", blocks_to_html(&inner, depth + 1)));
113 }
114 if list_item(lines[i]).is_some() {
115 return list_block(lines, i, depth);
116 }
117 let mut parts: Vec<String> = Vec::new();
119 while i < lines.len() {
120 let t = lines[i].trim_start();
121 if t.is_empty() || starts_block(t) {
122 break;
123 }
124 parts.push(inline(lines[i].trim_end(), depth));
125 i += 1;
126 }
127 (i, format!("<p>{}</p>", parts.join("<br>")))
128}
129
130fn starts_block(trimmed: &str) -> bool {
132 trimmed.starts_with("```")
133 || trimmed.starts_with('>')
134 || heading(trimmed).is_some()
135 || is_rule(trimmed)
136 || unordered_item(trimmed).is_some()
137 || ordered_item(trimmed).is_some()
138}
139
140fn heading(trimmed: &str) -> Option<(usize, &str)> {
142 let level = trimmed.bytes().take_while(|&b| b == b'#').count();
143 if (1..=6).contains(&level) {
144 if let Some(content) = trimmed[level..].strip_prefix(' ') {
145 return Some((level, content.trim()));
146 }
147 }
148 None
149}
150
151fn is_rule(trimmed: &str) -> bool {
153 let t: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
154 t.len() >= 3 && (t.chars().all(|c| c == '-') || t.chars().all(|c| c == '*') || t.chars().all(|c| c == '_'))
155}
156
157fn unordered_item(trimmed: &str) -> Option<&str> {
159 for marker in ["- ", "* ", "+ "] {
160 if let Some(rest) = trimmed.strip_prefix(marker) {
161 return Some(rest);
162 }
163 }
164 None
165}
166
167fn ordered_item(trimmed: &str) -> Option<&str> {
169 let digits = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
170 if digits == 0 {
171 return None;
172 }
173 trimmed[digits..].strip_prefix(". ")
174}
175
176#[derive(Clone, Copy, PartialEq)]
177enum ListKind {
178 Unordered,
179 Ordered,
180}
181
182fn list_item(line: &str) -> Option<(usize, ListKind, &str)> {
185 let trimmed = line.trim_start_matches([' ', '\t']);
186 let indent = line.len() - trimmed.len();
187 if let Some(item) = unordered_item(trimmed) {
188 Some((indent, ListKind::Unordered, item))
189 } else {
190 ordered_item(trimmed).map(|item| (indent, ListKind::Ordered, item))
191 }
192}
193
194fn list_block(lines: &[&str], start: usize, depth: u32) -> (usize, String) {
195 let (base_indent, kind, _) = list_item(lines[start]).expect("list_block starts on a recognized list item");
196 let (open, close) = match kind {
197 ListKind::Unordered => ("<ul>", "</ul>"),
198 ListKind::Ordered => ("<ol>", "</ol>"),
199 };
200 let mut out = String::from(open);
201 let mut i = start;
202 while i < lines.len() {
203 let Some((indent, item_kind, item)) = list_item(lines[i]) else { break };
204 if indent != base_indent || item_kind != kind {
205 break;
206 }
207 out.push_str(&format!("<li>{}", inline(item, depth)));
208 i += 1;
209 while i < lines.len() && depth < MAX_DEPTH {
210 let Some((nested_indent, _, _)) = list_item(lines[i]) else { break };
211 if nested_indent <= base_indent {
212 break;
213 }
214 let (next, nested) = list_block(lines, i, depth + 1);
215 out.push_str(&nested);
216 i = next;
217 }
218 out.push_str("</li>");
219 }
220 out.push_str(close);
221 (i, out)
222}
223
224fn emphasizable(inner: &str) -> bool {
227 !inner.is_empty() && inner.trim() == inner
228}
229
230fn safe_url(url: &str) -> bool {
232 url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
233}
234
235fn inline(text: &str, depth: u32) -> String {
238 if depth > MAX_DEPTH {
239 return esc(text);
240 }
241 let mut out = String::new();
242 let mut i = 0;
243 while i < text.len() {
244 let rest = &text[i..];
245 if let Some(after) = rest.strip_prefix('`') {
246 if let Some(n) = after.find('`') {
247 out.push_str(&format!("<code>{}</code>", esc(&after[..n])));
248 i += n + 2;
249 continue;
250 }
251 }
252 if let Some(after) = rest.strip_prefix("**") {
253 if let Some(mut n) = after.find("**") {
254 if after[n..].starts_with("***") {
257 n += 1;
258 }
259 if emphasizable(&after[..n]) {
260 out.push_str(&format!("<strong>{}</strong>", inline(&after[..n], depth + 1)));
261 i += n + 4;
262 continue;
263 }
264 }
265 }
266 if let Some(after) = rest.strip_prefix('*') {
267 if let Some(n) = after.find('*') {
268 if emphasizable(&after[..n]) {
269 out.push_str(&format!("<em>{}</em>", inline(&after[..n], depth + 1)));
270 i += n + 2;
271 continue;
272 }
273 }
274 }
275 if rest.starts_with('[') {
276 if let Some(close) = rest.find("](") {
277 if let Some(end) = rest[close + 2..].find(')') {
278 let label = &rest[1..close];
279 let url = &rest[close + 2..close + 2 + end];
280 if safe_url(url) {
281 out.push_str(&format!(r#"<a href="{}">{}</a>"#, esc(url), inline(label, depth + 1)));
282 i += close + 2 + end + 1;
283 continue;
284 }
285 }
286 }
287 }
288 let ch = rest.chars().next().expect("rest is non-empty inside the loop");
291 esc_char(ch, &mut out);
292 i += ch.len_utf8();
293 }
294 out
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn hostile_html_is_escaped_everywhere() {
303 assert_eq!(to_html("<script>alert(1)</script>"), "<p><script>alert(1)</script></p>");
304 assert_eq!(to_html("# <b>hi</b>"), "<h1><b>hi</b></h1>");
305 assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code><script>x</script>\n</code></pre>");
306 assert_eq!(to_html("`<i>`"), "<p><code><i></code></p>");
307 }
308
309 #[test]
310 fn unsafe_link_schemes_stay_literal_text() {
311 let js = to_html("[x](javascript:alert(1))");
312 assert!(!js.contains("<a "), "{js}");
313 assert!(js.contains("javascript:alert(1)"));
314 let ok = to_html("[docs](https://example.com/a?b=1)");
315 assert_eq!(ok, r#"<p><a href="https://example.com/a?b=1">docs</a></p>"#);
316 }
317
318 #[test]
319 fn headings_paragraphs_and_hard_breaks() {
320 assert_eq!(to_html("## Title"), "<h2>Title</h2>");
321 assert_eq!(to_html("####### seven"), "<p>####### seven</p>");
322 assert_eq!(to_html("line one\nline two"), "<p>line one<br>line two</p>");
323 assert_eq!(to_html("para one\n\npara two"), "<p>para one</p><p>para two</p>");
324 }
325
326 #[test]
327 fn emphasis_code_and_snake_case_survival() {
328 assert_eq!(
329 to_html("**bold** and *em* and `code`"),
330 "<p><strong>bold</strong> and <em>em</em> and <code>code</code></p>"
331 );
332 assert_eq!(to_html("**outer *inner***"), "<p><strong>outer <em>inner</em></strong></p>");
333 assert_eq!(to_html("keep snake_case and __this__ literal"), "<p>keep snake_case and __this__ literal</p>");
334 assert_eq!(to_html("a * b stays literal"), "<p>a * b stays literal</p>");
335 }
336
337 #[test]
338 fn lists_blockquotes_and_rules() {
339 assert_eq!(to_html("- a\n- b"), "<ul><li>a</li><li>b</li></ul>");
340 assert_eq!(to_html("1. a\n2. b"), "<ol><li>a</li><li>b</li></ol>");
341 assert_eq!(to_html("> quoted\n> more"), "<blockquote><p>quoted<br>more</p></blockquote>");
342 assert_eq!(to_html("---"), "<hr>");
343 }
344
345 #[test]
346 fn nested_lists_preserve_source_indentation() {
347 assert_eq!(
348 to_html("- parent\n - child\n - grandchild\n- sibling"),
349 "<ul><li>parent<ul><li>child<ul><li>grandchild</li></ul></li></ul></li><li>sibling</li></ul>"
350 );
351 assert_eq!(
352 to_html("1. parent\n - child\n2. sibling"),
353 "<ol><li>parent<ul><li>child</li></ul></li><li>sibling</li></ol>"
354 );
355 }
356
357 #[test]
358 fn unclosed_fence_runs_to_the_end() {
359 assert_eq!(to_html("```\ncode"), "<pre><code>code\n</code></pre>");
360 }
361
362 #[test]
363 fn multibyte_text_is_preserved() {
364 assert_eq!(to_html("héllo — **wörld** 🚀"), "<p>héllo — <strong>wörld</strong> 🚀</p>");
365 }
366
367 #[test]
368 fn blocks_carry_their_starting_source_lines() {
369 let text = "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q";
372 let blocks = to_html_blocks(text);
373 let lines: Vec<usize> = blocks.iter().map(|(l, _)| *l).collect();
374 assert_eq!(lines, vec![0, 2, 5, 8, 14]);
375 assert!(blocks[0].1.starts_with("<h1>"), "{}", blocks[0].1);
376 assert!(blocks[3].1.starts_with("<pre>"), "{}", blocks[3].1);
377 }
378
379 #[test]
380 fn concatenated_blocks_equal_to_html() {
381 for text in [
382 "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q\n\n---",
383 "plain",
384 "",
385 "> nested\n> > deeper\n\n1. one\n2. two",
386 ] {
387 let joined: String = to_html_blocks(text).into_iter().map(|(_, h)| h).collect();
388 assert_eq!(joined, to_html(text), "for input {text:?}");
389 }
390 }
391}