devup_editor_markdown/
export.rs1use 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
9pub 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 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 out.push_str(&indent_str);
114 out.push_str("**");
115 out.push_str(&inline);
116 out.push_str("**\n\n");
117 }
118 _ => {
119 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('&', "&")
178 .replace('"', """)
179 .replace('<', "<")
180 .replace('>', ">")
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 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 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 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}