use std::collections::HashMap;
use std::fmt::Write as _;
use crate::escape::{push_escaped, push_url};
#[cfg(test)]
fn to_html(markdown: &str) -> String {
let mut out = String::new();
push_html(&mut out, markdown);
out
}
pub(crate) fn push_html(out: &mut String, markdown: &str) {
let mut lines = markdown.lines().peekable();
let mut anchors = HashMap::new();
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(fence) = trimmed.strip_prefix("```") {
push_code_block(out, fence.trim(), &mut lines);
} else if let Some((level, text)) = heading(trimmed) {
push_heading(out, level, text, &mut anchors);
} else if is_rule(trimmed) {
out.push_str("<hr>\n");
} else if let Some(quoted) = quote(trimmed) {
let mut text = quoted.to_owned();
while let Some(more) = lines.peek().and_then(|next| quote(next.trim())) {
text.push(' ');
text.push_str(more);
lines.next();
}
out.push_str("<blockquote><p>");
push_inline(out, &text);
out.push_str("</p></blockquote>\n");
} else if let Some((ordered, item)) = list_item(trimmed) {
push_list(out, ordered, item, &mut lines);
} else {
let mut text = trimmed.to_owned();
while let Some(next) = lines.peek().map(|next| next.trim()) {
if next.is_empty() || starts_block(next) {
break;
}
text.push(' ');
text.push_str(next);
lines.next();
}
out.push_str("<p>");
push_inline(out, &text);
out.push_str("</p>\n");
}
}
}
fn starts_block(line: &str) -> bool {
line.starts_with("```")
|| heading(line).is_some()
|| is_rule(line)
|| quote(line).is_some()
|| list_item(line).is_some()
}
fn heading(line: &str) -> Option<(usize, &str)> {
let level = line.bytes().take_while(|byte| *byte == b'#').count();
let text = line.get(level..)?;
((1..=6).contains(&level) && text.starts_with(' ')).then(|| (level, text.trim()))
}
fn is_rule(line: &str) -> bool {
let Some(mark @ (b'-' | b'*')) = line.bytes().next() else {
return false;
};
line.bytes().all(|byte| byte == mark || byte == b' ')
&& line.bytes().filter(|byte| *byte == mark).count() >= 3
}
fn quote(line: &str) -> Option<&str> {
line.strip_prefix('>').map(str::trim)
}
fn list_item(line: &str) -> Option<(bool, &str)> {
if let Some(item) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
return Some((false, item.trim()));
}
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
let item = line.get(digits..)?.strip_prefix(". ")?;
(digits > 0).then(|| (true, item.trim()))
}
fn push_heading(out: &mut String, level: usize, text: &str, anchors: &mut HashMap<String, usize>) {
let _ = write!(out, "<h{level}");
let anchor = anchor(text);
if !anchor.is_empty() {
let anchor = unique(&anchor, anchors);
out.push_str(" id=\"h-");
out.push_str(&anchor);
out.push('"');
}
out.push('>');
push_inline(out, text);
let _ = writeln!(out, "</h{level}>");
}
fn anchor(text: &str) -> String {
let mut anchor = String::new();
for character in text.chars().flat_map(char::to_lowercase) {
if character.is_alphanumeric() {
anchor.push(character);
} else if !anchor.is_empty() && !anchor.ends_with('-') {
anchor.push('-');
}
}
anchor.trim_end_matches('-').to_owned()
}
fn unique(anchor: &str, anchors: &mut HashMap<String, usize>) -> String {
let Some(next) = anchors.get(anchor).copied() else {
anchors.insert(anchor.to_owned(), 1);
return anchor.to_owned();
};
let mut number = next;
let mut candidate = format!("{anchor}-{number}");
while anchors.contains_key(&candidate) {
number += 1;
candidate = format!("{anchor}-{number}");
}
anchors.insert(anchor.to_owned(), number + 1);
anchors.insert(candidate.clone(), 1);
candidate
}
fn push_code_block<'a>(
out: &mut String,
language: &str,
lines: &mut impl Iterator<Item = &'a str>,
) {
out.push_str("<pre><code");
let end = language
.find(|character: char| !character.is_ascii_alphanumeric() && character != '-')
.unwrap_or(language.len());
let language = &language[..end];
if !language.is_empty() {
out.push_str(" class=\"language-");
out.push_str(language);
out.push('"');
}
out.push('>');
for line in lines.by_ref() {
if line.trim() == "```" {
break;
}
push_escaped(out, line);
out.push('\n');
}
out.push_str("</code></pre>\n");
}
fn push_list<'a>(
out: &mut String,
ordered: bool,
first: &str,
lines: &mut std::iter::Peekable<impl Iterator<Item = &'a str>>,
) {
let tag = if ordered { "ol" } else { "ul" };
let _ = writeln!(out, "<{tag}>");
let mut item = first.to_owned();
while let Some(next) = lines.peek() {
let continued = next.starts_with([' ', '\t']) && !next.trim().is_empty();
if continued && list_item(next.trim()).is_none() {
item.push(' ');
item.push_str(next.trim());
lines.next();
continue;
}
match list_item(next.trim()) {
Some((same, text)) if same == ordered => {
push_item(out, &item);
text.clone_into(&mut item);
lines.next();
}
_ => break,
}
}
push_item(out, &item);
let _ = writeln!(out, "</{tag}>");
}
fn push_item(out: &mut String, text: &str) {
out.push_str("<li>");
push_inline(out, text);
out.push_str("</li>\n");
}
fn push_inline(out: &mut String, text: &str) {
let links_end = text
.rfind(')')
.and_then(|close| text[..close].rfind("]("))
.unwrap_or(0);
let mut rest = text;
while let Some(character) = rest.chars().next() {
let can_link = text.len() - rest.len() < links_end;
let consumed = match character {
'\\' => escaped_character(out, rest),
'`' => code_span(out, rest),
'*' => emphasis(out, rest, character),
'_' if !ends_in_word(&text[..text.len() - rest.len()]) => {
emphasis(out, rest, character)
}
'!' if can_link && rest.starts_with("![") => {
link(out, &rest[1..], true).map(|used| used + 1)
}
'[' if can_link => link(out, rest, false),
_ => None,
};
let used = consumed.unwrap_or_else(|| {
push_escaped(out, &rest[..character.len_utf8()]);
character.len_utf8()
});
rest = &rest[used..];
}
}
fn escaped_character(out: &mut String, text: &str) -> Option<usize> {
let next = text
.get(1..2)
.filter(|next| next.bytes().all(|byte| byte.is_ascii_punctuation()))?;
push_escaped(out, next);
Some(2)
}
fn code_span(out: &mut String, text: &str) -> Option<usize> {
let end = text[1..].find('`')? + 1;
out.push_str("<code>");
push_escaped(out, &text[1..end]);
out.push_str("</code>");
Some(end + 1)
}
fn ends_in_word(text: &str) -> bool {
text.chars().next_back().is_some_and(char::is_alphanumeric)
}
fn emphasis(out: &mut String, text: &str, marker: char) -> Option<usize> {
let (delimiter, tag) = if text[1..].starts_with(marker) {
(&text[..2], "strong")
} else {
(&text[..1], "em")
};
let inner = &text[delimiter.len()..];
let end = inner.find(delimiter)?;
let after = &inner[end + delimiter.len()..];
let closes_inside_word =
marker == '_' && after.chars().next().is_some_and(char::is_alphanumeric);
if end == 0 || inner.starts_with(' ') || closes_inside_word {
return None;
}
let _ = write!(out, "<{tag}>");
push_inline(out, &inner[..end]);
let _ = write!(out, "</{tag}>");
Some(delimiter.len() * 2 + end)
}
fn link(out: &mut String, text: &str, image: bool) -> Option<usize> {
let label_end = text.find("](")?;
let label = &text[1..label_end];
let target_start = label_end + 2;
let target_end = target_start + text[target_start..].find(')')?;
let url = text[target_start..target_end].trim();
if image {
out.push_str("<img src=\"");
push_url(out, url, false);
out.push_str("\" alt=\"");
push_escaped(out, label);
out.push_str("\">");
} else {
out.push_str("<a href=\"");
push_url(out, url, false);
out.push_str("\">");
push_inline(out, label);
out.push_str("</a>");
}
Some(target_end + 1)
}
#[cfg(test)]
mod tests {
use super::to_html;
#[test]
fn renders_the_blocks_of_a_page() {
let markdown = "# Our story\n\nWe started\nin 2026.\n\n- one\n- two\n continued\n\n\
1. first\n2. second\n\n> quoted\n> on.\n\n---\n\n```rust\nlet a = 1 < 2;\n```\n";
assert_eq!(
to_html(markdown),
"<h1 id=\"h-our-story\">Our story</h1>\n<p>We started in 2026.</p>\n\
<ul>\n<li>one</li>\n<li>two continued</li>\n</ul>\n\
<ol>\n<li>first</li>\n<li>second</li>\n</ol>\n\
<blockquote><p>quoted on.</p></blockquote>\n<hr>\n\
<pre><code class=\"language-rust\">let a = 1 < 2;\n</code></pre>\n"
);
}
#[test]
fn a_heading_id_cannot_take_an_id_the_page_uses() {
assert_eq!(
to_html("# Sofie notice"),
"<h1 id=\"h-sofie-notice\">Sofie notice</h1>\n"
);
}
#[test]
fn a_heading_without_letters_or_digits_has_no_id() {
assert_eq!(to_html("## !?"), "<h2>!?</h2>\n");
assert_eq!(
to_html("# Ünal & Co."),
"<h1 id=\"h-ünal-co\">Ünal & Co.</h1>\n"
);
}
#[test]
fn a_repeated_heading_gets_a_numbered_id() {
assert_eq!(
to_html("# Intro\n## Intro\n# Intro-1\n### Intro"),
"<h1 id=\"h-intro\">Intro</h1>\n<h2 id=\"h-intro-1\">Intro</h2>\n\
<h1 id=\"h-intro-1-1\">Intro-1</h1>\n<h3 id=\"h-intro-2\">Intro</h3>\n"
);
}
#[test]
fn many_repeated_headings_are_numbered_in_linear_time() {
let markdown = "# a\n".repeat(30_000);
let html = to_html(&markdown);
assert!(html.ends_with("<h1 id=\"h-a-29999\">a</h1>\n"));
}
#[test]
fn renders_inline_marks_links_and_images() {
assert_eq!(
to_html(
"Read **the *full* guide**, `code` and [docs](/docs) "
),
"<p>Read <strong>the <em>full</em> guide</strong>, <code>code</code> and \
<a href=\"/docs\">docs</a> <img src=\"https://x.test/a.png\" alt=\"logo\"></p>\n"
);
}
#[test]
fn an_underscore_inside_a_word_is_not_emphasis() {
assert_eq!(
to_html("snake_case_name, _em_ and in*ner*most"),
"<p>snake_case_name, <em>em</em> and in<em>ner</em>most</p>\n"
);
assert_eq!(to_html("_a_b"), "<p>_a_b</p>\n");
}
#[test]
fn a_run_of_brackets_renders_in_linear_time() {
let brackets = "[".repeat(1_000_000);
assert_eq!(
to_html(&format!("[a](/x) {brackets}) ![b")),
format!("<p><a href=\"/x\">a</a> {brackets}) ![b</p>\n")
);
}
#[test]
fn shows_html_as_text_and_sends_unsafe_links_nowhere() {
assert_eq!(
to_html("<script>alert(1)</script> [x](javascript:alert(1)) \\*not em\\*"),
"<p><script>alert(1)</script> <a href=\"#\">x</a>) *not em*</p>\n"
);
}
}