pub fn to_html(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
blocks_to_html(&lines, 0)
}
pub fn to_html_blocks(text: &str) -> Vec<(usize, String)> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].trim_start().is_empty() {
i += 1;
continue;
}
let (next, html) = one_block(&lines, i, 0);
out.push((i, html));
i = next;
}
out
}
const MAX_DEPTH: u32 = 8;
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
esc_char(ch, &mut out);
}
out
}
fn esc_char(ch: char, out: &mut String) {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
fn blocks_to_html(lines: &[&str], depth: u32) -> String {
let mut out = String::new();
let mut i = 0;
while i < lines.len() {
if lines[i].trim_start().is_empty() {
i += 1;
continue;
}
let (next, html) = one_block(lines, i, depth);
out.push_str(&html);
i = next;
}
out
}
fn one_block(lines: &[&str], start: usize, depth: u32) -> (usize, String) {
let mut i = start;
let trimmed = lines[i].trim_start();
if trimmed.starts_with("```") {
let mut j = i + 1;
let mut code = String::new();
while j < lines.len() && !lines[j].trim_start().starts_with("```") {
code.push_str(lines[j]);
code.push('\n');
j += 1;
}
return (j + 1, format!("<pre><code>{}</code></pre>", esc(&code)));
}
if let Some((level, content)) = heading(trimmed) {
return (i + 1, format!("<h{level}>{}</h{level}>", inline(content, depth)));
}
if is_rule(trimmed) {
return (i + 1, "<hr>".to_string());
}
if trimmed.starts_with('>') && depth < MAX_DEPTH {
let mut inner: Vec<&str> = Vec::new();
while i < lines.len() {
let t = lines[i].trim_start();
let Some(stripped) = t.strip_prefix('>') else { break };
inner.push(stripped.strip_prefix(' ').unwrap_or(stripped));
i += 1;
}
return (i, format!("<blockquote>{}</blockquote>", blocks_to_html(&inner, depth + 1)));
}
if unordered_item(trimmed).is_some() {
let mut out = String::from("<ul>");
while i < lines.len() {
let Some(item) = unordered_item(lines[i].trim_start()) else { break };
out.push_str(&format!("<li>{}</li>", inline(item, depth)));
i += 1;
}
out.push_str("</ul>");
return (i, out);
}
if ordered_item(trimmed).is_some() {
let mut out = String::from("<ol>");
while i < lines.len() {
let Some(item) = ordered_item(lines[i].trim_start()) else { break };
out.push_str(&format!("<li>{}</li>", inline(item, depth)));
i += 1;
}
out.push_str("</ol>");
return (i, out);
}
let mut parts: Vec<String> = Vec::new();
while i < lines.len() {
let t = lines[i].trim_start();
if t.is_empty() || starts_block(t) {
break;
}
parts.push(inline(lines[i].trim_end(), depth));
i += 1;
}
(i, format!("<p>{}</p>", parts.join("<br>")))
}
fn starts_block(trimmed: &str) -> bool {
trimmed.starts_with("```")
|| trimmed.starts_with('>')
|| heading(trimmed).is_some()
|| is_rule(trimmed)
|| unordered_item(trimmed).is_some()
|| ordered_item(trimmed).is_some()
}
fn heading(trimmed: &str) -> Option<(usize, &str)> {
let level = trimmed.bytes().take_while(|&b| b == b'#').count();
if (1..=6).contains(&level) {
if let Some(content) = trimmed[level..].strip_prefix(' ') {
return Some((level, content.trim()));
}
}
None
}
fn is_rule(trimmed: &str) -> bool {
let t: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
t.len() >= 3 && (t.chars().all(|c| c == '-') || t.chars().all(|c| c == '*') || t.chars().all(|c| c == '_'))
}
fn unordered_item(trimmed: &str) -> Option<&str> {
for marker in ["- ", "* ", "+ "] {
if let Some(rest) = trimmed.strip_prefix(marker) {
return Some(rest);
}
}
None
}
fn ordered_item(trimmed: &str) -> Option<&str> {
let digits = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
if digits == 0 {
return None;
}
trimmed[digits..].strip_prefix(". ")
}
fn emphasizable(inner: &str) -> bool {
!inner.is_empty() && inner.trim() == inner
}
fn safe_url(url: &str) -> bool {
url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
}
fn inline(text: &str, depth: u32) -> String {
if depth > MAX_DEPTH {
return esc(text);
}
let mut out = String::new();
let mut i = 0;
while i < text.len() {
let rest = &text[i..];
if let Some(after) = rest.strip_prefix('`') {
if let Some(n) = after.find('`') {
out.push_str(&format!("<code>{}</code>", esc(&after[..n])));
i += n + 2;
continue;
}
}
if let Some(after) = rest.strip_prefix("**") {
if let Some(mut n) = after.find("**") {
if after[n..].starts_with("***") {
n += 1;
}
if emphasizable(&after[..n]) {
out.push_str(&format!("<strong>{}</strong>", inline(&after[..n], depth + 1)));
i += n + 4;
continue;
}
}
}
if let Some(after) = rest.strip_prefix('*') {
if let Some(n) = after.find('*') {
if emphasizable(&after[..n]) {
out.push_str(&format!("<em>{}</em>", inline(&after[..n], depth + 1)));
i += n + 2;
continue;
}
}
}
if rest.starts_with('[') {
if let Some(close) = rest.find("](") {
if let Some(end) = rest[close + 2..].find(')') {
let label = &rest[1..close];
let url = &rest[close + 2..close + 2 + end];
if safe_url(url) {
out.push_str(&format!(r#"<a href="{}">{}</a>"#, esc(url), inline(label, depth + 1)));
i += close + 2 + end + 1;
continue;
}
}
}
}
let ch = rest.chars().next().expect("rest is non-empty inside the loop");
esc_char(ch, &mut out);
i += ch.len_utf8();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_html_is_escaped_everywhere() {
assert_eq!(to_html("<script>alert(1)</script>"), "<p><script>alert(1)</script></p>");
assert_eq!(to_html("# <b>hi</b>"), "<h1><b>hi</b></h1>");
assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code><script>x</script>\n</code></pre>");
assert_eq!(to_html("`<i>`"), "<p><code><i></code></p>");
}
#[test]
fn unsafe_link_schemes_stay_literal_text() {
let js = to_html("[x](javascript:alert(1))");
assert!(!js.contains("<a "), "{js}");
assert!(js.contains("javascript:alert(1)"));
let ok = to_html("[docs](https://example.com/a?b=1)");
assert_eq!(ok, r#"<p><a href="https://example.com/a?b=1">docs</a></p>"#);
}
#[test]
fn headings_paragraphs_and_hard_breaks() {
assert_eq!(to_html("## Title"), "<h2>Title</h2>");
assert_eq!(to_html("####### seven"), "<p>####### seven</p>");
assert_eq!(to_html("line one\nline two"), "<p>line one<br>line two</p>");
assert_eq!(to_html("para one\n\npara two"), "<p>para one</p><p>para two</p>");
}
#[test]
fn emphasis_code_and_snake_case_survival() {
assert_eq!(
to_html("**bold** and *em* and `code`"),
"<p><strong>bold</strong> and <em>em</em> and <code>code</code></p>"
);
assert_eq!(to_html("**outer *inner***"), "<p><strong>outer <em>inner</em></strong></p>");
assert_eq!(to_html("keep snake_case and __this__ literal"), "<p>keep snake_case and __this__ literal</p>");
assert_eq!(to_html("a * b stays literal"), "<p>a * b stays literal</p>");
}
#[test]
fn lists_blockquotes_and_rules() {
assert_eq!(to_html("- a\n- b"), "<ul><li>a</li><li>b</li></ul>");
assert_eq!(to_html("1. a\n2. b"), "<ol><li>a</li><li>b</li></ol>");
assert_eq!(to_html("> quoted\n> more"), "<blockquote><p>quoted<br>more</p></blockquote>");
assert_eq!(to_html("---"), "<hr>");
}
#[test]
fn unclosed_fence_runs_to_the_end() {
assert_eq!(to_html("```\ncode"), "<pre><code>code\n</code></pre>");
}
#[test]
fn multibyte_text_is_preserved() {
assert_eq!(to_html("héllo — **wörld** 🚀"), "<p>héllo — <strong>wörld</strong> 🚀</p>");
}
#[test]
fn blocks_carry_their_starting_source_lines() {
let text = "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q";
let blocks = to_html_blocks(text);
let lines: Vec<usize> = blocks.iter().map(|(l, _)| *l).collect();
assert_eq!(lines, vec![0, 2, 5, 8, 14]);
assert!(blocks[0].1.starts_with("<h1>"), "{}", blocks[0].1);
assert!(blocks[3].1.starts_with("<pre>"), "{}", blocks[3].1);
}
#[test]
fn concatenated_blocks_equal_to_html() {
for text in [
"# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q\n\n---",
"plain",
"",
"> nested\n> > deeper\n\n1. one\n2. two",
] {
let joined: String = to_html_blocks(text).into_iter().map(|(_, h)| h).collect();
assert_eq!(joined, to_html(text), "for input {text:?}");
}
}
}