Skip to main content

surf_parse/
render_md.rs

1//! Markdown degradation renderer.
2//!
3//! Converts a `SurfDoc` into standard CommonMark with no `::` directive markers.
4//! Each block type is degraded to the nearest Markdown equivalent.
5
6use crate::types::{Block, CalloutType, DecisionStatus, SurfDoc, Trend};
7
8/// Render a `SurfDoc` as standard CommonMark markdown.
9///
10/// The output contains no `::` directive markers. Each SurfDoc block type is
11/// degraded to its closest CommonMark equivalent.
12pub 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            // Header row
52            lines.push(format!("| {} |", headers.join(" | ")));
53            // Separator
54            let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
55            lines.push(format!("| {} |", sep.join(" | ")));
56            // Data rows
57            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!("![{alt_text}]({src})");
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            // Degrades to a markdown link
173            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!("![{alt_text}]({src})")
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            // Style blocks are invisible in markdown degradation
203            String::new()
204        }
205
206        Block::Faq { items, .. } => {
207            // Degrades to headings + paragraphs
208            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            // Degrades to a standard markdown table (same as Data)
219            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            // Degrades to a YAML-like config block
234            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            // Degrades to a heading + raw content
250            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}")); // up arrow
408    }
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("![Diagram](diagram.png)"));
431        assert!(md.contains("*Architecture*"));
432    }
433
434    // -- Web blocks ------------------------------------------------
435
436    #[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, "![Product shot](hero.png)");
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        // Ensure no :: markers exist (they belong to SurfDoc directives, not Markdown)
594        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}