use pulldown_cmark::{html, Options, Parser};
pub const MIN_BODY_PREVIEW_CHARS: usize = 80;
pub const PREVIEW_MD_SLICE_CHARS: usize = 4000;
pub fn markdown_to_html(md: &str) -> String {
let mut html = String::new();
let parser = Parser::new_ext(md, Options::all());
html::push_html(&mut html, parser);
html
}
pub fn strip_leading_boilerplate(md: &str) -> &str {
let mut seen_heading = false;
let mut byte_idx = 0;
let mut acc_bytes = 0;
for (i, line) in md.lines().enumerate() {
let line_len_with_nl = line.len() + 1;
if i == 0 && line.trim().is_empty() {
acc_bytes += line_len_with_nl;
continue;
}
if line.trim_start().starts_with('#') {
seen_heading = true;
}
if seen_heading && line.trim().is_empty() {
acc_bytes += line_len_with_nl;
byte_idx = acc_bytes;
break;
}
acc_bytes += line_len_with_nl;
}
if byte_idx == 0 {
md
} else {
&md[byte_idx.min(md.len())..]
}
}
pub fn utf8_prefix(s: &str, max_chars: usize) -> &str {
if max_chars == 0 {
return "";
}
let mut last_byte = 0;
for (ch_idx, (byte_idx, _)) in s.char_indices().enumerate() {
if ch_idx == max_chars {
last_byte = byte_idx;
break;
}
last_byte = byte_idx + 1;
}
if last_byte == 0 || last_byte >= s.len() {
s
} else {
&s[..last_byte]
}
}
pub fn html_first_paragraphs(html: &str, max_paragraphs: usize, max_chars: usize) -> String {
let mut out = String::new();
let mut start = 0;
let mut count = 0;
while count < max_paragraphs {
let Some(rel) = html[start..].find("<p") else {
break;
};
let p_start = start + rel;
let Some(rel_close) = html[p_start..].find("</p>") else {
break;
};
let close = p_start + rel_close + "</p>".len();
out.push_str(&html[p_start..close]);
count += 1;
start = close;
}
if out.is_empty() {
out = html.to_string();
}
if out.chars().count() > max_chars {
out.chars().take(max_chars).collect()
} else {
out
}
}
pub fn render_preview(content: &str, description: Option<&str>, full_preview: bool) -> String {
if full_preview {
return markdown_to_html(content);
}
let content_trimmed = content.trim();
let body_len = content_trimmed.chars().count();
let source_md = if body_len >= MIN_BODY_PREVIEW_CHARS || description.is_none() {
content_trimmed
} else {
description.unwrap_or(content_trimmed)
};
let source_md = strip_leading_boilerplate(source_md);
let source_md = utf8_prefix(source_md, PREVIEW_MD_SLICE_CHARS);
let raw_html = markdown_to_html(source_md);
html_first_paragraphs(&raw_html, 3, 800)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_leading_boilerplate_skips_to_after_heading() {
let md = "\n# Title\n\nReal content starts here.";
assert_eq!(strip_leading_boilerplate(md), "Real content starts here.");
}
#[test]
fn strip_leading_boilerplate_no_heading_is_noop() {
let md = "Just a paragraph, no heading.";
assert_eq!(strip_leading_boilerplate(md), md);
}
#[test]
fn utf8_prefix_respects_char_boundaries() {
let s = "héllo wörld"; let prefix = utf8_prefix(s, 5);
assert_eq!(prefix.chars().count(), 5);
}
#[test]
fn html_first_paragraphs_extracts_up_to_limit() {
let html = "<p>one</p><p>two</p><p>three</p>";
let out = html_first_paragraphs(html, 2, 1000);
assert_eq!(out, "<p>one</p><p>two</p>");
}
#[test]
fn html_first_paragraphs_falls_back_without_p_tags() {
let html = "<div>no paragraphs here</div>";
let out = html_first_paragraphs(html, 2, 1000);
assert_eq!(out, html);
}
#[test]
fn render_preview_full_uses_whole_body() {
let body = "# Heading\n\nSome content.";
let out = render_preview(body, None, true);
assert!(out.contains("Some content."));
assert!(out.contains("<h1>"));
}
}