devup-editor-markdown 1.0.16

Markdown ↔ Document conversion (import + export) for devup-editor
Documentation
use devup_editor_core::{
    Block, Document, DocumentExport, DocumentImport, IdGenerator, Mark, TextSpan,
};
use serde_json::Value;

use crate::MarkdownError;
use crate::import::parse_markdown;

/// Marker type carrying the [`DocumentExport`] / [`DocumentImport`] impls
/// for 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") {
                // Markdown only has 1. — renderers re-number on import.
                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" => {
            // Markdown has no native toggle. Render as bold paragraph.
            out.push_str(&indent_str);
            out.push_str("**");
            out.push_str(&inline);
            out.push_str("**\n\n");
        }
        _ => {
            // paragraph and unknown
            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('&', "&amp;")
        .replace('"', "&quot;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

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();

        // Re-import the exported output — should get the same structure.
        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());

        // Check block types match
        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() {
        // Silence unused import warning
        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>")
        );
    }
}