// SPDX-License-Identifier: AGPL-3.0-or-later use regex::{Captures, Regex}; use std::sync::LazyLock; struct WrappedTag { re: Regex, prefix: &'static str, suffix: &'static str, } static SIMPLE_REPLACEMENTS: LazyLock> = LazyLock::new(|| { [ (r"(?i)

", "\n\n"), (r"(?i)

", ""), (r"(?i)", "\n"), (r"(?i)", "\n\n**"), (r"(?i)", "**\n\n"), (r"(?i)
  • ", "• "), (r"(?i)
  • ", "\n"), (r"(?i)|", "\n"), ] .into_iter() .map(|(pattern, replacement)| (Regex::new(pattern).expect("valid regex"), replacement)) .collect() }); static WRAPPED_TAGS: LazyLock> = LazyLock::new(|| { [ ("pre>([\s\S]*?)")).expect("valid regex"), prefix, suffix, }) .collect() }); static LINK_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?is)]*?\s+)?href="([^"]*)"[^>]*>([\s\S]*?)"#) .expect("valid regex") }); static TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)<[^>]*>").expect("valid regex")); static EXCESS_NEWLINES_RE: LazyLock = LazyLock::new(|| Regex::new(r"\n{3,}").expect("valid regex")); pub fn to_markdown(html: &str, decode_entities: impl Fn(&str) -> String) -> String { let mut md = html.to_owned(); for (re, replacement) in SIMPLE_REPLACEMENTS.iter() { md = re.replace_all(&md, *replacement).into_owned(); } for tag in WRAPPED_TAGS.iter() { md = tag .re .replace_all(&md, |caps: &Captures<'_>| { format!("{}{}{}", tag.prefix, &caps[1], tag.suffix) }) .into_owned(); } md = LINK_RE .replace_all(&md, |caps: &Captures<'_>| { let href = &caps[1]; let text = &caps[2]; if href_is_safe(href) { format!("[{text}]({href})") } else { text.to_owned() } }) .into_owned(); md = TAG_RE.replace_all(&md, "").into_owned(); let decoded = decode_entities(&md); EXCESS_NEWLINES_RE .replace_all(decoded.trim_end(), "\n\n") .trim() .to_owned() } fn href_is_safe(href: &str) -> bool { let href = href.trim(); let Some(colon) = href.find(':') else { return true; }; let scheme = &href[..colon]; let looks_like_scheme = scheme .bytes() .next() .is_some_and(|b| b.is_ascii_alphabetic()) && scheme .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')); !looks_like_scheme || scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") } #[cfg(test)] mod tests { use super::*; fn identity(s: &str) -> String { s.to_owned() } #[test] fn converts_basic_markup() { assert_eq!(to_markdown("bold", identity), "**bold**"); assert_eq!(to_markdown("x", identity), "_x_"); assert_eq!(to_markdown("a
    b", identity), "a\nb"); } #[test] fn keeps_http_links() { assert_eq!( to_markdown(r#"t"#, identity), "[t](https://e.com)" ); assert_eq!( to_markdown(r#"t"#, identity), "[t](http://e.com)" ); } #[test] fn drops_dangerous_link_schemes_but_keeps_text() { assert_eq!( to_markdown(r#"click"#, identity), "click" ); assert_eq!( to_markdown(r#"click"#, identity), "click" ); } #[test] fn keeps_relative_links() { assert_eq!( to_markdown(r#"t"#, identity), "[t](/path)" ); } }