use crate::block::{Block, BlockType, ButtonStyle, CalloutType, EmbedType, ListType};
use crate::document::Document;
use crate::error::{BlocksError, Result};
use html_escape::encode_text;
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
use scraper::{Html, Selector};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConversionFormat {
Markdown,
Html,
Json,
JsonPretty,
PlainText,
}
pub struct Converter {
format: ConversionFormat,
}
impl Converter {
pub fn new(format: ConversionFormat) -> Self {
Self { format }
}
pub fn convert(&self, document: &Document) -> Result<String> {
match self.format {
ConversionFormat::Json => {
return serde_json::to_string(document).map_err(BlocksError::from);
}
ConversionFormat::JsonPretty => {
return serde_json::to_string_pretty(document).map_err(BlocksError::from);
}
ConversionFormat::PlainText => {
return self.convert_to_plain_text(document);
}
_ => {}
}
document.validate()?;
let mut output = String::new();
if !document.title.is_empty() {
match self.format {
ConversionFormat::Markdown => {
output.push_str(&format!("# {}\n\n", document.title));
}
ConversionFormat::Html => {
output.push_str(&format!("<h1>{}</h1>\n\n", encode_text(&document.title)));
}
_ => {}
}
}
for block in &document.blocks {
let block_output = self.convert_block(block)?;
output.push_str(&block_output);
output.push('\n');
}
Ok(output)
}
fn convert_to_plain_text(&self, document: &Document) -> Result<String> {
let mut output = String::new();
if !document.title.is_empty() {
output.push_str(&document.title);
output.push_str("\n\n");
}
for block in &document.blocks {
let text = self.extract_plain_text(block);
if !text.is_empty() {
output.push_str(&text);
output.push_str("\n\n");
}
}
Ok(output.trim_end().to_string())
}
fn extract_plain_text(&self, block: &Block) -> String {
match &block.block_type {
BlockType::Text | BlockType::Header { .. } | BlockType::Quote => block.content.clone(),
BlockType::List { .. } => block.content.lines().collect::<Vec<_>>().join("\n"),
BlockType::Code { .. } => block.content.clone(),
BlockType::Link { .. } => block.content.clone(),
BlockType::Image { alt, caption, .. } => caption.clone().unwrap_or_else(|| alt.clone()),
BlockType::Table { headers, rows, .. } => {
let mut text = headers.join(" | ");
for row in rows {
text.push('\n');
text.push_str(&row.join(" | "));
}
text
}
BlockType::Divider => String::new(),
BlockType::Embed { .. } => String::new(),
BlockType::Button { text, .. } => text.clone(),
BlockType::Callout { title, .. } => {
let mut text = title.clone().unwrap_or_default();
if !block.content.is_empty() {
if !text.is_empty() {
text.push_str(": ");
}
text.push_str(&block.content);
}
text
}
BlockType::Columns { content, .. } => content
.iter()
.flat_map(|col| col.iter())
.cloned()
.collect::<Vec<_>>()
.join("\n"),
BlockType::Details { summary, .. } => {
format!("{}: {}", summary, block.content)
}
}
}
pub fn convert_block(&self, block: &Block) -> Result<String> {
match self.format {
ConversionFormat::Markdown => self.convert_block_to_markdown(block),
ConversionFormat::Html => self.convert_block_to_html(block),
ConversionFormat::Json => serde_json::to_string(block).map_err(BlocksError::from),
ConversionFormat::JsonPretty => {
serde_json::to_string_pretty(block).map_err(BlocksError::from)
}
ConversionFormat::PlainText => Ok(self.extract_plain_text(block)),
}
}
fn convert_block_to_markdown(&self, block: &Block) -> Result<String> {
let result = match &block.block_type {
BlockType::Text => {
format!("{}\n", block.content)
}
BlockType::Header { level } => {
let prefix = "#".repeat(*level as usize);
format!("{} {}\n", prefix, block.content)
}
BlockType::List { list_type } => {
let lines: Vec<&str> = block.content.lines().collect();
let mut output = String::new();
for (index, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
continue;
}
let prefix = match list_type {
ListType::Ordered => format!("{}. ", index + 1),
ListType::Unordered => "- ".to_string(),
};
output.push_str(&format!("{}{}\n", prefix, line.trim()));
}
output
}
BlockType::Code { language } => {
let lang = language.as_deref().unwrap_or("");
format!("```{}\n{}\n```\n", lang, block.content)
}
BlockType::Quote => {
let lines: Vec<&str> = block.content.lines().collect();
let mut output = String::new();
for line in lines {
output.push_str(&format!("> {line}\n"));
}
output
}
BlockType::Link { url, title } => {
let title_part = if let Some(title) = title {
format!(" \"{title}\"")
} else {
String::new()
};
format!("[{}]({}{})\n", block.content, url, title_part)
}
BlockType::Image { url, alt, caption } => {
let mut output = format!("\n");
if let Some(caption) = caption {
output.push_str(&format!("*{caption}*\n"));
}
output
}
BlockType::Table {
headers,
rows,
has_header,
} => {
let mut output = String::new();
if *has_header && !headers.is_empty() {
output.push_str("| ");
output.push_str(&headers.join(" | "));
output.push_str(" |\n");
output.push_str("| ");
output.push_str(&vec!["---"; headers.len()].join(" | "));
output.push_str(" |\n");
}
for row in rows {
output.push_str("| ");
output.push_str(&row.join(" | "));
output.push_str(" |\n");
}
output.push('\n');
output
}
BlockType::Divider => "---\n\n".to_string(),
BlockType::Embed {
embed_type: _, url, ..
} => {
format!("[Embedded Content]({url})\n")
}
BlockType::Button { text, url, .. } => {
format!("[🔘 {text}]({url})\n")
}
BlockType::Callout {
callout_type,
title,
} => {
let icon = match callout_type {
CalloutType::Info => "ℹ️",
CalloutType::Warning => "⚠️",
CalloutType::Error => "❌",
CalloutType::Success => "✅",
CalloutType::Note => "📝",
CalloutType::Tip => "💡",
};
let title_text = if let Some(title) = title {
format!(" **{title}**")
} else {
String::new()
};
format!("> {}{}\n> \n> {}\n", icon, title_text, block.content)
}
BlockType::Columns { content, .. } => {
let mut output = String::new();
for (i, column) in content.iter().enumerate() {
if i > 0 {
output.push_str(" | ");
}
output.push_str(&column.join(" "));
}
output.push('\n');
output
}
BlockType::Details { summary, is_open } => {
let state = if *is_open { " open" } else { "" };
format!(
"<details{}>\n<summary>{}</summary>\n\n{}\n\n</details>\n",
state, summary, block.content
)
}
};
Ok(result)
}
fn convert_block_to_html(&self, block: &Block) -> Result<String> {
let result = match &block.block_type {
BlockType::Text => {
format!("<p>{}</p>\n", encode_text(&block.content))
}
BlockType::Header { level } => {
format!("<h{}>{}</h{}>\n", level, encode_text(&block.content), level)
}
BlockType::List { list_type } => {
let lines: Vec<&str> = block.content.lines().collect();
let mut output = String::new();
let tag = match list_type {
ListType::Ordered => "ol",
ListType::Unordered => "ul",
};
output.push_str(&format!("<{tag}>\n"));
for line in lines {
if line.trim().is_empty() {
continue;
}
output.push_str(&format!(" <li>{}</li>\n", encode_text(line.trim())));
}
output.push_str(&format!("</{tag}>\n"));
output
}
BlockType::Code { language } => {
let lang_attr = if let Some(lang) = language {
format!(" class=\"language-{}\"", encode_text(lang))
} else {
String::new()
};
format!(
"<pre><code{}>{}</code></pre>\n",
lang_attr,
encode_text(&block.content)
)
}
BlockType::Quote => {
let lines: Vec<&str> = block.content.lines().collect();
let mut output = String::new();
output.push_str("<blockquote>\n");
for line in lines {
if !line.trim().is_empty() {
output.push_str(&format!(" <p>{}</p>\n", encode_text(line)));
}
}
output.push_str("</blockquote>\n");
output
}
BlockType::Link { url, title } => {
let title_attr = if let Some(title) = title {
format!(" title=\"{}\"", encode_text(title))
} else {
String::new()
};
format!(
"<a href=\"{}\"{}>{}</a>\n",
encode_text(url),
title_attr,
encode_text(&block.content)
)
}
BlockType::Image { url, alt, caption } => {
let mut output = format!(
"<img src=\"{}\" alt=\"{}\">\n",
encode_text(url),
encode_text(alt)
);
if let Some(caption) = caption {
output.push_str(&format!("<p><em>{}</em></p>\n", encode_text(caption)));
}
output
}
BlockType::Table {
headers,
rows,
has_header,
} => {
let mut output = String::new();
output.push_str("<table>\n");
if *has_header && !headers.is_empty() {
output.push_str(" <thead>\n <tr>\n");
for header in headers {
output.push_str(&format!(" <th>{}</th>\n", encode_text(header)));
}
output.push_str(" </tr>\n </thead>\n");
}
if !rows.is_empty() {
output.push_str(" <tbody>\n");
for row in rows {
output.push_str(" <tr>\n");
for cell in row {
output.push_str(&format!(" <td>{}</td>\n", encode_text(cell)));
}
output.push_str(" </tr>\n");
}
output.push_str(" </tbody>\n");
}
output.push_str("</table>\n");
output
}
BlockType::Divider => "<hr>\n".to_string(),
BlockType::Embed {
embed_type,
url,
width,
height,
} => {
let width_attr = width.map(|w| format!(" width=\"{w}\"")).unwrap_or_default();
let height_attr = height
.map(|h| format!(" height=\"{h}\""))
.unwrap_or_default();
match embed_type {
EmbedType::YouTube => {
let video_id = if let Some(id) = url.split("v=").nth(1) {
id.split('&').next().unwrap_or(id)
} else if let Some(id) = url.split("youtu.be/").nth(1) {
id.split('?').next().unwrap_or(id)
} else {
return Ok(format!(
"<p>Invalid YouTube URL: {}</p>\n",
encode_text(url)
));
};
format!(
"<iframe src=\"https://www.youtube.com/embed/{video_id}\" frameborder=\"0\" allowfullscreen{width_attr}{height_attr}></iframe>\n"
)
}
EmbedType::Vimeo => {
let video_id = if let Some(id) = url.split("vimeo.com/").nth(1) {
id.split('?').next().unwrap_or(id)
} else {
return Ok(format!("<p>Invalid Vimeo URL: {}</p>\n", encode_text(url)));
};
format!(
"<iframe src=\"https://player.vimeo.com/video/{video_id}\" frameborder=\"0\" allowfullscreen{width_attr}{height_attr}></iframe>\n"
)
}
EmbedType::Iframe => {
format!(
"<iframe src=\"{}\" frameborder=\"0\"{}{}></iframe>\n",
encode_text(url),
width_attr,
height_attr
)
}
EmbedType::Audio => {
format!(
"<audio controls{}{}>\n <source src=\"{}\" type=\"audio/mpeg\">\n Your browser does not support the audio element.\n</audio>\n",
width_attr, height_attr, encode_text(url)
)
}
EmbedType::Video => {
format!(
"<video controls{}{}>\n <source src=\"{}\" type=\"video/mp4\">\n Your browser does not support the video element.\n</video>\n",
width_attr, height_attr, encode_text(url)
)
}
}
}
BlockType::Button { text, url, style } => {
let class = match style {
ButtonStyle::Primary => "btn btn-primary",
ButtonStyle::Secondary => "btn btn-secondary",
ButtonStyle::Danger => "btn btn-danger",
ButtonStyle::Success => "btn btn-success",
ButtonStyle::Warning => "btn btn-warning",
ButtonStyle::Info => "btn btn-info",
};
format!(
"<a href=\"{}\" class=\"{}\">{}</a>\n",
encode_text(url),
class,
encode_text(text)
)
}
BlockType::Callout {
callout_type,
title,
} => {
let (class, icon) = match callout_type {
CalloutType::Info => ("callout-info", "ℹ️"),
CalloutType::Warning => ("callout-warning", "⚠️"),
CalloutType::Error => ("callout-error", "❌"),
CalloutType::Success => ("callout-success", "✅"),
CalloutType::Note => ("callout-note", "📝"),
CalloutType::Tip => ("callout-tip", "💡"),
};
let title_html = if let Some(title) = title {
format!(
"<div class=\"callout-title\">{} <strong>{}</strong></div>",
icon,
encode_text(title)
)
} else {
format!("<div class=\"callout-icon\">{icon}</div>")
};
format!(
"<div class=\"callout {}\">\n {}\n <div class=\"callout-content\">{}</div>\n</div>\n",
class, title_html, encode_text(&block.content)
)
}
BlockType::Columns {
column_count,
content,
} => {
let mut output = format!("<div class=\"columns columns-{column_count}\">\n");
for column in content {
output.push_str(" <div class=\"column\">\n");
for item in column {
output.push_str(&format!(" <p>{}</p>\n", encode_text(item)));
}
output.push_str(" </div>\n");
}
output.push_str("</div>\n");
output
}
BlockType::Details { summary, is_open } => {
let open_attr = if *is_open { " open" } else { "" };
format!(
"<details{}>\n <summary>{}</summary>\n <div class=\"details-content\">{}</div>\n</details>\n",
open_attr, encode_text(summary), encode_text(&block.content)
)
}
};
Ok(result)
}
pub fn parse_markdown(&self, markdown: &str) -> Result<Document> {
let mut doc = Document::new();
let parser = Parser::new(markdown);
let mut current_content = String::new();
let mut in_code_block = false;
let mut code_language: Option<String> = None;
let mut list_items = Vec::new();
let mut in_list = false;
let mut list_ordered = false;
let mut quote_lines = Vec::new();
let mut in_quote = false;
for event in parser {
match event {
Event::Start(tag) => {
match tag {
Tag::Heading { level: _, .. } => {
current_content.clear();
}
Tag::Paragraph => {
if !in_quote {
current_content.clear();
}
}
Tag::CodeBlock(CodeBlockKind::Fenced(info)) => {
in_code_block = true;
code_language = if info.is_empty() {
None
} else {
Some(info.to_string())
};
current_content.clear();
}
Tag::CodeBlock(CodeBlockKind::Indented) => {
in_code_block = true;
code_language = None;
current_content.clear();
}
Tag::List(first_item_number) => {
in_list = true;
list_ordered = first_item_number.is_some();
list_items.clear();
}
Tag::Item => {
current_content.clear();
}
Tag::BlockQuote(_) => {
in_quote = true;
quote_lines.clear();
}
Tag::Link {
dest_url: _,
title: _,
..
} => {
}
Tag::Image {
dest_url: _,
title: _,
..
} => {
}
_ => {}
}
}
Event::End(tag_end) => {
match tag_end {
TagEnd::Heading(level) => {
if !current_content.trim().is_empty() {
if level == pulldown_cmark::HeadingLevel::H1 && doc.title.is_empty()
{
doc.title = current_content.trim().to_string();
} else {
let block = Block::new(
BlockType::Header { level: level as u8 },
current_content.trim().to_string(),
);
doc.add_block(block);
}
}
}
TagEnd::Paragraph => {
if !in_quote && !in_list && !current_content.trim().is_empty() {
let block =
Block::new(BlockType::Text, current_content.trim().to_string());
doc.add_block(block);
}
}
TagEnd::CodeBlock => {
if in_code_block {
let block = Block::new(
BlockType::Code {
language: code_language.clone(),
},
current_content.clone(),
);
doc.add_block(block);
in_code_block = false;
code_language = None;
}
}
TagEnd::List(_) => {
if in_list && !list_items.is_empty() {
let list_type = if list_ordered {
ListType::Ordered
} else {
ListType::Unordered
};
let block = Block::new(
BlockType::List { list_type },
list_items.join("\n"),
);
doc.add_block(block);
}
in_list = false;
list_items.clear();
}
TagEnd::Item => {
if in_list && !current_content.trim().is_empty() {
list_items.push(current_content.trim().to_string());
}
}
TagEnd::BlockQuote(_) => {
if in_quote && !quote_lines.is_empty() {
let block = Block::new(BlockType::Quote, quote_lines.join("\n"));
doc.add_block(block);
}
in_quote = false;
quote_lines.clear();
}
_ => {}
}
}
Event::Text(text) => {
if in_quote {
quote_lines.push(text.to_string());
} else {
current_content.push_str(&text);
}
}
Event::Code(text) => {
current_content.push_str(&format!("`{text}`"));
}
Event::SoftBreak | Event::HardBreak => {
if in_quote {
} else {
current_content.push(' ');
}
}
_ => {}
}
}
Ok(doc)
}
pub fn parse_html(&self, html: &str) -> Result<Document> {
let mut doc = Document::new();
let document = Html::parse_document(html);
if let Ok(title_selector) = Selector::parse("title") {
if let Some(title_element) = document.select(&title_selector).next() {
let title = title_element.text().collect::<String>();
if !title.trim().is_empty() {
doc = Document::with_title(title.trim().to_string());
}
}
}
if doc.title.is_empty() {
if let Ok(h1_selector) = Selector::parse("h1") {
if let Some(h1_element) = document.select(&h1_selector).next() {
let title = h1_element.text().collect::<String>();
if !title.trim().is_empty() {
doc = Document::with_title(title.trim().to_string());
}
}
}
}
self.parse_html_headers(&document, &mut doc)?;
self.parse_html_paragraphs(&document, &mut doc)?;
self.parse_html_lists(&document, &mut doc)?;
self.parse_html_code_blocks(&document, &mut doc)?;
self.parse_html_quotes(&document, &mut doc)?;
self.parse_html_links(&document, &mut doc)?;
self.parse_html_images(&document, &mut doc)?;
Ok(doc)
}
fn parse_html_headers(&self, document: &Html, doc: &mut Document) -> Result<()> {
let selectors = [
("h1", 1u8),
("h2", 2u8),
("h3", 3u8),
("h4", 4u8),
("h5", 5u8),
("h6", 6u8),
];
for (selector_str, level) in selectors {
if let Ok(selector) = Selector::parse(selector_str) {
for element in document.select(&selector) {
let content = element.text().collect::<String>();
if !content.trim().is_empty() && level > 1 {
let block =
Block::new(BlockType::Header { level }, content.trim().to_string());
doc.add_block(block);
}
}
}
}
Ok(())
}
fn parse_html_paragraphs(&self, document: &Html, doc: &mut Document) -> Result<()> {
if let Ok(selector) = Selector::parse("p") {
for element in document.select(&selector) {
let content = element.text().collect::<String>();
if !content.trim().is_empty() {
let block = Block::new(BlockType::Text, content.trim().to_string());
doc.add_block(block);
}
}
}
Ok(())
}
fn parse_html_lists(&self, document: &Html, doc: &mut Document) -> Result<()> {
if let Ok(ol_selector) = Selector::parse("ol") {
for ol_element in document.select(&ol_selector) {
let mut items = Vec::new();
if let Ok(li_selector) = Selector::parse("li") {
for li_element in ol_element.select(&li_selector) {
let item_text = li_element.text().collect::<String>();
if !item_text.trim().is_empty() {
items.push(item_text.trim().to_string());
}
}
}
if !items.is_empty() {
let block = Block::new(
BlockType::List {
list_type: ListType::Ordered,
},
items.join("\n"),
);
doc.add_block(block);
}
}
}
if let Ok(ul_selector) = Selector::parse("ul") {
for ul_element in document.select(&ul_selector) {
let mut items = Vec::new();
if let Ok(li_selector) = Selector::parse("li") {
for li_element in ul_element.select(&li_selector) {
let item_text = li_element.text().collect::<String>();
if !item_text.trim().is_empty() {
items.push(item_text.trim().to_string());
}
}
}
if !items.is_empty() {
let block = Block::new(
BlockType::List {
list_type: ListType::Unordered,
},
items.join("\n"),
);
doc.add_block(block);
}
}
}
Ok(())
}
fn parse_html_code_blocks(&self, document: &Html, doc: &mut Document) -> Result<()> {
if let Ok(selector) = Selector::parse("pre > code") {
for element in document.select(&selector) {
let content = element.text().collect::<String>();
if !content.trim().is_empty() {
let language = element.value().attr("class").and_then(|class| {
class.strip_prefix("language-").map(|lang| lang.to_string())
});
let block = Block::new(BlockType::Code { language }, content.to_string());
doc.add_block(block);
}
}
}
Ok(())
}
fn parse_html_quotes(&self, document: &Html, doc: &mut Document) -> Result<()> {
if let Ok(selector) = Selector::parse("blockquote") {
for element in document.select(&selector) {
let content = element.text().collect::<String>();
if !content.trim().is_empty() {
let block = Block::new(BlockType::Quote, content.trim().to_string());
doc.add_block(block);
}
}
}
Ok(())
}
fn parse_html_links(&self, document: &Html, doc: &mut Document) -> Result<()> {
if let Ok(selector) = Selector::parse("a[href]") {
for element in document.select(&selector) {
if let Some(href) = element.value().attr("href") {
let content = element.text().collect::<String>();
let title = element.value().attr("title").map(|t| t.to_string());
if !content.trim().is_empty() && !href.is_empty() {
let block = Block::new(
BlockType::Link {
url: href.to_string(),
title,
},
content.trim().to_string(),
);
doc.add_block(block);
}
}
}
}
Ok(())
}
fn parse_html_images(&self, document: &Html, doc: &mut Document) -> Result<()> {
if let Ok(selector) = Selector::parse("img[src]") {
for element in document.select(&selector) {
if let Some(src) = element.value().attr("src") {
let alt = element.value().attr("alt").unwrap_or("").to_string();
let title = element.value().attr("title").map(|t| t.to_string());
if !src.is_empty() && !alt.is_empty() {
let block = Block::new(
BlockType::Image {
url: src.to_string(),
alt,
caption: title,
},
String::new(),
);
doc.add_block(block);
}
}
}
}
Ok(())
}
}
pub trait Convertible {
fn to_format(&self, format: ConversionFormat) -> Result<String>;
}
impl Convertible for Block {
fn to_format(&self, format: ConversionFormat) -> Result<String> {
self.validate()?;
let converter = Converter::new(format);
converter.convert_block(self)
}
}
impl Convertible for Document {
fn to_format(&self, format: ConversionFormat) -> Result<String> {
let converter = Converter::new(format);
converter.convert(self)
}
}
impl Converter {
pub fn from_markdown(markdown: &str) -> Result<Document> {
let converter = Converter::new(ConversionFormat::Markdown);
converter.parse_markdown(markdown)
}
pub fn from_html(html: &str) -> Result<Document> {
let converter = Converter::new(ConversionFormat::Html);
converter.parse_html(html)
}
pub fn from_json(json: &str) -> Result<Document> {
serde_json::from_str(json).map_err(BlocksError::from)
}
pub fn block_from_json(json: &str) -> Result<Block> {
serde_json::from_str(json).map_err(BlocksError::from)
}
pub fn blocks_from_json(json: &str) -> Result<Vec<Block>> {
serde_json::from_str(json).map_err(BlocksError::from)
}
}
pub trait JsonSerializable {
fn to_json(&self) -> Result<String>;
fn to_json_pretty(&self) -> Result<String>;
}
impl JsonSerializable for Block {
fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(BlocksError::from)
}
fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(BlocksError::from)
}
}
impl JsonSerializable for Document {
fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(BlocksError::from)
}
fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(BlocksError::from)
}
}
impl JsonSerializable for Vec<Block> {
fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(BlocksError::from)
}
fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(BlocksError::from)
}
}
impl Block {
pub fn from_json(json: &str) -> Result<Self> {
Converter::block_from_json(json)
}
}
impl Document {
pub fn from_json(json: &str) -> Result<Self> {
Converter::from_json(json)
}
pub fn from_markdown(markdown: &str) -> Result<Self> {
Converter::from_markdown(markdown)
}
pub fn from_html(html: &str) -> Result<Self> {
Converter::from_html(html)
}
pub fn to_json(&self) -> Result<String> {
JsonSerializable::to_json(self)
}
pub fn to_json_pretty(&self) -> Result<String> {
JsonSerializable::to_json_pretty(self)
}
pub fn to_markdown(&self) -> Result<String> {
self.to_format(ConversionFormat::Markdown)
}
pub fn to_html(&self) -> Result<String> {
self.to_format(ConversionFormat::Html)
}
pub fn to_plain_text(&self) -> Result<String> {
self.to_format(ConversionFormat::PlainText)
}
}
#[cfg(test)]
mod parser_tests {
use super::*;
use crate::converters::Converter;
#[test]
fn test_markdown_to_blocks_headers() {
let markdown = r#"# Main Title
## Subtitle
### Smaller Header
"#;
let doc = Converter::from_markdown(markdown).unwrap();
assert_eq!(doc.len(), 2);
let blocks = &doc.blocks;
match &blocks[0].block_type {
BlockType::Header { level } => assert_eq!(*level, 2),
_ => panic!("Expected header block"),
}
assert_eq!(blocks[0].content, "Subtitle");
match &blocks[1].block_type {
BlockType::Header { level } => assert_eq!(*level, 3),
_ => panic!("Expected header block"),
}
assert_eq!(blocks[1].content, "Smaller Header");
}
#[test]
fn test_markdown_to_blocks_text() {
let markdown = r#"This is a paragraph.
This is another paragraph.
"#;
let doc = Converter::from_markdown(markdown).unwrap();
assert_eq!(doc.len(), 2);
let blocks = &doc.blocks;
for block in blocks {
match &block.block_type {
BlockType::Text => {}
_ => panic!("Expected text block"),
}
}
assert_eq!(blocks[0].content, "This is a paragraph.");
assert_eq!(blocks[1].content, "This is another paragraph.");
}
#[test]
fn test_markdown_to_blocks_code() {
let markdown = r#"```rust
fn main() {
println!("Hello, world!");
}
```
"#;
let doc = Converter::from_markdown(markdown).unwrap();
assert_eq!(doc.len(), 1);
let blocks = &doc.blocks;
match &blocks[0].block_type {
BlockType::Code { language } => {
assert_eq!(language.as_ref().unwrap(), "rust");
}
_ => panic!("Expected code block"),
}
assert!(blocks[0].content.contains("fn main()"));
}
#[test]
fn test_markdown_to_blocks_list() {
let markdown = r#"- Item 1
- Item 2
- Item 3
1. First item
2. Second item
3. Third item
"#;
let doc = Converter::from_markdown(markdown).unwrap();
assert_eq!(doc.len(), 2);
let blocks = &doc.blocks;
match &blocks[0].block_type {
BlockType::List { list_type } => {
assert_eq!(*list_type, ListType::Unordered);
}
_ => panic!(
"Expected unordered list block, got {:?}",
blocks[0].block_type
),
}
assert!(blocks[0].content.contains("Item 1"));
assert!(blocks[0].content.contains("Item 2"));
assert!(blocks[0].content.contains("Item 3"));
match &blocks[1].block_type {
BlockType::List { list_type } => {
assert_eq!(*list_type, ListType::Ordered);
}
_ => panic!(
"Expected ordered list block, got {:?}",
blocks[1].block_type
),
}
assert!(blocks[1].content.contains("First item"));
assert!(blocks[1].content.contains("Second item"));
assert!(blocks[1].content.contains("Third item"));
}
#[test]
fn test_markdown_to_blocks_quote() {
let markdown = r#"> This is a quote.
> It spans multiple lines.
"#;
let doc = Converter::from_markdown(markdown).unwrap();
assert_eq!(doc.len(), 1);
let blocks = &doc.blocks;
match &blocks[0].block_type {
BlockType::Quote => {}
_ => panic!("Expected quote block"),
}
assert!(blocks[0].content.contains("This is a quote."));
assert!(blocks[0].content.contains("It spans multiple lines."));
}
#[test]
fn test_roundtrip_markdown() {
let original_markdown = r#"# Main Title
## Subtitle
This is a paragraph.
```rust
fn main() {
println!("Hello, world!");
}
```
- Item 1
- Item 2
- Item 3
> This is a quote.
"#;
let doc = Converter::from_markdown(original_markdown).unwrap();
let converter = Converter::new(ConversionFormat::Markdown);
let converted_markdown = converter.convert(&doc).unwrap();
let doc2 = Converter::from_markdown(&converted_markdown).unwrap();
assert_eq!(doc.len(), doc2.len());
let blocks1 = &doc.blocks;
let blocks2 = &doc2.blocks;
for (b1, b2) in blocks1.iter().zip(blocks2.iter()) {
assert_eq!(b1.block_type, b2.block_type);
}
}
}
#[cfg(test)]
mod json_tests {
use super::*;
#[test]
fn test_document_to_json() {
let mut doc = Document::with_title("Test".to_string());
doc.add_block(Block::new(BlockType::Text, "Hello".to_string()));
let json = doc.to_json().unwrap();
assert!(json.contains("\"title\":\"Test\""));
assert!(json.contains("\"content\":\"Hello\""));
}
#[test]
fn test_document_to_json_pretty() {
let mut doc = Document::with_title("Test".to_string());
doc.add_block(Block::new(BlockType::Text, "Hello".to_string()));
let json = doc.to_json_pretty().unwrap();
assert!(json.contains("\n")); assert!(json.contains(" ")); }
#[test]
fn test_document_from_json() {
let mut original = Document::with_title("Test Doc".to_string());
original.add_block(Block::new(BlockType::Text, "Content".to_string()));
original.add_block(Block::new(
BlockType::Header { level: 2 },
"Header".to_string(),
));
let json = original.to_json().unwrap();
let restored = Document::from_json(&json).unwrap();
assert_eq!(restored.title, original.title);
assert_eq!(restored.blocks.len(), original.blocks.len());
assert_eq!(restored.blocks[0].block_type, original.blocks[0].block_type);
assert_eq!(restored.blocks[0].content, original.blocks[0].content);
}
#[test]
fn test_block_to_json() {
let block = Block::new(
BlockType::Code {
language: Some("rust".to_string()),
},
"fn main() {}".to_string(),
);
let json = block.to_json().unwrap();
assert!(json.contains("\"Code\""));
assert!(json.contains("\"rust\""));
assert!(json.contains("fn main()"));
}
#[test]
fn test_block_from_json() {
let original = Block::new(BlockType::Quote, "A quote".to_string());
let json = original.to_json().unwrap();
let restored = Block::from_json(&json).unwrap();
assert_eq!(restored.block_type, original.block_type);
assert_eq!(restored.content, original.content);
}
#[test]
fn test_blocks_from_json() {
let blocks = vec![
Block::new(BlockType::Text, "Text 1".to_string()),
Block::new(BlockType::Text, "Text 2".to_string()),
];
let json = JsonSerializable::to_json(&blocks).unwrap();
let restored = Converter::blocks_from_json(&json).unwrap();
assert_eq!(restored.len(), 2);
assert_eq!(restored[0].content, "Text 1");
assert_eq!(restored[1].content, "Text 2");
}
#[test]
fn test_json_roundtrip_all_block_types() {
use crate::block::{ButtonStyle, CalloutType, EmbedType};
let blocks = vec![
Block::new(BlockType::Text, "text".to_string()),
Block::new(BlockType::Header { level: 3 }, "header".to_string()),
Block::new(
BlockType::List {
list_type: ListType::Ordered,
},
"item".to_string(),
),
Block::new(
BlockType::Code {
language: Some("python".to_string()),
},
"code".to_string(),
),
Block::new(BlockType::Quote, "quote".to_string()),
Block::new(
BlockType::Link {
url: "https://example.com".to_string(),
title: Some("Link".to_string()),
},
"click".to_string(),
),
Block::new(
BlockType::Image {
url: "img.jpg".to_string(),
alt: "alt".to_string(),
caption: Some("cap".to_string()),
},
"".to_string(),
),
Block::new(
BlockType::Table {
headers: vec!["A".to_string()],
rows: vec![vec!["1".to_string()]],
has_header: true,
},
"".to_string(),
),
Block::new(BlockType::Divider, "".to_string()),
Block::new(
BlockType::Embed {
embed_type: EmbedType::YouTube,
url: "yt.com".to_string(),
width: Some(640),
height: Some(480),
},
"".to_string(),
),
Block::new(
BlockType::Button {
text: "Click".to_string(),
url: "btn.com".to_string(),
style: ButtonStyle::Primary,
},
"".to_string(),
),
Block::new(
BlockType::Callout {
callout_type: CalloutType::Warning,
title: Some("Warn".to_string()),
},
"warning".to_string(),
),
Block::new(
BlockType::Details {
summary: "More".to_string(),
is_open: false,
},
"details".to_string(),
),
];
for original in blocks {
let json = original.to_json().unwrap();
let restored = Block::from_json(&json).unwrap();
assert_eq!(
restored.block_type, original.block_type,
"Block type mismatch for {:?}",
original.block_type
);
assert_eq!(restored.content, original.content);
}
}
#[test]
fn test_conversion_format_json() {
let mut doc = Document::with_title("Test".to_string());
doc.add_block(Block::new(BlockType::Text, "Content".to_string()));
let json = doc.to_format(ConversionFormat::Json).unwrap();
let pretty = doc.to_format(ConversionFormat::JsonPretty).unwrap();
let from_json: Document = serde_json::from_str(&json).unwrap();
let from_pretty: Document = serde_json::from_str(&pretty).unwrap();
assert_eq!(from_json.title, doc.title);
assert_eq!(from_pretty.title, doc.title);
}
#[test]
fn test_plain_text_conversion() {
let mut doc = Document::with_title("Title".to_string());
doc.add_block(Block::new(
BlockType::Header { level: 1 },
"Header".to_string(),
));
doc.add_block(Block::new(BlockType::Text, "Some text content".to_string()));
doc.add_block(Block::new(
BlockType::List {
list_type: ListType::Unordered,
},
"Item 1\nItem 2".to_string(),
));
let plain = doc.to_plain_text().unwrap();
assert!(plain.contains("Title"));
assert!(plain.contains("Header"));
assert!(plain.contains("Some text content"));
assert!(plain.contains("Item 1"));
}
#[test]
fn test_document_convenience_methods() {
let mut doc = Document::with_title("Test".to_string());
doc.add_block(Block::new(BlockType::Text, "Hello".to_string()));
assert!(doc.to_json().is_ok());
assert!(doc.to_json_pretty().is_ok());
assert!(doc.to_markdown().is_ok());
assert!(doc.to_html().is_ok());
assert!(doc.to_plain_text().is_ok());
}
}