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
44pub fn to_html_review_blocks(text: &str) -> Vec<(usize, String)> {
49 let lines: Vec<&str> = text.lines().collect();
50 let mut out = Vec::new();
51 let mut i = 0;
52 while i < lines.len() {
53 if lines[i].trim_start().is_empty() {
54 i += 1;
55 continue;
56 }
57 if list_item(lines[i]).is_some() {
58 let (next, items) = list_review_blocks(&lines, i, 0);
59 out.extend(items);
60 i = next;
61 } else {
62 let (next, html) = one_block(&lines, i, 0);
63 out.push((i, html));
64 i = next;
65 }
66 }
67 out
68}
69
70const MAX_DEPTH: u32 = 8;
73
74fn esc(s: &str) -> String {
75 let mut out = String::with_capacity(s.len());
76 for ch in s.chars() {
77 esc_char(ch, &mut out);
78 }
79 out
80}
81
82fn esc_char(ch: char, out: &mut String) {
83 match ch {
84 '&' => out.push_str("&"),
85 '<' => out.push_str("<"),
86 '>' => out.push_str(">"),
87 '"' => out.push_str("""),
88 '\'' => out.push_str("'"),
89 _ => out.push(ch),
90 }
91}
92
93fn blocks_to_html(lines: &[&str], depth: u32) -> String {
94 let mut out = String::new();
95 let mut i = 0;
96 while i < lines.len() {
97 if lines[i].trim_start().is_empty() {
98 i += 1;
99 continue;
100 }
101 let (next, html) = one_block(lines, i, depth);
102 out.push_str(&html);
103 i = next;
104 }
105 out
106}
107
108fn one_block(lines: &[&str], start: usize, depth: u32) -> (usize, String) {
111 let mut i = start;
112 let trimmed = lines[i].trim_start();
113 if trimmed.starts_with("```") {
114 let mut j = i + 1;
116 let mut code = String::new();
117 while j < lines.len() && !lines[j].trim_start().starts_with("```") {
118 code.push_str(lines[j]);
119 code.push('\n');
120 j += 1;
121 }
122 return (j + 1, format!("<pre><code>{}</code></pre>", esc(&code)));
123 }
124 if let Some((level, content)) = heading(trimmed) {
125 return (i + 1, format!("<h{level}>{}</h{level}>", inline(content, depth)));
126 }
127 if is_rule(trimmed) {
128 return (i + 1, "<hr>".to_string());
129 }
130 if trimmed.starts_with('>') && depth < MAX_DEPTH {
131 let mut inner: Vec<&str> = Vec::new();
132 while i < lines.len() {
133 let t = lines[i].trim_start();
134 let Some(stripped) = t.strip_prefix('>') else { break };
135 inner.push(stripped.strip_prefix(' ').unwrap_or(stripped));
136 i += 1;
137 }
138 return (i, format!("<blockquote>{}</blockquote>", blocks_to_html(&inner, depth + 1)));
139 }
140 if list_item(lines[i]).is_some() {
141 return list_block(lines, i, depth);
142 }
143 let mut parts: Vec<String> = Vec::new();
145 while i < lines.len() {
146 let t = lines[i].trim_start();
147 if t.is_empty() || starts_block(t) {
148 break;
149 }
150 parts.push(inline(lines[i].trim_end(), depth));
151 i += 1;
152 }
153 (i, format!("<p>{}</p>", parts.join("<br>")))
154}
155
156fn starts_block(trimmed: &str) -> bool {
158 trimmed.starts_with("```")
159 || trimmed.starts_with('>')
160 || heading(trimmed).is_some()
161 || is_rule(trimmed)
162 || unordered_item(trimmed).is_some()
163 || ordered_item(trimmed).is_some()
164}
165
166fn heading(trimmed: &str) -> Option<(usize, &str)> {
168 let level = trimmed.bytes().take_while(|&b| b == b'#').count();
169 if (1..=6).contains(&level) {
170 if let Some(content) = trimmed[level..].strip_prefix(' ') {
171 return Some((level, content.trim()));
172 }
173 }
174 None
175}
176
177fn is_rule(trimmed: &str) -> bool {
179 let t: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
180 t.len() >= 3 && (t.chars().all(|c| c == '-') || t.chars().all(|c| c == '*') || t.chars().all(|c| c == '_'))
181}
182
183fn unordered_item(trimmed: &str) -> Option<&str> {
185 for marker in ["- ", "* ", "+ "] {
186 if let Some(rest) = trimmed.strip_prefix(marker) {
187 return Some(rest);
188 }
189 }
190 None
191}
192
193fn ordered_item(trimmed: &str) -> Option<&str> {
195 let digits = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
196 if digits == 0 {
197 return None;
198 }
199 trimmed[digits..].strip_prefix(". ")
200}
201
202#[derive(Clone, Copy, PartialEq)]
203enum ListKind {
204 Unordered,
205 Ordered,
206}
207
208fn list_item(line: &str) -> Option<(usize, ListKind, &str)> {
211 let trimmed = line.trim_start_matches([' ', '\t']);
212 let indent = line.len() - trimmed.len();
213 if let Some(item) = unordered_item(trimmed) {
214 Some((indent, ListKind::Unordered, item))
215 } else {
216 ordered_item(trimmed).map(|item| (indent, ListKind::Ordered, item))
217 }
218}
219
220fn list_block(lines: &[&str], start: usize, depth: u32) -> (usize, String) {
221 let (base_indent, kind, _) = list_item(lines[start]).expect("list_block starts on a recognized list item");
222 let (open, close) = match kind {
223 ListKind::Unordered => ("<ul>", "</ul>"),
224 ListKind::Ordered => ("<ol>", "</ol>"),
225 };
226 let mut out = String::from(open);
227 let mut i = start;
228 while i < lines.len() {
229 let Some((indent, item_kind, item)) = list_item(lines[i]) else { break };
230 if indent != base_indent || item_kind != kind {
231 break;
232 }
233 out.push_str(&format!("<li>{}", inline(item, depth)));
234 i += 1;
235 while i < lines.len() && depth < MAX_DEPTH {
236 let Some((nested_indent, _, _)) = list_item(lines[i]) else { break };
237 if nested_indent <= base_indent {
238 break;
239 }
240 let (next, nested) = list_block(lines, i, depth + 1);
241 out.push_str(&nested);
242 i = next;
243 }
244 out.push_str("</li>");
245 }
246 out.push_str(close);
247 (i, out)
248}
249
250fn list_review_blocks(lines: &[&str], start: usize, depth: u32) -> (usize, Vec<(usize, String)>) {
253 let (base_indent, _, _) = list_item(lines[start]).expect("review list starts on a recognized list item");
254 let mut out = Vec::new();
255 let mut i = start;
256 let mut indents = vec![base_indent];
257 while i < lines.len() {
258 let Some((indent, item_kind, item)) = list_item(lines[i]) else { break };
259 if indent < base_indent {
260 break;
261 }
262 while indents.last().is_some_and(|current| *current > indent) {
263 indents.pop();
264 }
265 if indents.last().is_none_or(|current| *current < indent) {
266 indents.push(indent);
267 }
268 let nesting = indents.len() - 1;
269 let (tag, start_attr) = match item_kind {
270 ListKind::Unordered => ("ul", String::new()),
271 ListKind::Ordered => {
272 let trimmed = lines[i].trim_start_matches([' ', '\t']);
273 let digits = trimmed.bytes().take_while(|byte| byte.is_ascii_digit()).count();
274 let ordinal = trimmed[..digits].parse::<usize>().unwrap_or(1);
275 ("ol", format!(r#" start="{ordinal}""#))
276 }
277 };
278 let offset = nesting * 2;
279 out.push((
280 i,
281 format!(
282 r#"<{tag} class="md-list-fragment" style="--md-list-offset:{offset}em"{start_attr}><li>{}</li></{tag}>"#,
283 inline(item, depth + nesting as u32)
284 ),
285 ));
286 i += 1;
287 }
288 (i, out)
289}
290
291fn emphasizable(inner: &str) -> bool {
294 !inner.is_empty() && inner.trim() == inner
295}
296
297fn safe_url(url: &str) -> bool {
299 url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
300}
301
302fn inline(text: &str, depth: u32) -> String {
305 if depth > MAX_DEPTH {
306 return esc(text);
307 }
308 let mut out = String::new();
309 let mut i = 0;
310 while i < text.len() {
311 let rest = &text[i..];
312 if let Some(after) = rest.strip_prefix('`') {
313 if let Some(n) = after.find('`') {
314 out.push_str(&format!("<code>{}</code>", esc(&after[..n])));
315 i += n + 2;
316 continue;
317 }
318 }
319 if let Some(after) = rest.strip_prefix("**") {
320 if let Some(mut n) = after.find("**") {
321 if after[n..].starts_with("***") {
324 n += 1;
325 }
326 if emphasizable(&after[..n]) {
327 out.push_str(&format!("<strong>{}</strong>", inline(&after[..n], depth + 1)));
328 i += n + 4;
329 continue;
330 }
331 }
332 }
333 if let Some(after) = rest.strip_prefix('*') {
334 if let Some(n) = after.find('*') {
335 if emphasizable(&after[..n]) {
336 out.push_str(&format!("<em>{}</em>", inline(&after[..n], depth + 1)));
337 i += n + 2;
338 continue;
339 }
340 }
341 }
342 if rest.starts_with('[') {
343 if let Some(close) = rest.find("](") {
344 if let Some(end) = rest[close + 2..].find(')') {
345 let label = &rest[1..close];
346 let url = &rest[close + 2..close + 2 + end];
347 if safe_url(url) {
348 out.push_str(&format!(r#"<a href="{}">{}</a>"#, esc(url), inline(label, depth + 1)));
349 i += close + 2 + end + 1;
350 continue;
351 }
352 }
353 }
354 }
355 let ch = rest.chars().next().expect("rest is non-empty inside the loop");
358 esc_char(ch, &mut out);
359 i += ch.len_utf8();
360 }
361 out
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn hostile_html_is_escaped_everywhere() {
370 assert_eq!(to_html("<script>alert(1)</script>"), "<p><script>alert(1)</script></p>");
371 assert_eq!(to_html("# <b>hi</b>"), "<h1><b>hi</b></h1>");
372 assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code><script>x</script>\n</code></pre>");
373 assert_eq!(to_html("`<i>`"), "<p><code><i></code></p>");
374 }
375
376 #[test]
377 fn unsafe_link_schemes_stay_literal_text() {
378 let js = to_html("[x](javascript:alert(1))");
379 assert!(!js.contains("<a "), "{js}");
380 assert!(js.contains("javascript:alert(1)"));
381 let ok = to_html("[docs](https://example.com/a?b=1)");
382 assert_eq!(ok, r#"<p><a href="https://example.com/a?b=1">docs</a></p>"#);
383 }
384
385 #[test]
386 fn headings_paragraphs_and_hard_breaks() {
387 assert_eq!(to_html("## Title"), "<h2>Title</h2>");
388 assert_eq!(to_html("####### seven"), "<p>####### seven</p>");
389 assert_eq!(to_html("line one\nline two"), "<p>line one<br>line two</p>");
390 assert_eq!(to_html("para one\n\npara two"), "<p>para one</p><p>para two</p>");
391 }
392
393 #[test]
394 fn emphasis_code_and_snake_case_survival() {
395 assert_eq!(
396 to_html("**bold** and *em* and `code`"),
397 "<p><strong>bold</strong> and <em>em</em> and <code>code</code></p>"
398 );
399 assert_eq!(to_html("**outer *inner***"), "<p><strong>outer <em>inner</em></strong></p>");
400 assert_eq!(to_html("keep snake_case and __this__ literal"), "<p>keep snake_case and __this__ literal</p>");
401 assert_eq!(to_html("a * b stays literal"), "<p>a * b stays literal</p>");
402 }
403
404 #[test]
405 fn lists_blockquotes_and_rules() {
406 assert_eq!(to_html("- a\n- b"), "<ul><li>a</li><li>b</li></ul>");
407 assert_eq!(to_html("1. a\n2. b"), "<ol><li>a</li><li>b</li></ol>");
408 assert_eq!(to_html("> quoted\n> more"), "<blockquote><p>quoted<br>more</p></blockquote>");
409 assert_eq!(to_html("---"), "<hr>");
410 }
411
412 #[test]
413 fn nested_lists_preserve_source_indentation() {
414 assert_eq!(
415 to_html("- parent\n - child\n - grandchild\n- sibling"),
416 "<ul><li>parent<ul><li>child<ul><li>grandchild</li></ul></li></ul></li><li>sibling</li></ul>"
417 );
418 assert_eq!(
419 to_html("1. parent\n - child\n2. sibling"),
420 "<ol><li>parent<ul><li>child</li></ul></li><li>sibling</li></ol>"
421 );
422 }
423
424 #[test]
425 fn unclosed_fence_runs_to_the_end() {
426 assert_eq!(to_html("```\ncode"), "<pre><code>code\n</code></pre>");
427 }
428
429 #[test]
430 fn multibyte_text_is_preserved() {
431 assert_eq!(to_html("héllo — **wörld** 🚀"), "<p>héllo — <strong>wörld</strong> 🚀</p>");
432 }
433
434 #[test]
435 fn blocks_carry_their_starting_source_lines() {
436 let text = "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q";
439 let blocks = to_html_blocks(text);
440 let lines: Vec<usize> = blocks.iter().map(|(l, _)| *l).collect();
441 assert_eq!(lines, vec![0, 2, 5, 8, 14]);
442 assert!(blocks[0].1.starts_with("<h1>"), "{}", blocks[0].1);
443 assert!(blocks[3].1.starts_with("<pre>"), "{}", blocks[3].1);
444 }
445
446 #[test]
447 fn review_blocks_give_every_list_item_its_source_line() {
448 let blocks = to_html_review_blocks("# Title\n\n- parent\n - child\n- sibling\n\n1. first\n2. second");
449 let lines: Vec<usize> = blocks.iter().map(|(line, _)| *line).collect();
450 assert_eq!(lines, vec![0, 2, 3, 4, 6, 7]);
451 assert_eq!(blocks[1].1, r#"<ul class="md-list-fragment" style="--md-list-offset:0em"><li>parent</li></ul>"#);
452 assert_eq!(blocks[2].1, r#"<ul class="md-list-fragment" style="--md-list-offset:2em"><li>child</li></ul>"#);
453 assert!(blocks[4].1.starts_with(r#"<ol class="md-list-fragment" style="--md-list-offset:0em" start="1">"#));
454 assert!(blocks[5].1.starts_with(r#"<ol class="md-list-fragment" style="--md-list-offset:0em" start="2">"#));
455 }
456
457 #[test]
458 fn concatenated_blocks_equal_to_html() {
459 for text in [
460 "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q\n\n---",
461 "plain",
462 "",
463 "> nested\n> > deeper\n\n1. one\n2. two",
464 ] {
465 let joined: String = to_html_blocks(text).into_iter().map(|(_, h)| h).collect();
466 assert_eq!(joined, to_html(text), "for input {text:?}");
467 }
468 }
469}