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/// Like [`to_html`], but one entry per top-level block: `(line, html)`,
24/// where `line` is the 0-based index (within `text`) of the block's first
25/// line. Concatenating the `html` parts yields exactly `to_html(text)`.
26/// This is what lets the page anchor a comment on a rendered markdown block
27/// back to the source line the block starts at.
28pub 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
44/// Recursion cap for nested blockquotes and nested inline emphasis; markdown
45/// past this depth renders as escaped literal text instead of overflowing.
46const 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("&amp;"),
59    '<' => out.push_str("&lt;"),
60    '>' => out.push_str("&gt;"),
61    '"' => out.push_str("&quot;"),
62    '\'' => out.push_str("&#x27;"),
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
82/// Render the single block starting at the non-blank `lines[start]`; returns
83/// the index just past the block and its HTML.
84fn 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    // Fenced code block; an unclosed fence runs to the end of the input.
89    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 unordered_item(trimmed).is_some() {
115    let mut out = String::from("<ul>");
116    while i < lines.len() {
117      let Some(item) = unordered_item(lines[i].trim_start()) else { break };
118      out.push_str(&format!("<li>{}</li>", inline(item, depth)));
119      i += 1;
120    }
121    out.push_str("</ul>");
122    return (i, out);
123  }
124  if ordered_item(trimmed).is_some() {
125    let mut out = String::from("<ol>");
126    while i < lines.len() {
127      let Some(item) = ordered_item(lines[i].trim_start()) else { break };
128      out.push_str(&format!("<li>{}</li>", inline(item, depth)));
129      i += 1;
130    }
131    out.push_str("</ol>");
132    return (i, out);
133  }
134  // Paragraph: consecutive plain lines; each single newline is a hard break.
135  let mut parts: Vec<String> = Vec::new();
136  while i < lines.len() {
137    let t = lines[i].trim_start();
138    if t.is_empty() || starts_block(t) {
139      break;
140    }
141    parts.push(inline(lines[i].trim_end(), depth));
142    i += 1;
143  }
144  (i, format!("<p>{}</p>", parts.join("<br>")))
145}
146
147/// True when the line begins some non-paragraph block, ending a paragraph.
148fn starts_block(trimmed: &str) -> bool {
149  trimmed.starts_with("```")
150    || trimmed.starts_with('>')
151    || heading(trimmed).is_some()
152    || is_rule(trimmed)
153    || unordered_item(trimmed).is_some()
154    || ordered_item(trimmed).is_some()
155}
156
157/// `#{1,6} ` → `(level, content)`.
158fn heading(trimmed: &str) -> Option<(usize, &str)> {
159  let level = trimmed.bytes().take_while(|&b| b == b'#').count();
160  if (1..=6).contains(&level) {
161    if let Some(content) = trimmed[level..].strip_prefix(' ') {
162      return Some((level, content.trim()));
163    }
164  }
165  None
166}
167
168/// Three or more of the same `-` / `*` / `_` and nothing else.
169fn is_rule(trimmed: &str) -> bool {
170  let t: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
171  t.len() >= 3 && (t.chars().all(|c| c == '-') || t.chars().all(|c| c == '*') || t.chars().all(|c| c == '_'))
172}
173
174/// `- item` / `* item` / `+ item` → the item text.
175fn unordered_item(trimmed: &str) -> Option<&str> {
176  for marker in ["- ", "* ", "+ "] {
177    if let Some(rest) = trimmed.strip_prefix(marker) {
178      return Some(rest);
179    }
180  }
181  None
182}
183
184/// `12. item` → the item text.
185fn ordered_item(trimmed: &str) -> Option<&str> {
186  let digits = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
187  if digits == 0 {
188    return None;
189  }
190  trimmed[digits..].strip_prefix(". ")
191}
192
193/// Emphasis content must be non-empty and not whitespace-flanked, so a bare
194/// asterisk in prose (`a * b`) stays literal.
195fn emphasizable(inner: &str) -> bool {
196  !inner.is_empty() && inner.trim() == inner
197}
198
199/// True for link targets this module will emit as `href`.
200fn safe_url(url: &str) -> bool {
201  url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
202}
203
204/// Inline spans: `` `code` `` (wins over everything inside it), `**bold**`,
205/// `*italic*`, `[label](url)`. Anything unmatched stays escaped literal text.
206fn inline(text: &str, depth: u32) -> String {
207  if depth > MAX_DEPTH {
208    return esc(text);
209  }
210  let mut out = String::new();
211  let mut i = 0;
212  while i < text.len() {
213    let rest = &text[i..];
214    if let Some(after) = rest.strip_prefix('`') {
215      if let Some(n) = after.find('`') {
216        out.push_str(&format!("<code>{}</code>", esc(&after[..n])));
217        i += n + 2;
218        continue;
219      }
220    }
221    if let Some(after) = rest.strip_prefix("**") {
222      if let Some(mut n) = after.find("**") {
223        // `**outer *inner***`: the first `**` found sits inside the trailing
224        // `***`; shift by one so the inner `*…*` pair stays intact.
225        if after[n..].starts_with("***") {
226          n += 1;
227        }
228        if emphasizable(&after[..n]) {
229          out.push_str(&format!("<strong>{}</strong>", inline(&after[..n], depth + 1)));
230          i += n + 4;
231          continue;
232        }
233      }
234    }
235    if let Some(after) = rest.strip_prefix('*') {
236      if let Some(n) = after.find('*') {
237        if emphasizable(&after[..n]) {
238          out.push_str(&format!("<em>{}</em>", inline(&after[..n], depth + 1)));
239          i += n + 2;
240          continue;
241        }
242      }
243    }
244    if rest.starts_with('[') {
245      if let Some(close) = rest.find("](") {
246        if let Some(end) = rest[close + 2..].find(')') {
247          let label = &rest[1..close];
248          let url = &rest[close + 2..close + 2 + end];
249          if safe_url(url) {
250            out.push_str(&format!(r#"<a href="{}">{}</a>"#, esc(url), inline(label, depth + 1)));
251            i += close + 2 + end + 1;
252            continue;
253          }
254        }
255      }
256    }
257    // `text[i..]` is always on a char boundary: every arm advances by whole
258    // characters (ASCII markers or a full `len_utf8`).
259    let ch = rest.chars().next().expect("rest is non-empty inside the loop");
260    esc_char(ch, &mut out);
261    i += ch.len_utf8();
262  }
263  out
264}
265
266#[cfg(test)]
267mod tests {
268  use super::*;
269
270  #[test]
271  fn hostile_html_is_escaped_everywhere() {
272    assert_eq!(to_html("<script>alert(1)</script>"), "<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
273    assert_eq!(to_html("# <b>hi</b>"), "<h1>&lt;b&gt;hi&lt;/b&gt;</h1>");
274    assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code>&lt;script&gt;x&lt;/script&gt;\n</code></pre>");
275    assert_eq!(to_html("`<i>`"), "<p><code>&lt;i&gt;</code></p>");
276  }
277
278  #[test]
279  fn unsafe_link_schemes_stay_literal_text() {
280    let js = to_html("[x](javascript:alert(1))");
281    assert!(!js.contains("<a "), "{js}");
282    assert!(js.contains("javascript:alert(1)"));
283    let ok = to_html("[docs](https://example.com/a?b=1)");
284    assert_eq!(ok, r#"<p><a href="https://example.com/a?b=1">docs</a></p>"#);
285  }
286
287  #[test]
288  fn headings_paragraphs_and_hard_breaks() {
289    assert_eq!(to_html("## Title"), "<h2>Title</h2>");
290    assert_eq!(to_html("####### seven"), "<p>####### seven</p>");
291    assert_eq!(to_html("line one\nline two"), "<p>line one<br>line two</p>");
292    assert_eq!(to_html("para one\n\npara two"), "<p>para one</p><p>para two</p>");
293  }
294
295  #[test]
296  fn emphasis_code_and_snake_case_survival() {
297    assert_eq!(
298      to_html("**bold** and *em* and `code`"),
299      "<p><strong>bold</strong> and <em>em</em> and <code>code</code></p>"
300    );
301    assert_eq!(to_html("**outer *inner***"), "<p><strong>outer <em>inner</em></strong></p>");
302    assert_eq!(to_html("keep snake_case and __this__ literal"), "<p>keep snake_case and __this__ literal</p>");
303    assert_eq!(to_html("a * b stays literal"), "<p>a * b stays literal</p>");
304  }
305
306  #[test]
307  fn lists_blockquotes_and_rules() {
308    assert_eq!(to_html("- a\n- b"), "<ul><li>a</li><li>b</li></ul>");
309    assert_eq!(to_html("1. a\n2. b"), "<ol><li>a</li><li>b</li></ol>");
310    assert_eq!(to_html("> quoted\n> more"), "<blockquote><p>quoted<br>more</p></blockquote>");
311    assert_eq!(to_html("---"), "<hr>");
312  }
313
314  #[test]
315  fn unclosed_fence_runs_to_the_end() {
316    assert_eq!(to_html("```\ncode"), "<pre><code>code\n</code></pre>");
317  }
318
319  #[test]
320  fn multibyte_text_is_preserved() {
321    assert_eq!(to_html("héllo — **wörld** 🚀"), "<p>héllo — <strong>wörld</strong> 🚀</p>");
322  }
323
324  #[test]
325  fn blocks_carry_their_starting_source_lines() {
326    // The fence spans a blank line (9-13), so naive blank-line splitting
327    // would mangle it; block tracking must not.
328    let text = "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q";
329    let blocks = to_html_blocks(text);
330    let lines: Vec<usize> = blocks.iter().map(|(l, _)| *l).collect();
331    assert_eq!(lines, vec![0, 2, 5, 8, 14]);
332    assert!(blocks[0].1.starts_with("<h1>"), "{}", blocks[0].1);
333    assert!(blocks[3].1.starts_with("<pre>"), "{}", blocks[3].1);
334  }
335
336  #[test]
337  fn concatenated_blocks_equal_to_html() {
338    for text in [
339      "# Title\n\npara one\npara two\n\n- a\n- b\n\n```\ncode\n\nmore\n```\n\n> q\n\n---",
340      "plain",
341      "",
342      "> nested\n> > deeper\n\n1. one\n2. two",
343    ] {
344      let joined: String = to_html_blocks(text).into_iter().map(|(_, h)| h).collect();
345      assert_eq!(joined, to_html(text), "for input {text:?}");
346    }
347  }
348}