Skip to main content

devup_editor_markdown/
export.rs

1use devup_editor_core::{
2    Block, Document, DocumentExport, DocumentImport, IdGenerator, Mark, TextSpan,
3};
4use serde_json::Value;
5
6use crate::MarkdownError;
7use crate::import::parse_markdown;
8
9/// Marker type carrying the [`DocumentExport`] / [`DocumentImport`] impls
10/// for Markdown.
11pub struct Markdown;
12
13impl DocumentExport for Markdown {
14    type Output = String;
15    type Error = MarkdownError;
16
17    fn export(doc: &Document) -> Result<String, MarkdownError> {
18        let mut out = String::new();
19        for id in doc.root_block_ids() {
20            let Some(block) = doc.get_block(id) else {
21                continue;
22            };
23            write_block(&mut out, block);
24        }
25        Ok(out)
26    }
27}
28
29impl DocumentImport for Markdown {
30    type Input = String;
31    type Error = MarkdownError;
32
33    fn import(input: String, id_gen: &mut dyn IdGenerator) -> Result<Document, MarkdownError> {
34        Ok(parse_markdown(&input, id_gen))
35    }
36}
37
38fn write_block(out: &mut String, block: &Block) {
39    let indent_str = "  ".repeat(usize::try_from(block.indent_level()).unwrap_or(0));
40    let inline = render_inline(&block.content);
41    let plain = block.plain_text();
42
43    match block.ty.as_str() {
44        "heading" => {
45            let level = block
46                .props
47                .get("level")
48                .and_then(Value::as_u64)
49                .unwrap_or(1)
50                .clamp(1, 6) as usize;
51            out.push_str(&indent_str);
52            out.push_str(&"#".repeat(level));
53            out.push(' ');
54            out.push_str(&inline);
55            out.push_str("\n\n");
56        }
57        "todo" => {
58            let checked = block
59                .props
60                .get("checked")
61                .and_then(Value::as_bool)
62                .unwrap_or(false);
63            out.push_str(&indent_str);
64            out.push_str(if checked { "- [x] " } else { "- [ ] " });
65            out.push_str(&inline);
66            out.push('\n');
67        }
68        "list" => {
69            out.push_str(&indent_str);
70            let style = block
71                .props
72                .get("style")
73                .and_then(Value::as_str)
74                .unwrap_or("unordered");
75            if style.starts_with("ordered") {
76                // Markdown only has 1. — renderers re-number on import.
77                out.push_str("1. ");
78            } else {
79                out.push_str("- ");
80            }
81            out.push_str(&inline);
82            out.push('\n');
83        }
84        "quote" => {
85            for line in plain.split('\n') {
86                out.push_str(&indent_str);
87                out.push_str("> ");
88                out.push_str(&escape_markdown(line));
89                out.push('\n');
90            }
91            out.push('\n');
92        }
93        "code" => {
94            let lang = block
95                .props
96                .get("language")
97                .and_then(Value::as_str)
98                .unwrap_or("");
99            out.push_str(&indent_str);
100            out.push_str("```");
101            out.push_str(lang);
102            out.push('\n');
103            for line in plain.split('\n') {
104                out.push_str(&indent_str);
105                out.push_str(line);
106                out.push('\n');
107            }
108            out.push_str(&indent_str);
109            out.push_str("```\n\n");
110        }
111        "toggle" => {
112            // Markdown has no native toggle. Render as bold paragraph.
113            out.push_str(&indent_str);
114            out.push_str("**");
115            out.push_str(&inline);
116            out.push_str("**\n\n");
117        }
118        _ => {
119            // paragraph and unknown
120            out.push_str(&indent_str);
121            out.push_str(&inline);
122            out.push_str("\n\n");
123        }
124    }
125}
126
127fn render_inline(spans: &[TextSpan]) -> String {
128    let mut out = String::new();
129    for span in spans {
130        let escaped = escape_markdown(&span.text);
131        out.push_str(&apply_marks(&escaped, &span.marks));
132    }
133    out
134}
135
136fn apply_marks(text: &str, marks: &[Mark]) -> String {
137    let has = |t: &str| marks.iter().any(|m| m.ty == t);
138    let mut out = text.to_string();
139    let mut inline_styles = Vec::new();
140    if let Some(color) = style_value(marks, "color", "color") {
141        inline_styles.push(format!("color:{color}"));
142    }
143    if let Some(bg) = style_value(marks, "highlight", "backgroundColor") {
144        inline_styles.push(format!("background-color:{bg}"));
145    }
146    if !inline_styles.is_empty() {
147        out = format!(
148            "<span style=\"{}\">{out}</span>",
149            escape_html_attr(&inline_styles.join(";"))
150        );
151    }
152    if has("code") {
153        out = format!("`{out}`");
154    }
155    if has("strike") {
156        out = format!("~~{out}~~");
157    }
158    if has("bold") && has("italic") {
159        out = format!("***{out}***");
160    } else if has("bold") {
161        out = format!("**{out}**");
162    } else if has("italic") {
163        out = format!("*{out}*");
164    }
165    out
166}
167
168fn style_value<'a>(marks: &'a [Mark], mark_type: &str, key: &str) -> Option<&'a str> {
169    marks.iter().find(|m| m.ty == mark_type).and_then(|mark| {
170        mark.style()
171            .and_then(|style| style.get(key))
172            .and_then(Value::as_str)
173    })
174}
175
176fn escape_html_attr(text: &str) -> String {
177    text.replace('&', "&amp;")
178        .replace('"', "&quot;")
179        .replace('<', "&lt;")
180        .replace('>', "&gt;")
181}
182
183fn escape_markdown(text: &str) -> String {
184    let mut out = String::with_capacity(text.len());
185    for c in text.chars() {
186        match c {
187            '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
188            | '!' | '|' | '>' | '<' => {
189                out.push('\\');
190                out.push(c);
191            }
192            _ => out.push(c),
193        }
194    }
195    out
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use devup_editor_core::{Block, BlockId, SequentialIdGenerator};
202    use serde_json::Map;
203
204    fn para(text: &str) -> Block {
205        let mut b = Block::new_paragraph(BlockId::new("p"));
206        b.content = vec![TextSpan::plain(text)];
207        b
208    }
209
210    fn doc_with(blocks: Vec<Block>) -> Document {
211        let mut doc = Document::new();
212        for b in blocks {
213            doc.push_root_block(b);
214        }
215        doc
216    }
217
218    #[test]
219    fn export_heading() {
220        let mut b = Block::new(BlockId::new("h1"), "heading");
221        b.content = vec![TextSpan::plain("Title")];
222        b.props.insert("level".into(), Value::from(1u64));
223        let out = Markdown::export(&doc_with(vec![b])).unwrap();
224        assert!(out.contains("# Title"));
225    }
226
227    #[test]
228    fn export_code_block_with_language() {
229        let mut b = Block::new(BlockId::new("c"), "code");
230        b.content = vec![TextSpan::plain("fn main() {}")];
231        b.props
232            .insert("language".into(), Value::String("rust".into()));
233        let out = Markdown::export(&doc_with(vec![b])).unwrap();
234        assert!(out.contains("```rust"));
235        assert!(out.contains("fn main() {}"));
236        assert!(out.contains("```\n"));
237    }
238
239    #[test]
240    fn roundtrip_simple_document() {
241        let mut id_gen = SequentialIdGenerator::new("t");
242        let source = "# Hello\n\nThis is **bold** and *italic*.\n\n- item 1\n- item 2\n";
243        let doc = Markdown::import(source.to_string(), &mut id_gen).unwrap();
244        let exported = Markdown::export(&doc).unwrap();
245
246        // Re-import the exported output — should get the same structure.
247        let mut id_gen2 = SequentialIdGenerator::new("t2");
248        let doc2 = Markdown::import(exported, &mut id_gen2).unwrap();
249
250        assert_eq!(doc.root_block_count(), doc2.root_block_count());
251
252        // Check block types match
253        let types1: Vec<String> = doc
254            .root_block_ids()
255            .iter()
256            .filter_map(|id| doc.get_block(id))
257            .map(|b| b.ty.clone())
258            .collect();
259        let types2: Vec<String> = doc2
260            .root_block_ids()
261            .iter()
262            .filter_map(|id| doc2.get_block(id))
263            .map(|b| b.ty.clone())
264            .collect();
265        assert_eq!(types1, types2);
266    }
267
268    #[test]
269    fn roundtrip_preserves_code_block() {
270        let mut id_gen = SequentialIdGenerator::new("t");
271        let source = "```python\nprint('hi')\n```\n";
272        let doc = Markdown::import(source.to_string(), &mut id_gen).unwrap();
273        let exported = Markdown::export(&doc).unwrap();
274        assert!(exported.contains("```python"));
275
276        let mut id_gen2 = SequentialIdGenerator::new("t2");
277        let doc2 = Markdown::import(exported, &mut id_gen2).unwrap();
278        let block = doc2
279            .get_block(&BlockId::new("t2-1"))
280            .expect("code block survived round trip");
281        assert_eq!(block.ty, "code");
282        assert_eq!(
283            block.props.get("language").and_then(|v| v.as_str()),
284            Some("python")
285        );
286    }
287
288    #[test]
289    fn export_empty_document_is_empty_string() {
290        let doc = Document::new();
291        let out = Markdown::export(&doc).unwrap();
292        assert!(out.is_empty());
293    }
294
295    #[test]
296    fn export_paragraph_uses_indent_prefix() {
297        let mut b = para("indented");
298        b.props.insert("indent".into(), Value::from(2u64));
299        let out = Markdown::export(&doc_with(vec![b])).unwrap();
300        assert!(out.starts_with("    indented"), "got: {out:?}");
301    }
302
303    #[test]
304    fn export_quote_uses_indent_prefix() {
305        let mut b = Block::new(BlockId::new("q"), "quote");
306        b.content = vec![TextSpan::plain("quoted")];
307        b.props.insert("indent".into(), Value::from(1u64));
308        let out = Markdown::export(&doc_with(vec![b])).unwrap();
309        assert!(out.starts_with("  > quoted"), "got: {out:?}");
310    }
311
312    #[test]
313    fn export_code_uses_indent_prefix() {
314        let mut b = Block::new(BlockId::new("c"), "code");
315        b.content = vec![TextSpan::plain("line")];
316        b.props.insert("indent".into(), Value::from(1u64));
317        let out = Markdown::export(&doc_with(vec![b])).unwrap();
318        assert!(out.starts_with("  ```"), "got: {out:?}");
319        assert!(out.contains("  line\n"), "got: {out:?}");
320    }
321
322    #[test]
323    fn export_toggle_uses_indent_prefix() {
324        let mut b = Block::new(BlockId::new("t"), "toggle");
325        b.content = vec![TextSpan::plain("toggle")];
326        b.props.insert("indent".into(), Value::from(1u64));
327        let out = Markdown::export(&doc_with(vec![b])).unwrap();
328        assert!(out.starts_with("  **toggle**"), "got: {out:?}");
329    }
330
331    #[test]
332    fn unused_map_still_imports() {
333        // Silence unused import warning
334        let _ = Map::<String, Value>::new();
335    }
336
337    #[test]
338    fn export_color_and_highlight_as_inline_html_styles() {
339        let mut color_style = Map::new();
340        color_style.insert("color".into(), Value::String("#ff0000".into()));
341        let mut color_attrs = Map::new();
342        color_attrs.insert("style".into(), Value::Object(color_style));
343
344        let mut highlight_style = Map::new();
345        highlight_style.insert("backgroundColor".into(), Value::String("#fff000".into()));
346        let mut highlight_attrs = Map::new();
347        highlight_attrs.insert("style".into(), Value::Object(highlight_style));
348
349        let mut b = para("color");
350        b.content = vec![TextSpan::with_marks(
351            "color",
352            vec![
353                Mark::with_attrs("color", color_attrs),
354                Mark::with_attrs("highlight", highlight_attrs),
355            ],
356        )];
357
358        let out = Markdown::export(&doc_with(vec![b])).unwrap();
359        assert!(
360            out.contains("<span style=\"color:#ff0000;background-color:#fff000\">color</span>")
361        );
362    }
363}