use devup_editor_core::{
Block, Document, DocumentExport, DocumentImport, IdGenerator, Mark, TextSpan,
};
use serde_json::Value;
use crate::MarkdownError;
use crate::import::parse_markdown;
pub struct Markdown;
impl DocumentExport for Markdown {
type Output = String;
type Error = MarkdownError;
fn export(doc: &Document) -> Result<String, MarkdownError> {
let mut out = String::new();
for id in doc.root_block_ids() {
let Some(block) = doc.get_block(id) else {
continue;
};
write_block(&mut out, block);
}
Ok(out)
}
}
impl DocumentImport for Markdown {
type Input = String;
type Error = MarkdownError;
fn import(input: String, id_gen: &mut dyn IdGenerator) -> Result<Document, MarkdownError> {
Ok(parse_markdown(&input, id_gen))
}
}
fn write_block(out: &mut String, block: &Block) {
let indent_str = " ".repeat(usize::try_from(block.indent_level()).unwrap_or(0));
let inline = render_inline(&block.content);
let plain = block.plain_text();
match block.ty.as_str() {
"heading" => {
let level = block
.props
.get("level")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 6) as usize;
out.push_str(&indent_str);
out.push_str(&"#".repeat(level));
out.push(' ');
out.push_str(&inline);
out.push_str("\n\n");
}
"todo" => {
let checked = block
.props
.get("checked")
.and_then(Value::as_bool)
.unwrap_or(false);
out.push_str(&indent_str);
out.push_str(if checked { "- [x] " } else { "- [ ] " });
out.push_str(&inline);
out.push('\n');
}
"list" => {
out.push_str(&indent_str);
let style = block
.props
.get("style")
.and_then(Value::as_str)
.unwrap_or("unordered");
if style.starts_with("ordered") {
out.push_str("1. ");
} else {
out.push_str("- ");
}
out.push_str(&inline);
out.push('\n');
}
"quote" => {
for line in plain.split('\n') {
out.push_str(&indent_str);
out.push_str("> ");
out.push_str(&escape_markdown(line));
out.push('\n');
}
out.push('\n');
}
"code" => {
let lang = block
.props
.get("language")
.and_then(Value::as_str)
.unwrap_or("");
out.push_str(&indent_str);
out.push_str("```");
out.push_str(lang);
out.push('\n');
for line in plain.split('\n') {
out.push_str(&indent_str);
out.push_str(line);
out.push('\n');
}
out.push_str(&indent_str);
out.push_str("```\n\n");
}
"toggle" => {
out.push_str(&indent_str);
out.push_str("**");
out.push_str(&inline);
out.push_str("**\n\n");
}
_ => {
out.push_str(&indent_str);
out.push_str(&inline);
out.push_str("\n\n");
}
}
}
fn render_inline(spans: &[TextSpan]) -> String {
let mut out = String::new();
for span in spans {
let escaped = escape_markdown(&span.text);
out.push_str(&apply_marks(&escaped, &span.marks));
}
out
}
fn apply_marks(text: &str, marks: &[Mark]) -> String {
let has = |t: &str| marks.iter().any(|m| m.ty == t);
let mut out = text.to_string();
let mut inline_styles = Vec::new();
if let Some(color) = style_value(marks, "color", "color") {
inline_styles.push(format!("color:{color}"));
}
if let Some(bg) = style_value(marks, "highlight", "backgroundColor") {
inline_styles.push(format!("background-color:{bg}"));
}
if !inline_styles.is_empty() {
out = format!(
"<span style=\"{}\">{out}</span>",
escape_html_attr(&inline_styles.join(";"))
);
}
if has("code") {
out = format!("`{out}`");
}
if has("strike") {
out = format!("~~{out}~~");
}
if has("bold") && has("italic") {
out = format!("***{out}***");
} else if has("bold") {
out = format!("**{out}**");
} else if has("italic") {
out = format!("*{out}*");
}
out
}
fn style_value<'a>(marks: &'a [Mark], mark_type: &str, key: &str) -> Option<&'a str> {
marks.iter().find(|m| m.ty == mark_type).and_then(|mark| {
mark.style()
.and_then(|style| style.get(key))
.and_then(Value::as_str)
})
}
fn escape_html_attr(text: &str) -> String {
text.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn escape_markdown(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
| '!' | '|' | '>' | '<' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use devup_editor_core::{Block, BlockId, SequentialIdGenerator};
use serde_json::Map;
fn para(text: &str) -> Block {
let mut b = Block::new_paragraph(BlockId::new("p"));
b.content = vec![TextSpan::plain(text)];
b
}
fn doc_with(blocks: Vec<Block>) -> Document {
let mut doc = Document::new();
for b in blocks {
doc.push_root_block(b);
}
doc
}
#[test]
fn export_heading() {
let mut b = Block::new(BlockId::new("h1"), "heading");
b.content = vec![TextSpan::plain("Title")];
b.props.insert("level".into(), Value::from(1u64));
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(out.contains("# Title"));
}
#[test]
fn export_code_block_with_language() {
let mut b = Block::new(BlockId::new("c"), "code");
b.content = vec![TextSpan::plain("fn main() {}")];
b.props
.insert("language".into(), Value::String("rust".into()));
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(out.contains("```rust"));
assert!(out.contains("fn main() {}"));
assert!(out.contains("```\n"));
}
#[test]
fn roundtrip_simple_document() {
let mut id_gen = SequentialIdGenerator::new("t");
let source = "# Hello\n\nThis is **bold** and *italic*.\n\n- item 1\n- item 2\n";
let doc = Markdown::import(source.to_string(), &mut id_gen).unwrap();
let exported = Markdown::export(&doc).unwrap();
let mut id_gen2 = SequentialIdGenerator::new("t2");
let doc2 = Markdown::import(exported, &mut id_gen2).unwrap();
assert_eq!(doc.root_block_count(), doc2.root_block_count());
let types1: Vec<String> = doc
.root_block_ids()
.iter()
.filter_map(|id| doc.get_block(id))
.map(|b| b.ty.clone())
.collect();
let types2: Vec<String> = doc2
.root_block_ids()
.iter()
.filter_map(|id| doc2.get_block(id))
.map(|b| b.ty.clone())
.collect();
assert_eq!(types1, types2);
}
#[test]
fn roundtrip_preserves_code_block() {
let mut id_gen = SequentialIdGenerator::new("t");
let source = "```python\nprint('hi')\n```\n";
let doc = Markdown::import(source.to_string(), &mut id_gen).unwrap();
let exported = Markdown::export(&doc).unwrap();
assert!(exported.contains("```python"));
let mut id_gen2 = SequentialIdGenerator::new("t2");
let doc2 = Markdown::import(exported, &mut id_gen2).unwrap();
let block = doc2
.get_block(&BlockId::new("t2-1"))
.expect("code block survived round trip");
assert_eq!(block.ty, "code");
assert_eq!(
block.props.get("language").and_then(|v| v.as_str()),
Some("python")
);
}
#[test]
fn export_empty_document_is_empty_string() {
let doc = Document::new();
let out = Markdown::export(&doc).unwrap();
assert!(out.is_empty());
}
#[test]
fn export_paragraph_uses_indent_prefix() {
let mut b = para("indented");
b.props.insert("indent".into(), Value::from(2u64));
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(out.starts_with(" indented"), "got: {out:?}");
}
#[test]
fn export_quote_uses_indent_prefix() {
let mut b = Block::new(BlockId::new("q"), "quote");
b.content = vec![TextSpan::plain("quoted")];
b.props.insert("indent".into(), Value::from(1u64));
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(out.starts_with(" > quoted"), "got: {out:?}");
}
#[test]
fn export_code_uses_indent_prefix() {
let mut b = Block::new(BlockId::new("c"), "code");
b.content = vec![TextSpan::plain("line")];
b.props.insert("indent".into(), Value::from(1u64));
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(out.starts_with(" ```"), "got: {out:?}");
assert!(out.contains(" line\n"), "got: {out:?}");
}
#[test]
fn export_toggle_uses_indent_prefix() {
let mut b = Block::new(BlockId::new("t"), "toggle");
b.content = vec![TextSpan::plain("toggle")];
b.props.insert("indent".into(), Value::from(1u64));
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(out.starts_with(" **toggle**"), "got: {out:?}");
}
#[test]
fn unused_map_still_imports() {
let _ = Map::<String, Value>::new();
}
#[test]
fn export_color_and_highlight_as_inline_html_styles() {
let mut color_style = Map::new();
color_style.insert("color".into(), Value::String("#ff0000".into()));
let mut color_attrs = Map::new();
color_attrs.insert("style".into(), Value::Object(color_style));
let mut highlight_style = Map::new();
highlight_style.insert("backgroundColor".into(), Value::String("#fff000".into()));
let mut highlight_attrs = Map::new();
highlight_attrs.insert("style".into(), Value::Object(highlight_style));
let mut b = para("color");
b.content = vec![TextSpan::with_marks(
"color",
vec![
Mark::with_attrs("color", color_attrs),
Mark::with_attrs("highlight", highlight_attrs),
],
)];
let out = Markdown::export(&doc_with(vec![b])).unwrap();
assert!(
out.contains("<span style=\"color:#ff0000;background-color:#fff000\">color</span>")
);
}
}