1use crate::types::{Block, CalloutType, DecisionStatus, SurfDoc, Trend};
7
8pub fn to_markdown(doc: &SurfDoc) -> String {
13 let mut parts: Vec<String> = Vec::new();
14
15 for block in &doc.blocks {
16 parts.push(render_block(block));
17 }
18
19 parts.join("\n\n")
20}
21
22fn render_block(block: &Block) -> String {
23 match block {
24 Block::Markdown { content, .. } => content.clone(),
25
26 Block::Callout {
27 callout_type,
28 title,
29 content,
30 ..
31 } => {
32 let type_label = callout_type_label(*callout_type);
33 let prefix = match title {
34 Some(t) => format!("**{type_label}**: {t}"),
35 None => format!("**{type_label}**"),
36 };
37 let mut lines = vec![format!("> {prefix}")];
38 for line in content.lines() {
39 lines.push(format!("> {line}"));
40 }
41 lines.join("\n")
42 }
43
44 Block::Data {
45 headers, rows, ..
46 } => {
47 if headers.is_empty() {
48 return String::new();
49 }
50 let mut lines = Vec::new();
51 lines.push(format!("| {} |", headers.join(" | ")));
53 let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
55 lines.push(format!("| {} |", sep.join(" | ")));
56 for row in rows {
58 lines.push(format!("| {} |", row.join(" | ")));
59 }
60 lines.join("\n")
61 }
62
63 Block::Code {
64 lang, content, ..
65 } => {
66 let lang_tag = lang.as_deref().unwrap_or("");
67 format!("```{lang_tag}\n{content}\n```")
68 }
69
70 Block::Tasks { items, .. } => {
71 let lines: Vec<String> = items
72 .iter()
73 .map(|item| {
74 let check = if item.done { "x" } else { " " };
75 match &item.assignee {
76 Some(a) => format!("- [{check}] {} @{a}", item.text),
77 None => format!("- [{check}] {}", item.text),
78 }
79 })
80 .collect();
81 lines.join("\n")
82 }
83
84 Block::Decision {
85 status,
86 date,
87 content,
88 ..
89 } => {
90 let status_label = decision_status_label(*status);
91 let date_part = match date {
92 Some(d) => format!(" ({d})"),
93 None => String::new(),
94 };
95 let mut lines = vec![format!("> **Decision** ({status_label}){date_part}")];
96 for line in content.lines() {
97 lines.push(format!("> {line}"));
98 }
99 lines.join("\n")
100 }
101
102 Block::Metric {
103 label,
104 value,
105 trend,
106 unit,
107 ..
108 } => {
109 let trend_arrow = match trend {
110 Some(Trend::Up) => " \u{2191}",
111 Some(Trend::Down) => " \u{2193}",
112 Some(Trend::Flat) => " \u{2192}",
113 None => "",
114 };
115 let unit_part = match unit {
116 Some(u) => format!(" {u}"),
117 None => String::new(),
118 };
119 format!("**{label}**: {value}{unit_part}{trend_arrow}")
120 }
121
122 Block::Summary { content, .. } => {
123 let lines: Vec<String> = content.lines().map(|l| format!("> *{l}*")).collect();
124 lines.join("\n")
125 }
126
127 Block::Figure {
128 src,
129 caption,
130 alt,
131 ..
132 } => {
133 let alt_text = alt.as_deref().unwrap_or("");
134 let img = format!("");
135 match caption {
136 Some(c) => format!("{img}\n*{c}*"),
137 None => img,
138 }
139 }
140
141 Block::Tabs { tabs, .. } => {
142 let parts: Vec<String> = tabs
143 .iter()
144 .map(|tab| format!("### {}\n\n{}", tab.label, tab.content))
145 .collect();
146 parts.join("\n\n")
147 }
148
149 Block::Columns { columns, .. } => {
150 let parts: Vec<String> = columns
151 .iter()
152 .map(|col| col.content.clone())
153 .collect();
154 parts.join("\n\n---\n\n")
155 }
156
157 Block::Quote {
158 content,
159 attribution,
160 ..
161 } => {
162 let mut lines: Vec<String> = content.lines().map(|l| format!("> {l}")).collect();
163 if let Some(attr) = attribution {
164 lines.push(format!(">\n> \u{2014} {attr}"));
165 }
166 lines.join("\n")
167 }
168
169 Block::Cta {
170 label, href, ..
171 } => {
172 format!("[{label}]({href})")
174 }
175
176 Block::HeroImage {
177 src, alt, ..
178 } => {
179 let alt_text = alt.as_deref().unwrap_or("Hero image");
180 format!("")
181 }
182
183 Block::Testimonial {
184 content,
185 author,
186 role,
187 company,
188 ..
189 } => {
190 let mut lines: Vec<String> = content.lines().map(|l| format!("> {l}")).collect();
191 let details: Vec<&str> = [author.as_deref(), role.as_deref(), company.as_deref()]
192 .iter()
193 .filter_map(|v| *v)
194 .collect();
195 if !details.is_empty() {
196 lines.push(format!(">\n> \u{2014} {}", details.join(", ")));
197 }
198 lines.join("\n")
199 }
200
201 Block::Style { .. } => {
202 String::new()
204 }
205
206 Block::Faq { items, .. } => {
207 let parts: Vec<String> = items
209 .iter()
210 .map(|item| format!("### {}\n\n{}", item.question, item.answer))
211 .collect();
212 parts.join("\n\n")
213 }
214
215 Block::PricingTable {
216 headers, rows, ..
217 } => {
218 if headers.is_empty() {
220 return String::new();
221 }
222 let mut lines = Vec::new();
223 lines.push(format!("| {} |", headers.join(" | ")));
224 let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
225 lines.push(format!("| {} |", sep.join(" | ")));
226 for row in rows {
227 lines.push(format!("| {} |", row.join(" | ")));
228 }
229 lines.join("\n")
230 }
231
232 Block::Site { domain, properties, .. } => {
233 let mut lines = vec!["**Site Configuration**".to_string()];
235 if let Some(d) = domain {
236 lines.push(format!("- domain: {d}"));
237 }
238 for p in properties {
239 lines.push(format!("- {}: {}", p.key, p.value));
240 }
241 lines.join("\n")
242 }
243
244 Block::Page {
245 title,
246 content,
247 ..
248 } => {
249 if let Some(t) = title {
251 format!("## {t}\n\n{content}")
252 } else {
253 content.clone()
254 }
255 }
256
257 Block::Unknown {
258 name,
259 content,
260 ..
261 } => {
262 let mut lines = Vec::new();
263 lines.push(format!("<!-- ::{name} -->"));
264 if !content.is_empty() {
265 lines.push(content.clone());
266 }
267 lines.push("<!-- :: -->".to_string());
268 lines.join("\n")
269 }
270 }
271}
272
273fn callout_type_label(ct: CalloutType) -> &'static str {
274 match ct {
275 CalloutType::Info => "Info",
276 CalloutType::Warning => "Warning",
277 CalloutType::Danger => "Danger",
278 CalloutType::Tip => "Tip",
279 CalloutType::Note => "Note",
280 CalloutType::Success => "Success",
281 }
282}
283
284fn decision_status_label(ds: DecisionStatus) -> &'static str {
285 match ds {
286 DecisionStatus::Proposed => "proposed",
287 DecisionStatus::Accepted => "accepted",
288 DecisionStatus::Rejected => "rejected",
289 DecisionStatus::Superseded => "superseded",
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::types::*;
297
298 fn span() -> Span {
299 Span {
300 start_line: 1,
301 end_line: 1,
302 start_offset: 0,
303 end_offset: 0,
304 }
305 }
306
307 fn doc_with(blocks: Vec<Block>) -> SurfDoc {
308 SurfDoc {
309 front_matter: None,
310 blocks,
311 source: String::new(),
312 }
313 }
314
315 #[test]
316 fn md_callout_warning() {
317 let doc = doc_with(vec![Block::Callout {
318 callout_type: CalloutType::Warning,
319 title: Some("Watch out".into()),
320 content: "Sharp edges ahead.".into(),
321 span: span(),
322 }]);
323 let md = to_markdown(&doc);
324 assert!(md.contains("> **Warning**: Watch out"));
325 assert!(md.contains("> Sharp edges ahead."));
326 }
327
328 #[test]
329 fn md_data_table() {
330 let doc = doc_with(vec![Block::Data {
331 id: None,
332 format: DataFormat::Table,
333 sortable: false,
334 headers: vec!["Name".into(), "Age".into()],
335 rows: vec![vec!["Alice".into(), "30".into()]],
336 raw_content: String::new(),
337 span: span(),
338 }]);
339 let md = to_markdown(&doc);
340 assert!(md.contains("| Name | Age |"));
341 assert!(md.contains("| --- | --- |"));
342 assert!(md.contains("| Alice | 30 |"));
343 }
344
345 #[test]
346 fn md_code_block() {
347 let doc = doc_with(vec![Block::Code {
348 lang: Some("rust".into()),
349 file: None,
350 highlight: vec![],
351 content: "fn main() {}".into(),
352 span: span(),
353 }]);
354 let md = to_markdown(&doc);
355 assert!(md.contains("```rust"));
356 assert!(md.contains("fn main() {}"));
357 assert!(md.contains("```"));
358 }
359
360 #[test]
361 fn md_tasks() {
362 let doc = doc_with(vec![Block::Tasks {
363 items: vec![
364 TaskItem {
365 done: false,
366 text: "Write tests".into(),
367 assignee: None,
368 },
369 TaskItem {
370 done: true,
371 text: "Write parser".into(),
372 assignee: Some("brady".into()),
373 },
374 ],
375 span: span(),
376 }]);
377 let md = to_markdown(&doc);
378 assert!(md.contains("- [ ] Write tests"));
379 assert!(md.contains("- [x] Write parser @brady"));
380 }
381
382 #[test]
383 fn md_decision() {
384 let doc = doc_with(vec![Block::Decision {
385 status: DecisionStatus::Accepted,
386 date: Some("2026-02-10".into()),
387 deciders: vec![],
388 content: "We chose Rust.".into(),
389 span: span(),
390 }]);
391 let md = to_markdown(&doc);
392 assert!(md.contains("> **Decision** (accepted) (2026-02-10)"));
393 assert!(md.contains("> We chose Rust."));
394 }
395
396 #[test]
397 fn md_metric() {
398 let doc = doc_with(vec![Block::Metric {
399 label: "MRR".into(),
400 value: "$2K".into(),
401 trend: Some(Trend::Up),
402 unit: Some("USD".into()),
403 span: span(),
404 }]);
405 let md = to_markdown(&doc);
406 assert!(md.contains("**MRR**: $2K USD"));
407 assert!(md.contains("\u{2191}")); }
409
410 #[test]
411 fn md_summary() {
412 let doc = doc_with(vec![Block::Summary {
413 content: "Executive overview.".into(),
414 span: span(),
415 }]);
416 let md = to_markdown(&doc);
417 assert!(md.contains("> *Executive overview.*"));
418 }
419
420 #[test]
421 fn md_figure() {
422 let doc = doc_with(vec![Block::Figure {
423 src: "diagram.png".into(),
424 caption: Some("Architecture".into()),
425 alt: Some("Diagram".into()),
426 width: None,
427 span: span(),
428 }]);
429 let md = to_markdown(&doc);
430 assert!(md.contains(""));
431 assert!(md.contains("*Architecture*"));
432 }
433
434 #[test]
437 fn md_cta() {
438 let doc = doc_with(vec![Block::Cta {
439 label: "Sign Up".into(),
440 href: "/signup".into(),
441 primary: true,
442 span: span(),
443 }]);
444 let md = to_markdown(&doc);
445 assert_eq!(md, "[Sign Up](/signup)");
446 }
447
448 #[test]
449 fn md_hero_image() {
450 let doc = doc_with(vec![Block::HeroImage {
451 src: "hero.png".into(),
452 alt: Some("Product shot".into()),
453 span: span(),
454 }]);
455 let md = to_markdown(&doc);
456 assert_eq!(md, "");
457 }
458
459 #[test]
460 fn md_testimonial() {
461 let doc = doc_with(vec![Block::Testimonial {
462 content: "Great product!".into(),
463 author: Some("Jane".into()),
464 role: Some("Engineer".into()),
465 company: None,
466 span: span(),
467 }]);
468 let md = to_markdown(&doc);
469 assert!(md.contains("> Great product!"));
470 assert!(md.contains("\u{2014} Jane, Engineer"));
471 }
472
473 #[test]
474 fn md_style_invisible() {
475 let doc = doc_with(vec![Block::Style {
476 properties: vec![crate::types::StyleProperty {
477 key: "accent".into(),
478 value: "blue".into(),
479 }],
480 span: span(),
481 }]);
482 let md = to_markdown(&doc);
483 assert!(md.is_empty());
484 }
485
486 #[test]
487 fn md_faq() {
488 let doc = doc_with(vec![Block::Faq {
489 items: vec![
490 crate::types::FaqItem {
491 question: "Is it free?".into(),
492 answer: "Yes.".into(),
493 },
494 crate::types::FaqItem {
495 question: "Can I export?".into(),
496 answer: "PDF and HTML.".into(),
497 },
498 ],
499 span: span(),
500 }]);
501 let md = to_markdown(&doc);
502 assert!(md.contains("### Is it free?"));
503 assert!(md.contains("Yes."));
504 assert!(md.contains("### Can I export?"));
505 assert!(md.contains("PDF and HTML."));
506 }
507
508 #[test]
509 fn md_pricing_table() {
510 let doc = doc_with(vec![Block::PricingTable {
511 headers: vec!["".into(), "Free".into(), "Pro".into()],
512 rows: vec![vec!["Price".into(), "$0".into(), "$9/mo".into()]],
513 span: span(),
514 }]);
515 let md = to_markdown(&doc);
516 assert!(md.contains("Free | Pro"));
517 assert!(md.contains("| --- | --- | --- |"));
518 assert!(md.contains("| Price | $0 | $9/mo |"));
519 }
520
521 #[test]
522 fn md_site() {
523 let doc = doc_with(vec![Block::Site {
524 domain: Some("example.com".into()),
525 properties: vec![
526 crate::types::StyleProperty { key: "name".into(), value: "Test".into() },
527 ],
528 span: span(),
529 }]);
530 let md = to_markdown(&doc);
531 assert!(md.contains("**Site Configuration**"));
532 assert!(md.contains("domain: example.com"));
533 assert!(md.contains("name: Test"));
534 }
535
536 #[test]
537 fn md_page_with_title() {
538 let doc = doc_with(vec![Block::Page {
539 route: "/".into(),
540 layout: None,
541 title: Some("Home".into()),
542 sidebar: false,
543 content: "Welcome to our site.".into(),
544 children: vec![],
545 span: span(),
546 }]);
547 let md = to_markdown(&doc);
548 assert!(md.contains("## Home"));
549 assert!(md.contains("Welcome to our site."));
550 }
551
552 #[test]
553 fn md_page_no_title() {
554 let doc = doc_with(vec![Block::Page {
555 route: "/about".into(),
556 layout: None,
557 title: None,
558 sidebar: false,
559 content: "# About Us\n\nWe build things.".into(),
560 children: vec![],
561 span: span(),
562 }]);
563 let md = to_markdown(&doc);
564 assert!(md.contains("# About Us"));
565 assert!(md.contains("We build things."));
566 }
567
568 #[test]
569 fn md_no_surfdoc_markers() {
570 let doc = doc_with(vec![
571 Block::Callout {
572 callout_type: CalloutType::Info,
573 title: None,
574 content: "Hello".into(),
575 span: span(),
576 },
577 Block::Code {
578 lang: Some("rust".into()),
579 file: None,
580 highlight: vec![],
581 content: "let x = 1;".into(),
582 span: span(),
583 },
584 Block::Metric {
585 label: "A".into(),
586 value: "1".into(),
587 trend: None,
588 unit: None,
589 span: span(),
590 },
591 ]);
592 let md = to_markdown(&doc);
593 assert!(
595 !md.contains("::callout"),
596 "Output should not contain ::callout markers"
597 );
598 assert!(
599 !md.contains("::code"),
600 "Output should not contain ::code markers"
601 );
602 assert!(
603 !md.contains("::metric"),
604 "Output should not contain ::metric markers"
605 );
606 }
607}