use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html}; pub(crate) fn extract_local_refs(md: &str) -> Vec<String> { let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; let mut refs = Vec::new(); for event in Parser::new_ext(md, opts) { let url: Option<pulldown_cmark::CowStr> = match event { Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url), Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url), Event::End(TagEnd::Image | TagEnd::Link) => None, _ => None, }; if let Some(url) = url { let s = url.as_ref(); if !s.contains("://") && !s.starts_with('#') && !s.is_empty() { refs.push(s.to_owned()); } } } refs } pub(crate) fn render_markdown_html(md: &str) -> String { let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; let parser = Parser::new_ext(md, opts); let mut buf = String::new(); html::push_html(&mut buf, parser); buf } pub(crate) fn render_markdown_gemtext(md: &str) -> String { let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; let parser = Parser::new_ext(md, opts); let mut out: Vec<String> = Vec::new(); let mut buf = String::new(); let mut links: Vec<(String, String)> = Vec::new(); let mut last_blank = false; enum Ctx { Paragraph, BlockQuote, ListItem, Heading(u8), } let mut stack: Vec<Ctx> = Vec::new(); let mut in_link = false; let mut link_url = String::new(); let mut link_text = String::new(); let mut in_code = false; let mut code_kind = String::new(); let mut code_body = String::new(); fn in_ctx(stack: &[Ctx], target: Ctx) -> bool { stack .iter() .any(|c| std::mem::discriminant(c) == std::mem::discriminant(&target)) } fn flush_line( out: &mut Vec<String>, buf: &mut String, links: &mut Vec<(String, String)>, stack: &[Ctx], _last_blank: &mut bool, ) { let text = buf.trim(); if text.is_empty() && links.is_empty() { return; } let in_blockquote = in_ctx(stack, Ctx::BlockQuote); let in_list = in_ctx(stack, Ctx::ListItem); if let Some(Ctx::Heading(lvl)) = stack.last() { let prefix = match lvl { 1 => "#", 2 => "##", _ => "###", }; if !text.is_empty() { out.push(format!("{prefix} {text}")); } for (url, t) in links.drain(..) { out.push(format!("=> {url} {t}")); } *_last_blank = false; } else if in_blockquote { if !text.is_empty() { for line in text.lines() { out.push(format!("> {line}")); } } for (url, t) in links.drain(..) { out.push(format!("=> {url} {t}")); } *_last_blank = false; } else if in_list { if !text.is_empty() { out.push(format!("* {text}")); } for (url, t) in links.drain(..) { out.push(format!("=> {url} {t}")); } *_last_blank = false; } else { if !text.is_empty() { out.push(text.to_string()); } for (url, t) in links.drain(..) { out.push(format!("=> {url} {t}")); } *_last_blank = false; } buf.clear(); } for event in parser { match event { Event::Start(tag) => match tag { Tag::Heading { level, .. } => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); let lvl = level as u8; stack.push(Ctx::Heading(if lvl > 3 { 3 } else { lvl })); } Tag::Paragraph => { stack.push(Ctx::Paragraph); } Tag::BlockQuote(_) => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); stack.push(Ctx::BlockQuote); } Tag::CodeBlock(kind) => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); in_code = true; code_kind = match kind { CodeBlockKind::Fenced(info) => info.to_string(), CodeBlockKind::Indented => String::new(), }; code_body.clear(); } Tag::List { .. } => {} Tag::Item => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); stack.push(Ctx::ListItem); } Tag::Link { dest_url, .. } => { in_link = true; link_url = dest_url.to_string(); link_text.clear(); } Tag::Image { dest_url, .. } => { in_link = true; link_url = dest_url.to_string(); link_text.clear(); } _ => {} }, Event::End(tag) => match tag { TagEnd::Heading(_) => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); stack.pop(); } TagEnd::Paragraph => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); if !in_ctx(&stack, Ctx::BlockQuote) && !in_ctx(&stack, Ctx::ListItem) { out.push(String::new()); last_blank = true; } stack.pop(); } TagEnd::BlockQuote(_) => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); out.push(String::new()); last_blank = true; stack.pop(); } TagEnd::CodeBlock => { if !code_body.is_empty() { out.push(format!("```{}", code_kind)); for line in code_body.trim().lines() { out.push(line.to_string()); } out.push("```".to_string()); out.push(String::new()); last_blank = true; } in_code = false; code_kind.clear(); code_body.clear(); } TagEnd::List(_) => { out.push(String::new()); last_blank = true; } TagEnd::Item => { flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); stack.pop(); } TagEnd::Link => { let t = link_text.trim().to_string(); if in_link && !link_url.is_empty() && !t.is_empty() { links.push((link_url.clone(), t)); } in_link = false; link_url.clear(); link_text.clear(); } TagEnd::Image => { let t = if link_text.trim().is_empty() { "image".to_string() } else { link_text.trim().to_string() }; if in_link { out.push(format!("=> {} {}", link_url, t)); } in_link = false; link_url.clear(); link_text.clear(); } _ => {} }, Event::Text(text) => { if in_code { code_body.push_str(&text); } else if in_link { link_text.push_str(&text); } else { buf.push_str(&text); } } Event::Code(text) => { if in_link { link_text.push_str(&text); } else { buf.push_str(&text); } } Event::SoftBreak | Event::HardBreak => { if in_code { code_body.push('\n'); } else { buf.push(' '); } } _ => {} } } flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); while out.last().is_some_and(|l| l.is_empty()) { out.pop(); } out.join("\n") + "\n" } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_local_refs_none() { let md = "# Hello\n\nJust text, no links.\n"; assert!(extract_local_refs(md).is_empty()); } #[test] fn test_extract_local_refs_absolute_url() { let md = "[foo](https://example.com)"; assert!(extract_local_refs(md).is_empty()); } #[test] fn test_extract_local_refs_relative() { let md = "[foo](/bar/baz)"; assert_eq!(extract_local_refs(md), vec!["/bar/baz"]); } #[test] fn test_extract_local_refs_fragment() { let md = "[foo](#section)"; assert!(extract_local_refs(md).is_empty()); } #[test] fn test_render_markdown_html_basic() { let md = "# Hello\n\nThis is **bold**."; let html = render_markdown_html(md); assert!(html.contains("<h1>Hello</h1>")); assert!(html.contains("<strong>bold</strong>")); } #[test] fn test_render_markdown_html_table() { let md = "| A | B |\n|---|---|\n| 1 | 2 |"; let html = render_markdown_html(md); assert!(html.contains("<table>")); } #[test] fn test_render_markdown_gemtext_heading() { let md = "# Title\n\n## Subtitle\n\n### H3\n\n#### H4 (clamped)"; let gmi = render_markdown_gemtext(md); assert_eq!(gmi.lines().next(), Some("# Title")); assert!(gmi.contains("## Subtitle")); assert!(gmi.contains("### H3")); assert!(gmi.contains("### H4 (clamped)")); } #[test] fn test_render_markdown_gemtext_paragraph() { let md = "Hello, world!"; let gmi = render_markdown_gemtext(md); assert!(gmi.starts_with("Hello, world!")); } #[test] fn test_render_markdown_gemtext_link() { let md = "[example](https://example.com)"; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("=> https://example.com example")); } #[test] fn test_render_markdown_gemtext_image() { let md = ""; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("=> img.png alt")); } #[test] fn test_render_markdown_gemtext_image_no_alt() { let md = ""; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("=> img.png image")); } #[test] fn test_render_markdown_gemtext_blockquote() { let md = "> A wise quote."; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("> A wise quote.")); } #[test] fn test_render_markdown_gemtext_list() { let md = "- one\n- two\n- three"; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("* one")); assert!(gmi.contains("* two")); assert!(gmi.contains("* three")); } #[test] fn test_render_markdown_gemtext_code_block() { let md = "```rust\nfn main() {}\n```"; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("```rust")); assert!(gmi.contains("fn main() {}")); assert!(gmi.contains("```")); } #[test] fn test_render_markdown_gemtext_empty_code_block() { let md = "```\n```"; let gmi = render_markdown_gemtext(md); // Empty code blocks are silently dropped. assert!(!gmi.contains("```")); } #[test] fn test_render_markdown_gemtext_trailing_newline() { let md = "# Hi"; let gmi = render_markdown_gemtext(md); assert!(gmi.ends_with('\n')); } #[test] fn test_render_markdown_gemtext_inline_code() { let md = "use `foo` here"; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("foo")); } #[test] fn test_render_markdown_gemtext_soft_break() { let md = "line1\nline2"; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("line1 line2") || gmi.contains("line1\nline2")); } #[test] fn test_render_markdown_gemtext_config_option() { // Ensure ENABLE_TABLES and ENABLE_STRIKETHROUGH are set let md = "~~strike~~"; let gmi = render_markdown_gemtext(md); assert!(gmi.contains("strike")); } }