Skip to main content

packdiff_dto/
markdown.rs

1//! Minimal, safety-first Markdown → HTML: used for comment bodies and for
2//! the rendered view of markdown files on the generated page.
3//!
4//! Safety by construction: every input character is HTML-escaped first; the
5//! only tags in the output are the ones this module emits, and link targets
6//! are restricted to `http://` / `https://` / `mailto:` — hostile input
7//! cannot smuggle markup or `javascript:` URLs.
8//!
9//! Deliberately a SUBSET (documented in docs/PAGE.md): ATX headings, fenced
10//! code blocks, flat (non-nested) lists, blockquotes, thematic breaks,
11//! paragraphs; inline `` `code` ``, `**bold**`, `*italic*`, and
12//! `[links](https://…)`. Underscores are NOT emphasis, so `snake_case`
13//! identifiers survive verbatim. A single newline inside a paragraph is a
14//! hard break, matching how people write review comments.
15
16/// Render markdown `text` to HTML. Pure and deterministic; never fails —
17/// anything unrecognized falls back to escaped literal text.
18pub fn to_html(text: &str) -> String {
19  let lines: Vec<&str> = text.lines().collect();
20  blocks_to_html(&lines, 0)
21}
22
23/// Recursion cap for nested blockquotes and nested inline emphasis; markdown
24/// past this depth renders as escaped literal text instead of overflowing.
25const 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("&amp;"),
38    '<' => out.push_str("&lt;"),
39    '>' => out.push_str("&gt;"),
40    '"' => out.push_str("&quot;"),
41    '\'' => out.push_str("&#x27;"),
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      // Fenced code block; an unclosed fence runs to the end of the input.
57      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    // Paragraph: consecutive plain lines; each single newline is a hard break.
110    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
124/// True when the line begins some non-paragraph block, ending a paragraph.
125fn 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
134/// `#{1,6} ` → `(level, content)`.
135fn 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
145/// Three or more of the same `-` / `*` / `_` and nothing else.
146fn 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
151/// `- item` / `* item` / `+ item` → the item text.
152fn 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
161/// `12. item` → the item text.
162fn 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
170/// Emphasis content must be non-empty and not whitespace-flanked, so a bare
171/// asterisk in prose (`a * b`) stays literal.
172fn emphasizable(inner: &str) -> bool {
173  !inner.is_empty() && inner.trim() == inner
174}
175
176/// True for link targets this module will emit as `href`.
177fn safe_url(url: &str) -> bool {
178  url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
179}
180
181/// Inline spans: `` `code` `` (wins over everything inside it), `**bold**`,
182/// `*italic*`, `[label](url)`. Anything unmatched stays escaped literal text.
183fn 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        // `**outer *inner***`: the first `**` found sits inside the trailing
201        // `***`; shift by one so the inner `*…*` pair stays intact.
202        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    // `text[i..]` is always on a char boundary: every arm advances by whole
235    // characters (ASCII markers or a full `len_utf8`).
236    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>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
250    assert_eq!(to_html("# <b>hi</b>"), "<h1>&lt;b&gt;hi&lt;/b&gt;</h1>");
251    assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code>&lt;script&gt;x&lt;/script&gt;\n</code></pre>");
252    assert_eq!(to_html("`<i>`"), "<p><code>&lt;i&gt;</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}