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, 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/// Like [`to_html_blocks`], but splits every list item into its own rendered
45/// block. Review pages use this form so every bullet is an independently
46/// commentable source line; nested items carry a safe numeric indentation
47/// token that the page turns back into their visual nesting.
48pub 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
70/// Recursion cap for nested blockquotes and nested inline emphasis; markdown
71/// past this depth renders as escaped literal text instead of overflowing.
72const 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("&amp;"),
85    '<' => out.push_str("&lt;"),
86    '>' => out.push_str("&gt;"),
87    '"' => out.push_str("&quot;"),
88    '\'' => out.push_str("&#x27;"),
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
108/// Render the single block starting at the non-blank `lines[start]`; returns
109/// the index just past the block and its HTML.
110fn 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    // Fenced code block; an unclosed fence runs to the end of the input.
115    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  // Paragraph: consecutive plain lines; each single newline is a hard break.
144  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
156/// True when the line begins some non-paragraph block, ending a paragraph.
157fn 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
166/// `#{1,6} ` → `(level, content)`.
167fn 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
177/// Three or more of the same `-` / `*` / `_` and nothing else.
178fn 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
183/// `- item` / `* item` / `+ item` → the item text.
184fn 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
193/// `12. item` → the item text.
194fn 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
208/// Leading byte indentation, marker kind, and item content. Markdown list
209/// indentation is relative, so any deeper run nests beneath the current item.
210fn 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
250/// Render one HTML list per source item. Repeated list containers are
251/// deliberate: the page wraps each result in a separate comment target.
252fn 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
291/// Emphasis content must be non-empty and not whitespace-flanked, so a bare
292/// asterisk in prose (`a * b`) stays literal.
293fn emphasizable(inner: &str) -> bool {
294  !inner.is_empty() && inner.trim() == inner
295}
296
297/// True for link targets this module will emit as `href`.
298fn safe_url(url: &str) -> bool {
299  url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:")
300}
301
302/// Inline spans: `` `code` `` (wins over everything inside it), `**bold**`,
303/// `*italic*`, `[label](url)`. Anything unmatched stays escaped literal text.
304fn 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        // `**outer *inner***`: the first `**` found sits inside the trailing
322        // `***`; shift by one so the inner `*…*` pair stays intact.
323        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    // `text[i..]` is always on a char boundary: every arm advances by whole
356    // characters (ASCII markers or a full `len_utf8`).
357    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>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
371    assert_eq!(to_html("# <b>hi</b>"), "<h1>&lt;b&gt;hi&lt;/b&gt;</h1>");
372    assert_eq!(to_html("```\n<script>x</script>\n```"), "<pre><code>&lt;script&gt;x&lt;/script&gt;\n</code></pre>");
373    assert_eq!(to_html("`<i>`"), "<p><code>&lt;i&gt;</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    // The fence spans a blank line (9-13), so naive blank-line splitting
437    // would mangle it; block tracking must not.
438    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}