use crate::{Transform, TransformError, TransformerCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HtmlToMarkdown;
impl Transform for HtmlToMarkdown {
fn name(&self) -> &'static str {
"HTML to Markdown"
}
fn id(&self) -> &'static str {
"htmltomarkdown"
}
fn category(&self) -> TransformerCategory {
TransformerCategory::Formatter
}
fn description(&self) -> &'static str {
"Converts HTML to Markdown format"
}
fn transform(&self, input: &str) -> Result<String, TransformError> {
let mut markdown = String::new();
let mut in_code_block = false;
let mut code_block_content = String::new();
let mut code_language = String::new();
let mut in_blockquote = false;
let mut list_stack = Vec::new(); let lines = input.lines();
for mut line in lines {
if !in_code_block {
line = line.trim();
}
if let Some(after_tag) = line.strip_prefix("<pre><code") {
in_code_block = true;
code_block_content.clear();
code_language.clear();
if let Some(class_attr) = after_tag.find("class=") {
if let Some(language_part) = after_tag[class_attr..].find("language-") {
let language_start = class_attr + language_part + 9;
if let Some(quote_end) = after_tag[language_start..].find('"') {
code_language =
after_tag[language_start..language_start + quote_end].to_string();
}
}
}
if let Some(content_start) = after_tag.find('>') {
let content = &after_tag[content_start + 1..];
if !content.is_empty() {
code_block_content.push_str(content);
code_block_content.push('\n');
}
}
continue;
} else if line == "</code></pre>" {
in_code_block = false;
markdown.push_str("```");
if !code_language.is_empty() {
markdown.push_str(&code_language);
}
markdown.push('\n');
if !code_block_content.is_empty() {
markdown.push_str(&code_block_content);
}
markdown.push_str("```\n\n");
continue;
}
if in_code_block {
code_block_content.push_str(line);
code_block_content.push('\n');
continue;
}
if line.starts_with("<blockquote>") {
in_blockquote = true;
if line.ends_with("</blockquote>") {
let content =
line["<blockquote>".len()..line.len() - "</blockquote>".len()].trim();
let processed_content = replace_html_tags_with_markdown(content);
markdown.push_str(&format!("> {}\n", processed_content));
in_blockquote = false;
}
continue;
} else if line.starts_with("<p>") && in_blockquote {
if let Some(content) = line["<p>".len()..].trim().strip_suffix("</p>") {
let processed_content = replace_html_tags_with_markdown(content);
markdown.push_str(&format!("> {}\n", processed_content));
}
continue;
} else if line == "</blockquote>" {
in_blockquote = false;
markdown.push('\n');
continue;
}
if in_blockquote && !line.starts_with("<") && !line.ends_with(">") {
let processed_line = replace_html_tags_with_markdown(line);
markdown.push_str(&format!("> {}\n", processed_line));
continue;
}
if line == "<hr>" || line == "<hr/>" || line == "<hr />" {
markdown.push_str("---\n\n");
continue;
}
let mut is_header = false;
for i in 1..=6 {
let tag = format!("<h{}>", i);
let closing_tag = format!("</h{}>", i);
if line.starts_with(&tag) && line.ends_with(&closing_tag) {
let content = line[tag.len()..line.len() - closing_tag.len()].trim();
let processed_content = replace_html_tags_with_markdown(content);
markdown.push_str(&format!("{} {}\n\n", "#".repeat(i), processed_content));
is_header = true;
break;
}
}
if is_header {
continue;
}
if line.starts_with("<ul>") {
list_stack.push(("ul", 0));
continue;
} else if line.starts_with("<ol>") {
list_stack.push(("ol", 0));
continue;
} else if line == "</ul>" || line == "</ol>" {
if !list_stack.is_empty() {
list_stack.pop();
}
if list_stack.is_empty() {
markdown.push('\n');
}
continue;
}
if line.starts_with("<li>") && line.ends_with("</li>") {
let content = line[4..line.len() - 5].trim();
let processed_line = replace_html_tags_with_markdown(content);
let indent = list_stack.len().saturating_sub(1) * 2;
let list_marker = if !list_stack.is_empty() && list_stack.last().unwrap().0 == "ol"
{
let last_idx = list_stack.len() - 1;
let (list_type, count) = list_stack[last_idx];
let new_count = count + 1;
list_stack[last_idx] = (list_type, new_count);
format!("{}. ", new_count)
} else {
"- ".to_string()
};
markdown.push_str(&format!(
"{}{}{}\n",
" ".repeat(indent),
list_marker,
processed_line
));
continue;
}
let mut processed_line = line.to_string();
processed_line = replace_html_tags_with_markdown(&processed_line);
if line.starts_with("<p>") && line.ends_with("</p>") {
let content = &processed_line[3..processed_line.len() - 4].trim();
if !content.is_empty() {
markdown.push_str(&format!("{}\n\n", content));
}
} else if !line.is_empty() && !line.starts_with("<") && !line.ends_with(">") {
markdown.push_str(&format!("{}\n\n", processed_line));
} else if !(processed_line.is_empty()
|| (processed_line.starts_with("<") && processed_line.ends_with(">")))
{
markdown.push_str(&format!("{}\n", processed_line));
}
}
Ok(markdown.trim().to_string())
}
fn default_test_input(&self) -> &'static str {
"<h1>Hello World</h1>\n<p>This is a <strong>bold</strong> and <em>italic</em> text.</p>\n<ul>\n<li>List item 1</li>\n<li>List item 2</li>\n</ul>\n<ol>\n<li>Ordered item 1</li>\n<li>Ordered item 2</li>\n</ol>\n<p><a href=\"https://example.com\">Link text</a></p>\n<blockquote><p>A blockquote</p></blockquote>\n<hr>\n<pre><code class=\"language-rust\">fn main() {\n println!(\"Hello, world!\");\n}\n</code></pre>"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_html_to_markdown() {
let transformer = HtmlToMarkdown;
let input = "<h1>Title</h1>\n<p>This is <strong>bold</strong> and <em>italic</em>.</p>\n<ul>\n<li>Item 1</li>\n<li>Item 2</li>\n</ul>\n<p><a href=\"https://example.com\">Link</a></p>";
let expected = "# Title\n\nThis is **bold** and *italic*.\n\n- Item 1\n- Item 2\n\n[Link](https://example.com)";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_code_block() {
let transformer = HtmlToMarkdown;
let input = "<pre><code>code here\n</code></pre>";
let expected = "```\ncode here\n```";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_code_block_with_language() {
let transformer = HtmlToMarkdown;
let input = "<pre><code class=\"language-rust\">fn main() {\n println!(\"Hello!\");\n}\n</code></pre>";
let expected = "```rust\nfn main() {\n println!(\"Hello!\");\n}\n```";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_ordered_list() {
let transformer = HtmlToMarkdown;
let input = "<ol>\n<li>First item</li>\n<li>Second item</li>\n</ol>";
let expected = "1. First item\n2. Second item";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_blockquote() {
let transformer = HtmlToMarkdown;
let input = "<blockquote>This is a quote</blockquote>";
let expected = "> This is a quote";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
#[test]
fn test_horizontal_rule() {
let transformer = HtmlToMarkdown;
let input = "<p>Before</p>\n<hr>\n<p>After</p>";
let expected = "Before\n\n---\n\nAfter";
assert_eq!(transformer.transform(input).unwrap(), expected);
}
}
fn replace_html_tags_with_markdown(input: &str) -> String {
let mut s = input.to_string();
while let Some(start) = s.find("<a href=\"") {
if let Some(href_end) = s[start + 9..].find('"') {
let href_start = start + 9;
let href_end = href_start + href_end;
let url = &s[href_start..href_end];
if let Some(text_start) = s[href_end..].find('>') {
let text_start = href_end + text_start + 1;
if let Some(text_end) = s[text_start..].find("</a>") {
let text_end = text_start + text_end;
let text = &s[text_start..text_end];
let replacement = format!("[{}]({})", text, url);
s.replace_range(start..text_end + 4, &replacement);
} else {
break;
}
} else {
break;
}
} else {
break;
}
}
while let Some(start) = s.find("<strong>") {
if let Some(end) = s[start..].find("</strong>") {
let content_start = start + 8;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("**{}**", content);
s.replace_range(start..content_end + 9, &replacement);
} else {
break;
}
}
while let Some(start) = s.find("<b>") {
if let Some(end) = s[start..].find("</b>") {
let content_start = start + 3;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("**{}**", content);
s.replace_range(start..content_end + 4, &replacement);
} else {
break;
}
}
while let Some(start) = s.find("<em>") {
if let Some(end) = s[start..].find("</em>") {
let content_start = start + 4;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("*{}*", content);
s.replace_range(start..content_end + 5, &replacement);
} else {
break;
}
}
while let Some(start) = s.find("<i>") {
if let Some(end) = s[start..].find("</i>") {
let content_start = start + 3;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("*{}*", content);
s.replace_range(start..content_end + 4, &replacement);
} else {
break;
}
}
while let Some(start) = s.find("<s>") {
if let Some(end) = s[start..].find("</s>") {
let content_start = start + 3;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("~~{}~~", content);
s.replace_range(start..content_end + 4, &replacement);
} else {
break;
}
}
while let Some(start) = s.find("<del>") {
if let Some(end) = s[start..].find("</del>") {
let content_start = start + 5;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("~~{}~~", content);
s.replace_range(start..content_end + 6, &replacement);
} else {
break;
}
}
while let Some(start) = s.find("<code>") {
if let Some(end) = s[start..].find("</code>") {
let content_start = start + 6;
let content_end = start + end;
let content = &s[content_start..content_end];
let replacement = format!("`{}`", content);
s.replace_range(start..content_end + 7, &replacement);
} else {
break;
}
}
s
}