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::citation;
7use crate::types::{Block, CalloutType, ChartType, DecisionStatus, Format, HttpMethod, ListDisplay, SurfDoc, Trend};
8
9/// Render a `SurfDoc` as standard CommonMark markdown.
10///
11/// The output contains no `::` directive markers. Each SurfDoc block type is
12/// degraded to its closest CommonMark equivalent.
13pub fn to_markdown(doc: &SurfDoc) -> String {
14    let _cite_scope = citation::install_context(citation::build_context(
15        &doc.blocks,
16        doc.front_matter.as_ref().and_then(|fm| fm.format),
17    ));
18    let mut parts: Vec<String> = Vec::new();
19
20    for block in &doc.blocks {
21        parts.push(render_block(block));
22    }
23
24    parts.join("\n\n")
25}
26
27/// Render a `::bibliography` as a markdown reference list (heading + entries).
28fn render_bibliography_md(style_override: Option<Format>) -> String {
29    citation::with_active(|ctx| {
30        let Some(ctx) = ctx else {
31            return String::new();
32        };
33        if ctx.references.is_empty() {
34            return String::new();
35        }
36        let style = style_override.unwrap_or(ctx.style);
37        let refs = if style_override.is_some() {
38            ctx.references.clone()
39        } else {
40            citation::ordered_references(ctx)
41        };
42        let mut lines = vec![format!("## {}", citation::bibliography_heading(style))];
43        if citation::is_numbered(style) {
44            for line in citation::reference_list(&refs, style) {
45                lines.push(line);
46            }
47        } else {
48            for line in citation::reference_list(&refs, style) {
49                lines.push(format!("- {line}"));
50            }
51        }
52        lines.join("\n")
53    })
54}
55
56pub(crate) fn render_block(block: &Block) -> String {
57    match block {
58        Block::Markdown { content, .. } => citation::substitute_text_cites(content),
59
60        // `::cite` is a definition only — emits nothing in markdown output.
61        Block::Cite { .. } => String::new(),
62
63        Block::Bibliography { style, .. } => render_bibliography_md(*style),
64
65        Block::Callout {
66            callout_type,
67            title,
68            content,
69            ..
70        } => {
71            let type_label = callout_type_label(*callout_type);
72            let prefix = match title {
73                Some(t) => format!("**{type_label}**: {t}"),
74                None => format!("**{type_label}**"),
75            };
76            let mut lines = vec![format!("> {prefix}")];
77            for line in content.lines() {
78                lines.push(format!("> {line}"));
79            }
80            lines.join("\n")
81        }
82
83        Block::Data {
84            headers, rows, ..
85        } => {
86            if headers.is_empty() {
87                return String::new();
88            }
89            let mut lines = Vec::new();
90            // Header row
91            lines.push(format!("| {} |", headers.join(" | ")));
92            // Separator
93            let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
94            lines.push(format!("| {} |", sep.join(" | ")));
95            // Data rows
96            for row in rows {
97                lines.push(format!("| {} |", row.join(" | ")));
98            }
99            lines.join("\n")
100        }
101
102        Block::Code {
103            lang, content, ..
104        } => {
105            let lang_tag = lang.as_deref().unwrap_or("");
106            format!("```{lang_tag}\n{content}\n```")
107        }
108
109        Block::Tasks { items, .. } => {
110            let lines: Vec<String> = items
111                .iter()
112                .map(|item| {
113                    let check = if item.done { "x" } else { " " };
114                    match &item.assignee {
115                        Some(a) => format!("- [{check}] {} @{a}", item.text),
116                        None => format!("- [{check}] {}", item.text),
117                    }
118                })
119                .collect();
120            lines.join("\n")
121        }
122
123        Block::Decision {
124            status,
125            date,
126            content,
127            ..
128        } => {
129            let status_label = decision_status_label(*status);
130            let date_part = match date {
131                Some(d) => format!(" ({d})"),
132                None => String::new(),
133            };
134            let mut lines = vec![format!("> **Decision** ({status_label}){date_part}")];
135            for line in content.lines() {
136                lines.push(format!("> {line}"));
137            }
138            lines.join("\n")
139        }
140
141        Block::Metric {
142            label,
143            value,
144            trend,
145            unit,
146            ..
147        } => {
148            let trend_arrow = match trend {
149                Some(Trend::Up) => " \u{2191}",
150                Some(Trend::Down) => " \u{2193}",
151                Some(Trend::Flat) => " \u{2192}",
152                None => "",
153            };
154            let unit_part = match unit {
155                Some(u) => format!(" {u}"),
156                None => String::new(),
157            };
158            format!("**{label}**: {value}{unit_part}{trend_arrow}")
159        }
160
161        Block::Summary { content, .. } => {
162            let lines: Vec<String> = content.lines().map(|l| format!("> *{l}*")).collect();
163            lines.join("\n")
164        }
165
166        Block::Figure {
167            src,
168            caption,
169            alt,
170            ..
171        } => {
172            let alt_text = alt.as_deref().unwrap_or("");
173            let img = format!("![{alt_text}]({src})");
174            match caption {
175                Some(c) => format!("{img}\n*{c}*"),
176                None => img,
177            }
178        }
179
180        Block::Diagram {
181            diagram_type,
182            title,
183            content,
184            ..
185        } => {
186            // Degrade to a fenced code block carrying the raw DSL; the info
187            // string keeps the diagram kind recoverable downstream.
188            let info = if diagram_type.is_empty() {
189                "diagram".to_string()
190            } else {
191                format!("diagram-{diagram_type}")
192            };
193            let fence = format!("```{info}\n{content}\n```");
194            match title {
195                Some(t) => format!("**{t}**\n\n{fence}"),
196                None => fence,
197            }
198        }
199
200        Block::Tabs { tabs, .. } => {
201            let parts: Vec<String> = tabs
202                .iter()
203                .map(|tab| format!("### {}\n\n{}", tab.label, tab.content))
204                .collect();
205            parts.join("\n\n")
206        }
207
208        Block::Columns { columns, .. } => {
209            let parts: Vec<String> = columns
210                .iter()
211                .map(|col| col.content.clone())
212                .collect();
213            parts.join("\n\n---\n\n")
214        }
215
216        Block::Quote {
217            content,
218            attribution,
219            ..
220        } => {
221            let mut lines: Vec<String> = content.lines().map(|l| format!("> {l}")).collect();
222            if let Some(attr) = attribution {
223                lines.push(format!(">\n> \u{2014} {attr}"));
224            }
225            lines.join("\n")
226        }
227
228        Block::Cta {
229            label, href, ..
230        } => {
231            // Degrades to a markdown link
232            format!("[{label}]({href})")
233        }
234
235        Block::HeroImage {
236            src, alt, ..
237        } => {
238            let alt_text = alt.as_deref().unwrap_or("Hero image");
239            format!("![{alt_text}]({src})")
240        }
241
242        Block::Testimonial {
243            content,
244            author,
245            role,
246            company,
247            ..
248        } => {
249            let mut lines: Vec<String> = content.lines().map(|l| format!("> {l}")).collect();
250            let details: Vec<&str> = [author.as_deref(), role.as_deref(), company.as_deref()]
251                .iter()
252                .filter_map(|v| *v)
253                .collect();
254            if !details.is_empty() {
255                lines.push(format!(">\n> \u{2014} {}", details.join(", ")));
256            }
257            lines.join("\n")
258        }
259
260        Block::Style { .. } => {
261            // Style blocks are invisible in markdown degradation
262            String::new()
263        }
264
265        Block::Faq { items, .. } => {
266            // Degrades to headings + paragraphs
267            let parts: Vec<String> = items
268                .iter()
269                .map(|item| format!("### {}\n\n{}", item.question, item.answer))
270                .collect();
271            parts.join("\n\n")
272        }
273
274        Block::PricingTable {
275            headers, rows, ..
276        } => {
277            // Degrades to a standard markdown table (same as Data)
278            if headers.is_empty() {
279                return String::new();
280            }
281            let mut lines = Vec::new();
282            lines.push(format!("| {} |", headers.join(" | ")));
283            let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
284            lines.push(format!("| {} |", sep.join(" | ")));
285            for row in rows {
286                lines.push(format!("| {} |", row.join(" | ")));
287            }
288            lines.join("\n")
289        }
290
291        Block::Site { domain, properties, .. } => {
292            // Degrades to a YAML-like config block
293            let mut lines = vec!["**Site Configuration**".to_string()];
294            if let Some(d) = domain {
295                lines.push(format!("- domain: {d}"));
296            }
297            for p in properties {
298                lines.push(format!("- {}: {}", p.key, p.value));
299            }
300            lines.join("\n")
301        }
302
303        Block::Page {
304            title,
305            content,
306            ..
307        } => {
308            // Degrades to a heading + raw content
309            if let Some(t) = title {
310                format!("## {t}\n\n{content}")
311            } else {
312                content.clone()
313            }
314        }
315
316        Block::Deck { properties, .. } => {
317            // Degrades to a YAML-like config block.
318            let mut lines = vec!["**Deck Configuration**".to_string()];
319            for p in properties {
320                lines.push(format!("- {}: {}", p.key, p.value));
321            }
322            lines.join("\n")
323        }
324
325        Block::Slide {
326            kicker, children, ..
327        } => {
328            // Degrades to an optional kicker line + the slide's child blocks.
329            let mut parts: Vec<String> = Vec::new();
330            if let Some(k) = kicker {
331                parts.push(format!("**{k}**"));
332            }
333            for child in children {
334                parts.push(render_block(child));
335            }
336            parts.join("\n\n")
337        }
338
339        Block::Nav { items, groups, .. } => {
340            // Degrades to a markdown list of links, with grouped sections as
341            // `## Heading` subheads (mirrors the nav author syntax).
342            let mut lines: Vec<String> = items
343                .iter()
344                .map(|item| format!("- [{}]({})", item.label, item.href))
345                .collect();
346            for g in groups {
347                if let Some(label) = &g.label {
348                    lines.push(format!("## {}", label));
349                }
350                for item in &g.items {
351                    lines.push(format!("- [{}]({})", item.label, item.href));
352                }
353            }
354            lines.join("\n")
355        }
356
357        Block::BeforeAfter {
358            before_items,
359            after_items,
360            transition,
361            ..
362        } => {
363            let mut lines = Vec::new();
364            lines.push("**Before**".to_string());
365            for item in before_items {
366                lines.push(format!("- {} \u{2014} {}", item.label, item.detail));
367            }
368            lines.push(String::new());
369            if let Some(t) = transition {
370                lines.push(format!("\u{2193} *{t}* \u{2193}"));
371                lines.push(String::new());
372            }
373            lines.push("**After**".to_string());
374            for item in after_items {
375                lines.push(format!("- {} \u{2014} {}", item.label, item.detail));
376            }
377            lines.join("\n")
378        }
379
380        Block::Pipeline { steps, .. } => {
381            steps
382                .iter()
383                .map(|s| {
384                    if let Some(d) = &s.description {
385                        format!("{} ({})", s.label, d)
386                    } else {
387                        s.label.clone()
388                    }
389                })
390                .collect::<Vec<_>>()
391                .join(" \u{2192} ")
392        }
393
394        Block::Section {
395            headline,
396            subtitle,
397            children,
398            ..
399        } => {
400            let mut lines = Vec::new();
401            if let Some(h) = headline {
402                lines.push(format!("## {h}"));
403                lines.push(String::new());
404            }
405            if let Some(s) = subtitle {
406                lines.push(s.clone());
407                lines.push(String::new());
408            }
409            for child in children {
410                lines.push(render_block(child));
411                lines.push(String::new());
412            }
413            lines.join("\n").trim().to_string()
414        }
415
416        Block::ProductCard {
417            title,
418            subtitle,
419            badge,
420            body,
421            features,
422            cta_label,
423            cta_href,
424            ..
425        } => {
426            let mut lines = Vec::new();
427            let badge_str = badge.as_ref().map(|b| format!(" [{b}]")).unwrap_or_default();
428            lines.push(format!("### {title}{badge_str}"));
429            lines.push(String::new());
430            if let Some(s) = subtitle {
431                lines.push(format!("*{s}*"));
432                lines.push(String::new());
433            }
434            if !body.is_empty() {
435                lines.push(body.clone());
436                lines.push(String::new());
437            }
438            for f in features {
439                lines.push(format!("- {f}"));
440            }
441            if let (Some(label), Some(href)) = (cta_label, cta_href) {
442                lines.push(String::new());
443                lines.push(format!("[{label}]({href})"));
444            }
445            lines.join("\n").trim().to_string()
446        }
447
448        Block::Unknown {
449            name,
450            content,
451            ..
452        } => {
453            let mut lines = Vec::new();
454            lines.push(format!("<!-- ::{name} -->"));
455            if !content.is_empty() {
456                lines.push(content.clone());
457            }
458            lines.push("<!-- :: -->".to_string());
459            lines.join("\n")
460        }
461
462        Block::Embed { src, title, .. } => {
463            let label = title.as_deref().unwrap_or("Embedded content");
464            format!("[{label}]({src})")
465        }
466
467        Block::Form { fields, submit_label, .. } => {
468            let mut lines = Vec::new();
469            lines.push("**Form**".to_string());
470            for field in fields {
471                let req = if field.required { " *" } else { "" };
472                lines.push(format!("- {}{}", field.label, req));
473            }
474            if let Some(label) = submit_label {
475                lines.push(format!("\n[{}]", label));
476            }
477            lines.join("\n")
478        }
479
480        Block::Gallery { items, .. } => {
481            let mut lines = Vec::new();
482            for item in items {
483                let alt = item.alt.as_deref().unwrap_or("");
484                let cap = item.caption.as_deref().map(|c| format!(" — {c}")).unwrap_or_default();
485                lines.push(format!("![{alt}]({}){cap}", item.src));
486            }
487            lines.join("\n")
488        }
489
490        Block::Footer { sections, copyright, social, .. } => {
491            let mut lines = Vec::new();
492            lines.push("---".to_string());
493            for section in sections {
494                lines.push(format!("**{}**", section.heading));
495                for link in &section.links {
496                    if link.href.is_empty() {
497                        lines.push(format!("- {}", link.label));
498                    } else {
499                        lines.push(format!("- [{}]({})", link.label, link.href));
500                    }
501                }
502                lines.push(String::new());
503            }
504            for link in social {
505                lines.push(format!("@{} {}", link.platform, link.href));
506            }
507            if let Some(cr) = copyright {
508                lines.push(cr.clone());
509            }
510            lines.join("\n")
511        }
512
513        Block::Details {
514            title, content, ..
515        } => {
516            let heading = title.as_deref().unwrap_or("Details");
517            format!("**{}**\n\n{}", heading, content)
518        }
519
520        Block::Divider { label, .. } => match label {
521            Some(text) => format!("--- {} ---", text),
522            None => "---".to_string(),
523        },
524
525        Block::Hero {
526            headline,
527            subtitle,
528            buttons,
529            ..
530        } => {
531            let mut lines = Vec::new();
532            if let Some(h) = headline {
533                lines.push(format!("# {h}"));
534                lines.push(String::new());
535            }
536            if let Some(s) = subtitle {
537                lines.push(s.clone());
538                lines.push(String::new());
539            }
540            for btn in buttons {
541                lines.push(format!("[{}]({})", btn.label, btn.href));
542            }
543            lines.join("\n")
544        }
545
546        Block::Banner {
547            headline,
548            subtitle,
549            buttons,
550            ..
551        } => {
552            let mut lines = Vec::new();
553            if let Some(h) = headline {
554                lines.push(format!("# {h}"));
555                lines.push(String::new());
556            }
557            if let Some(s) = subtitle {
558                lines.push(s.clone());
559                lines.push(String::new());
560            }
561            for btn in buttons {
562                lines.push(format!("[{}]({})", btn.label, btn.href));
563            }
564            lines.join("\n").trim().to_string()
565        }
566
567        Block::ProductGrid { groups, .. } => {
568            let mut lines = Vec::new();
569            for group in groups {
570                if let Some(label) = &group.label {
571                    lines.push(format!("### {label}"));
572                    lines.push(String::new());
573                }
574                for item in &group.items {
575                    let tagline = item
576                        .tagline
577                        .as_deref()
578                        .map(|t| format!(" — {t}"))
579                        .unwrap_or_default();
580                    lines.push(format!("- [{}]({}){}", item.name, item.href, tagline));
581                }
582                lines.push(String::new());
583            }
584            lines.join("\n").trim().to_string()
585        }
586
587        Block::PostGrid { title, subtitle, items, .. } => {
588            let mut lines = Vec::new();
589            if let Some(t) = title {
590                lines.push(format!("### {t}"));
591                lines.push(String::new());
592            }
593            if let Some(s) = subtitle {
594                lines.push(s.clone());
595                lines.push(String::new());
596            }
597            for item in items {
598                let excerpt = item
599                    .excerpt
600                    .as_deref()
601                    .map(|e| format!(" — {e}"))
602                    .unwrap_or_default();
603                lines.push(format!("- [{}]({}){}", item.title, item.href, excerpt));
604            }
605            lines.join("\n").trim().to_string()
606        }
607
608        Block::Gate { title, subtitle, .. } => {
609            let mut lines = Vec::new();
610            if let Some(t) = title {
611                lines.push(format!("### {t}"));
612                lines.push(String::new());
613            }
614            if let Some(s) = subtitle {
615                lines.push(s.clone());
616            }
617            lines.join("\n").trim().to_string()
618        }
619
620        Block::Features { cards, .. } => {
621            let mut lines = Vec::new();
622            for card in cards {
623                lines.push(format!("### {}", card.title));
624                lines.push(String::new());
625                if !card.body.is_empty() {
626                    lines.push(card.body.clone());
627                    lines.push(String::new());
628                }
629                if let (Some(label), Some(href)) = (&card.link_label, &card.link_href) {
630                    lines.push(format!("[{label}]({href})"));
631                    lines.push(String::new());
632                }
633            }
634            lines.join("\n").trim().to_string()
635        }
636
637        Block::Steps { steps, .. } => {
638            let mut lines = Vec::new();
639            for (i, step) in steps.iter().enumerate() {
640                lines.push(format!("{}. **{}**", i + 1, step.title));
641                if !step.body.is_empty() {
642                    lines.push(format!("   {}", step.body));
643                }
644            }
645            lines.join("\n")
646        }
647
648        Block::Stats { items, .. } => {
649            items
650                .iter()
651                .map(|item| format!("- **{}** {}", item.value, item.label))
652                .collect::<Vec<_>>()
653                .join("\n")
654        }
655
656        Block::Comparison {
657            headers, rows, ..
658        } => {
659            let mut lines = Vec::new();
660            lines.push(format!("| {} |", headers.join(" | ")));
661            lines.push(format!("| {} |", headers.iter().map(|_| "---").collect::<Vec<_>>().join(" | ")));
662            for row in rows {
663                lines.push(format!("| {} |", row.join(" | ")));
664            }
665            lines.join("\n")
666        }
667
668        Block::Logo { src, alt, .. } => {
669            let alt_text = alt.as_deref().unwrap_or("Logo");
670            format!("![{alt_text}]({src})")
671        }
672
673        Block::Toc { .. } => {
674            "*Table of Contents*".to_string()
675        }
676
677        // ----- App description blocks -----
678
679        Block::List {
680            source, display, filters, sort, ..
681        } => {
682            let display_str = match display {
683                ListDisplay::Card => "cards",
684                ListDisplay::Table => "table",
685                ListDisplay::Compact => "compact list",
686            };
687            let mut lines = vec![format!("**Data List** ({display_str})")];
688            lines.push(format!("Source: `{source}`"));
689            if !filters.is_empty() {
690                let filter_names: Vec<&str> = filters.iter().map(|f| f.field.as_str()).collect();
691                lines.push(format!("Filters: {}", filter_names.join(", ")));
692            }
693            if let Some(s) = sort {
694                let dir = if s.descending { "descending" } else { "ascending" };
695                lines.push(format!("Sort: {} {dir}", s.field));
696            }
697            lines.join("\n")
698        }
699
700        Block::Board {
701            source, columns, ..
702        } => {
703            let mut lines = vec!["**Board**".to_string()];
704            if !columns.is_empty() {
705                lines.push(format!("Columns: {}", columns.join(" | ")));
706            }
707            lines.push(format!("Source: `{source}`"));
708            lines.join("\n")
709        }
710
711        Block::Action {
712            method, target, label, fields, ..
713        } => {
714            let method_str = match method {
715                HttpMethod::Get => "GET",
716                HttpMethod::Post => "POST",
717                HttpMethod::Put => "PUT",
718                HttpMethod::Patch => "PATCH",
719                HttpMethod::Delete => "DELETE",
720            };
721            let mut lines = vec![format!("**{label}** ({method_str} `{target}`)")];
722            for field in fields {
723                let req = if field.required { " *" } else { "" };
724                lines.push(format!("- {}{}", field.label, req));
725            }
726            lines.join("\n")
727        }
728
729        Block::FilterBar { fields, .. } => {
730            let mut lines = vec!["**Filters**".to_string()];
731            for field in fields {
732                lines.push(format!("- {}: {}", field.label, field.options.join(" | ")));
733            }
734            lines.join("\n")
735        }
736
737        Block::Search { placeholder, .. } => {
738            let ph = placeholder.as_deref().unwrap_or("Search...");
739            format!("**Search**: {ph}")
740        }
741
742        Block::Dashboard { source, refresh, .. } => {
743            let refresh_str = refresh.map(|r| format!(" (refresh every {r}s)")).unwrap_or_default();
744            format!("**Dashboard**{refresh_str}\nSource: `{source}`")
745        }
746
747        Block::ChatInput { modes, placeholder, .. } => {
748            let ph = placeholder.as_deref().unwrap_or("Type a message...");
749            let mut lines = vec![format!("**Chat**: {ph}")];
750            if !modes.is_empty() {
751                lines.push(format!("Modes: {}", modes.join(" | ")));
752            }
753            lines.join("\n")
754        }
755
756        Block::Feed { source, stream, .. } => {
757            let mode = if *stream { "streaming" } else { "polling" };
758            format!("**Feed** ({mode})\nSource: `{source}`")
759        }
760
761        Block::Store {
762            title, currency, items, ..
763        } => {
764            let cur = currency.as_deref().unwrap_or("$");
765            let mut out = format!("**{}**\n", title.as_deref().unwrap_or("Store"));
766            for it in items {
767                match &it.blurb {
768                    Some(b) => out.push_str(&format!("- {} — {}{} ({})\n", it.name, cur, it.price, b)),
769                    None => out.push_str(&format!("- {} — {}{}\n", it.name, cur, it.price)),
770                }
771            }
772            out
773        }
774
775        Block::Booking {
776            title, services, days, ..
777        } => {
778            let mut out = format!("**{}**\n", title.as_deref().unwrap_or("Booking"));
779            for s in services {
780                let meta: Vec<&str> = [s.duration.as_deref(), s.price.as_deref()]
781                    .into_iter()
782                    .flatten()
783                    .collect();
784                if meta.is_empty() {
785                    out.push_str(&format!("- {}\n", s.name));
786                } else {
787                    out.push_str(&format!("- {} ({})\n", s.name, meta.join(", ")));
788                }
789            }
790            let open = days.iter().filter(|d| !d.slots.is_empty()).count();
791            out.push_str(&format!("\n_{open} day(s) with availability._"));
792            out
793        }
794
795        Block::Editor { lang, .. } => {
796            let lang_str = lang.as_deref().unwrap_or("text");
797            format!("**Editor** ({lang_str})")
798        }
799
800        Block::Chart { chart_type, source, period, title, data, .. } => {
801            let type_str = match chart_type {
802                ChartType::Line => "Line",
803                ChartType::Bar => "Bar",
804                ChartType::Pie => "Pie",
805                ChartType::Area => "Area",
806                ChartType::Scatter => "Scatter",
807                ChartType::Donut => "Donut",
808                ChartType::StackedBar => "Stacked Bar",
809                ChartType::Radar => "Radar",
810            };
811            let heading = match title {
812                Some(t) => format!("**{type_str} Chart — {t}**"),
813                None => format!("**{type_str} Chart**"),
814            };
815            // Inline data → render a compact markdown table of the values.
816            if let Some(d) = data {
817                let mut out = heading;
818                out.push('\n');
819                out.push_str("| | ");
820                out.push_str(&d.series.iter().map(|s| s.name.clone()).collect::<Vec<_>>().join(" | "));
821                out.push_str(" |\n|---|");
822                out.push_str(&"---|".repeat(d.series.len()));
823                for (i, cat) in d.categories.iter().enumerate() {
824                    out.push_str(&format!("\n| {cat} | "));
825                    let cells: Vec<String> = d
826                        .series
827                        .iter()
828                        .map(|s| s.values.get(i).map(|v| format!("{v}")).unwrap_or_default())
829                        .collect();
830                    out.push_str(&cells.join(" | "));
831                    out.push_str(" |");
832                }
833                return out;
834            }
835            let period_str = period.as_ref().map(|p| format!(" ({p})")).unwrap_or_default();
836            format!("{heading}{period_str}\nSource: `{source}`")
837        }
838
839        Block::SplitPane { ratio, .. } => {
840            format!("**Split Pane** ({ratio})")
841        }
842
843        // ----- Infrastructure manifest blocks -----
844
845        Block::App { name, children, .. } => {
846            let mut lines = vec![format!("## App: {name}")];
847            for child in children {
848                let rendered = render_block(child);
849                if !rendered.is_empty() { lines.push(rendered); }
850            }
851            lines.join("\n\n")
852        }
853
854        Block::Build { base, runtime, edition, properties, .. } => {
855            let mut lines = vec!["**Build**".to_string()];
856            if let Some(b) = base { lines.push(format!("- base: {b}")); }
857            if let Some(r) = runtime { lines.push(format!("- runtime: {r}")); }
858            if let Some(e) = edition { lines.push(format!("- edition: {e}")); }
859            for p in properties { lines.push(format!("- {}: {}", p.key, p.value)); }
860            lines.join("\n")
861        }
862
863        Block::InfraDatabase { name, shared_auth, volume_gb, properties, .. } => {
864            let mut lines = vec!["**Database**".to_string()];
865            if let Some(n) = name { lines.push(format!("- name: {n}")); }
866            if *shared_auth { lines.push("- shared_auth: true".to_string()); }
867            if let Some(v) = volume_gb { lines.push(format!("- volume: {v} GB")); }
868            for p in properties { lines.push(format!("- {}: {}", p.key, p.value)); }
869            lines.join("\n")
870        }
871
872        Block::Deploy { env, app, machines, memory, auto_stop, min_machines, strategy, properties, .. } => {
873            let env_str = env.as_deref().unwrap_or("unknown");
874            let mut lines = vec![format!("**Deploy: {env_str}**")];
875            if let Some(a) = app { lines.push(format!("- app: {a}")); }
876            if let Some(m) = machines { lines.push(format!("- machines: {m}")); }
877            if let Some(m) = memory { lines.push(format!("- memory: {m} MB")); }
878            if let Some(a) = auto_stop { lines.push(format!("- auto_stop: {a}")); }
879            if let Some(m) = min_machines { lines.push(format!("- min_machines: {m}")); }
880            if let Some(s) = strategy { lines.push(format!("- strategy: {s}")); }
881            for p in properties { lines.push(format!("- {}: {}", p.key, p.value)); }
882            lines.join("\n")
883        }
884
885        Block::InfraEnv { tier, entries, .. } => {
886            let tier_str = tier.as_deref().unwrap_or("env");
887            let mut lines = vec![format!("**Env ({tier_str})**"), "```".to_string()];
888            for e in entries {
889                match &e.default_value {
890                    Some(v) => lines.push(format!("{} = {}", e.name, v)),
891                    None => lines.push(e.name.clone()),
892                }
893            }
894            lines.push("```".to_string());
895            lines.join("\n")
896        }
897
898        Block::Health { path, method, grace, interval, timeout, .. } => {
899            let mut lines = vec!["**Health Check**".to_string()];
900            if let Some(p) = path { lines.push(format!("- path: {p}")); }
901            if let Some(m) = method { lines.push(format!("- method: {m}")); }
902            if let Some(g) = grace { lines.push(format!("- grace: {g}")); }
903            if let Some(i) = interval { lines.push(format!("- interval: {i}")); }
904            if let Some(t) = timeout { lines.push(format!("- timeout: {t}")); }
905            lines.join("\n")
906        }
907
908        Block::Concurrency { concurrency_type, hard_limit, soft_limit, force_https, .. } => {
909            let mut lines = vec!["**Concurrency**".to_string()];
910            if let Some(t) = concurrency_type { lines.push(format!("- type: {t}")); }
911            if let Some(h) = hard_limit { lines.push(format!("- hard_limit: {h}")); }
912            if let Some(s) = soft_limit { lines.push(format!("- soft_limit: {s}")); }
913            if *force_https { lines.push("- force_https: true".to_string()); }
914            lines.join("\n")
915        }
916
917        Block::Cicd { provider, properties, .. } => {
918            let prov = provider.as_deref().unwrap_or("CI/CD");
919            let mut lines = vec![format!("**{prov}**")];
920            for p in properties { lines.push(format!("- {}: {}", p.key, p.value)); }
921            lines.join("\n")
922        }
923
924        Block::Smoke { checks, .. } => {
925            let mut lines = vec![
926                "| Method | Path | Expected |".to_string(),
927                "| --- | --- | --- |".to_string(),
928            ];
929            for c in checks {
930                lines.push(format!("| {} | `{}` | {} |", c.method, c.path, c.expected));
931            }
932            lines.join("\n")
933        }
934
935        Block::Domains { entries, .. } => {
936            let mut lines = vec!["**Domains**".to_string()];
937            for e in entries {
938                match &e.description {
939                    Some(d) => lines.push(format!("- {} \u{2014} {}", e.domain, d)),
940                    None => lines.push(format!("- {}", e.domain)),
941                }
942            }
943            lines.join("\n")
944        }
945
946        Block::Crates { entries, .. } => {
947            let mut lines = vec!["**Crates**".to_string()];
948            for e in entries {
949                let mut detail = Vec::new();
950                if let Some(s) = &e.source { detail.push(format!("source: {s}")); }
951                if let Some(f) = &e.features { detail.push(format!("features: {f}")); }
952                if detail.is_empty() {
953                    lines.push(format!("- `{}`", e.name));
954                } else {
955                    lines.push(format!("- `{}` ({})", e.name, detail.join(", ")));
956                }
957            }
958            lines.join("\n")
959        }
960
961        Block::DeployUrls { entries, .. } => {
962            let mut lines = vec!["**Deploy URLs**".to_string()];
963            for p in entries { lines.push(format!("- {}: {}", p.key, p.value)); }
964            lines.join("\n")
965        }
966
967        Block::Volumes { entries, .. } => {
968            let mut lines = vec!["**Volumes**".to_string()];
969            for v in entries { lines.push(format!("- {} \u{2192} {}", v.name, v.mount)); }
970            lines.join("\n")
971        }
972
973        Block::Model { name, fields, .. } => {
974            let mut lines = vec![format!("**Model: {name}**"), String::new()];
975            lines.push("| Field | Type | Constraints |".to_string());
976            lines.push("|-------|------|-------------|".to_string());
977            for f in fields {
978                let type_str = model_field_type_md(&f.field_type);
979                let constraints: Vec<String> = f.constraints.iter().map(|c| constraint_md(c)).collect();
980                lines.push(format!("| {} | {} | {} |", f.name, type_str, constraints.join(", ")));
981            }
982            lines.join("\n")
983        }
984
985        Block::Route { method, path, auth, returns, body, handler, content, .. } => {
986            let method_str = http_method_md(*method);
987            let mut lines = vec![format!("**{method_str} `{path}`**")];
988            if let Some(a) = auth { lines.push(format!("- auth: {a}")); }
989            if let Some(r) = returns { lines.push(format!("- returns: `{r}`")); }
990            if let Some(b) = body { lines.push(format!("- body: `{b}`")); }
991            if let Some(h) = handler {
992                lines.push(String::new());
993                lines.push("```rust".to_string());
994                lines.push(h.clone());
995                lines.push("```".to_string());
996            }
997            if !content.is_empty() { lines.push(content.clone()); }
998            lines.join("\n")
999        }
1000
1001        Block::Auth { provider, session, roles, default_role, .. } => {
1002            let provider_str = auth_provider_md(*provider);
1003            let mut lines = vec!["**Authentication**".to_string()];
1004            lines.push(format!("- provider: {provider_str}"));
1005            if let Some(s) = session { lines.push(format!("- session: {s}")); }
1006            if !roles.is_empty() { lines.push(format!("- roles: {}", roles.join(", "))); }
1007            if let Some(dr) = default_role { lines.push(format!("- default role: {dr}")); }
1008            lines.join("\n")
1009        }
1010
1011        Block::Binding { source, target, events, .. } => {
1012            let mut lines = vec!["**Binding**".to_string()];
1013            lines.push(format!("- source: `{source}`"));
1014            lines.push(format!("- target: `{target}`"));
1015            for e in events { lines.push(format!("- {}: {}", e.event, e.action)); }
1016            lines.join("\n")
1017        }
1018
1019        Block::Schema { name, fields, .. } => {
1020            let mut lines = vec![format!("**Schema: {name}**"), String::new()];
1021            lines.push("| Column | Type | Constraints |".to_string());
1022            lines.push("|---|---|---|".to_string());
1023            for f in fields {
1024                let constraints = f.constraints.iter().map(|c| constraint_md(c)).collect::<Vec<_>>().join(", ");
1025                lines.push(format!("| {} | {} | {} |", f.name, model_field_type_md(&f.field_type), constraints));
1026            }
1027            lines.join("\n")
1028        }
1029
1030        Block::Use { crates, .. } => {
1031            let mut lines = vec!["**Dependencies**".to_string()];
1032            for c in crates {
1033                let mut parts = vec![format!("`{}`", c.name)];
1034                if let Some(v) = &c.version { parts.push(v.clone()); }
1035                if !c.features.is_empty() { parts.push(format!("[{}]", c.features.join(", "))); }
1036                lines.push(format!("- {}", parts.join(" ")));
1037            }
1038            lines.join("\n")
1039        }
1040
1041        Block::AppEnv { vars, .. } => {
1042            let mut lines = vec!["**Environment Variables**".to_string(), String::new()];
1043            lines.push("| Name | Required | Description |".to_string());
1044            lines.push("|---|---|---|".to_string());
1045            for v in vars {
1046                let req = if v.required { "yes" } else { "no" };
1047                let desc = v.description.as_deref().unwrap_or("");
1048                lines.push(format!("| `{}` | {} | {} |", v.name, req, desc));
1049            }
1050            lines.join("\n")
1051        }
1052
1053        Block::AppDeploy { region, scale, domain, memory, properties, .. } => {
1054            let mut lines = vec!["**Deploy Configuration**".to_string()];
1055            if let Some(r) = region { lines.push(format!("- region: {r}")); }
1056            if let Some(s) = scale { lines.push(format!("- scale: {s}")); }
1057            if let Some(d) = domain { lines.push(format!("- domain: {d}")); }
1058            if let Some(m) = memory { lines.push(format!("- memory: {m}")); }
1059            for (k, v) in properties { lines.push(format!("- {k}: {v}")); }
1060            lines.join("\n")
1061        }
1062
1063        Block::Row { title, description, .. } => {
1064            if description.is_empty() { format!("- {title}") } else { format!("- **{title}** — {description}") }
1065        }
1066
1067        Block::InfoCard { title, subtitle, summary, facts, steps, .. } => {
1068            let mut lines = vec![format!("## {title}")];
1069            if !subtitle.is_empty() { lines.push(format!("*{subtitle}*")); }
1070            if !summary.is_empty() { lines.push(String::new()); lines.push(summary.clone()); }
1071            for fact in facts { lines.push(format!("- **{}**: {}", fact[0], fact[1])); }
1072            for (i, step) in steps.iter().enumerate() { lines.push(format!("{}. {step}", i + 1)); }
1073            lines.join("\n")
1074        }
1075
1076        // ── Interactive / application blocks (markdown fallback) ──
1077
1078        Block::AppShell { children, .. } => {
1079            let parts: Vec<String> = children.iter().map(render_block).collect();
1080            parts.join("\n\n")
1081        }
1082
1083        Block::Sidebar { children, .. } => {
1084            let parts: Vec<String> = children.iter().map(render_block).collect();
1085            parts.join("\n\n")
1086        }
1087
1088        Block::Panel { children, .. } => {
1089            let parts: Vec<String> = children.iter().map(render_block).collect();
1090            parts.join("\n\n")
1091        }
1092
1093        Block::TabBar { items, .. } => {
1094            let labels: Vec<String> = items.iter().map(|i| format!("- {}", i.label)).collect();
1095            labels.join("\n")
1096        }
1097
1098        Block::TabContent { tab, children, .. } => {
1099            let parts: Vec<String> = children.iter().map(render_block).collect();
1100            format!("### {tab}\n\n{}", parts.join("\n\n"))
1101        }
1102
1103        Block::Toolbar { items, .. } => {
1104            let labels: Vec<String> = items.iter().filter_map(|i| match i {
1105                crate::types::ToolbarItem::Button { label, .. } => Some(format!("- {}", label.as_deref().unwrap_or("Button"))),
1106                crate::types::ToolbarItem::Badge { value, .. } => Some(format!("- [{value}]")),
1107                crate::types::ToolbarItem::Text { value, .. } => Some(format!("- {value}")),
1108                _ => None,
1109            }).collect();
1110            labels.join("\n")
1111        }
1112
1113        Block::Drawer { children, .. } => {
1114            let parts: Vec<String> = children.iter().map(render_block).collect();
1115            parts.join("\n\n")
1116        }
1117
1118        Block::Modal { name, title, children, .. } => {
1119            let heading = title.as_deref().unwrap_or(name);
1120            let parts: Vec<String> = children.iter().map(render_block).collect();
1121            format!("### {heading}\n\n{}", parts.join("\n\n"))
1122        }
1123
1124        Block::CommandPalette { items, .. } => {
1125            let lines: Vec<String> = items.iter().map(|i| {
1126                let desc = i.description.as_deref().unwrap_or("");
1127                format!("- **{}** — {desc}", i.label)
1128            }).collect();
1129            lines.join("\n")
1130        }
1131
1132        Block::CodeEditor { lang, content, .. } => {
1133            let lang_tag = lang.as_deref().unwrap_or("");
1134            format!("```{lang_tag}\n{content}\n```")
1135        }
1136
1137        Block::BlockEditor { .. } => "*Block editor*".to_string(),
1138
1139        Block::Terminal { .. } => "*Terminal*".to_string(),
1140
1141        Block::NavTree { .. } => "*File tree*".to_string(),
1142
1143        Block::Badge { value, .. } => format!("[{value}]"),
1144
1145        Block::SuggestionChips { .. } => "*Suggestion chips*".to_string(),
1146
1147        Block::ChatThread { .. } => "*Chat thread*".to_string(),
1148
1149        Block::ChatInputSimple { placeholder, .. } => {
1150            let ph = placeholder.as_deref().unwrap_or("Message...");
1151            format!("*[{ph}]*")
1152        }
1153
1154        Block::Progress { steps, .. } => {
1155            let lines: Vec<String> = steps.iter().enumerate().map(|(i, s)| {
1156                let marker = match s.status.as_str() {
1157                    "done" => "\u{2713}",
1158                    "active" => "\u{25CF}",
1159                    _ => "\u{25CB}",
1160                };
1161                format!("{}. {} {marker}", i + 1, s.label)
1162            }).collect();
1163            lines.join("\n")
1164        }
1165
1166        Block::LogStream { .. } => "*Log stream*".to_string(),
1167
1168        Block::ProblemList { .. } => "*Problem list*".to_string(),
1169
1170    }
1171}
1172
1173fn model_field_type_md(ft: &crate::types::ModelFieldType) -> String {
1174    use crate::types::ModelFieldType;
1175    match ft {
1176        ModelFieldType::Uuid => "uuid".to_string(),
1177        ModelFieldType::String => "string".to_string(),
1178        ModelFieldType::Int => "int".to_string(),
1179        ModelFieldType::Float => "float".to_string(),
1180        ModelFieldType::Bool => "bool".to_string(),
1181        ModelFieldType::Datetime => "datetime".to_string(),
1182        ModelFieldType::Text => "text".to_string(),
1183        ModelFieldType::Json => "json".to_string(),
1184        ModelFieldType::Money => "money".to_string(),
1185        ModelFieldType::Image => "image".to_string(),
1186        ModelFieldType::Email => "email".to_string(),
1187        ModelFieldType::Url => "url".to_string(),
1188        ModelFieldType::Enum(variants) => format!("enum({})", variants.join(", ")),
1189        ModelFieldType::Ref(target) => format!("ref({target})"),
1190    }
1191}
1192
1193fn constraint_md(c: &crate::types::FieldConstraint) -> String {
1194    use crate::types::FieldConstraint;
1195    match c {
1196        FieldConstraint::Primary => "primary".to_string(),
1197        FieldConstraint::Auto => "auto".to_string(),
1198        FieldConstraint::Required => "required".to_string(),
1199        FieldConstraint::Optional => "optional".to_string(),
1200        FieldConstraint::Unique => "unique".to_string(),
1201        FieldConstraint::Index => "index".to_string(),
1202        FieldConstraint::Max(n) => format!("max={n}"),
1203        FieldConstraint::Min(n) => format!("min={n}"),
1204        FieldConstraint::Default(v) => format!("default={v}"),
1205    }
1206}
1207
1208fn http_method_md(m: crate::types::HttpMethod) -> &'static str {
1209    use crate::types::HttpMethod;
1210    match m {
1211        HttpMethod::Get => "GET",
1212        HttpMethod::Post => "POST",
1213        HttpMethod::Put => "PUT",
1214        HttpMethod::Patch => "PATCH",
1215        HttpMethod::Delete => "DELETE",
1216    }
1217}
1218
1219fn auth_provider_md(p: crate::types::AuthProvider) -> &'static str {
1220    use crate::types::AuthProvider;
1221    match p {
1222        AuthProvider::Email => "email",
1223        AuthProvider::OAuth => "oauth",
1224        AuthProvider::ApiKey => "api-key",
1225        AuthProvider::Token => "token",
1226    }
1227}
1228
1229fn callout_type_label(ct: CalloutType) -> &'static str {
1230    match ct {
1231        CalloutType::Info => "Info",
1232        CalloutType::Warning => "Warning",
1233        CalloutType::Danger => "Danger",
1234        CalloutType::Tip => "Tip",
1235        CalloutType::Note => "Note",
1236        CalloutType::Success => "Success",
1237        CalloutType::Context => "Context",
1238    }
1239}
1240
1241fn decision_status_label(ds: DecisionStatus) -> &'static str {
1242    match ds {
1243        DecisionStatus::Proposed => "proposed",
1244        DecisionStatus::Accepted => "accepted",
1245        DecisionStatus::Rejected => "rejected",
1246        DecisionStatus::Superseded => "superseded",
1247    }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use crate::types::*;
1254
1255    fn span() -> Span {
1256        Span {
1257            start_line: 1,
1258            end_line: 1,
1259            start_offset: 0,
1260            end_offset: 0,
1261        }
1262    }
1263
1264    fn doc_with(blocks: Vec<Block>) -> SurfDoc {
1265        SurfDoc {
1266            front_matter: None,
1267            blocks,
1268            source: String::new(),
1269        }
1270    }
1271
1272    #[test]
1273    fn md_callout_warning() {
1274        let doc = doc_with(vec![Block::Callout {
1275            callout_type: CalloutType::Warning,
1276            title: Some("Watch out".into()),
1277            content: "Sharp edges ahead.".into(),
1278            span: span(),
1279        }]);
1280        let md = to_markdown(&doc);
1281        assert!(md.contains("> **Warning**: Watch out"));
1282        assert!(md.contains("> Sharp edges ahead."));
1283    }
1284
1285    #[test]
1286    fn md_diagram_degrades_to_fenced_block() {
1287        let doc = doc_with(vec![Block::Diagram {
1288            diagram_type: "architecture".into(),
1289            title: Some("System Map".into()),
1290            content: "web -> api".into(),
1291            span: span(),
1292        }]);
1293        let md = to_markdown(&doc);
1294        assert!(md.contains("**System Map**"));
1295        assert!(md.contains("```diagram-architecture\nweb -> api\n```"));
1296
1297        // No title + empty type -> bare `diagram` info string, no bold line.
1298        let doc = doc_with(vec![Block::Diagram {
1299            diagram_type: String::new(),
1300            title: None,
1301            content: "a -> b".into(),
1302            span: span(),
1303        }]);
1304        let md = to_markdown(&doc);
1305        assert_eq!(md, "```diagram\na -> b\n```");
1306    }
1307
1308    #[test]
1309    fn md_data_table() {
1310        let doc = doc_with(vec![Block::Data {
1311            id: None,
1312            format: DataFormat::Table,
1313            sortable: false,
1314            headers: vec!["Name".into(), "Age".into()],
1315            rows: vec![vec!["Alice".into(), "30".into()]],
1316            raw_content: String::new(),
1317            span: span(),
1318        }]);
1319        let md = to_markdown(&doc);
1320        assert!(md.contains("| Name | Age |"));
1321        assert!(md.contains("| --- | --- |"));
1322        assert!(md.contains("| Alice | 30 |"));
1323    }
1324
1325    #[test]
1326    fn md_code_block() {
1327        let doc = doc_with(vec![Block::Code {
1328            lang: Some("rust".into()),
1329            file: None,
1330            highlight: vec![],
1331            content: "fn main() {}".into(),
1332            span: span(),
1333        }]);
1334        let md = to_markdown(&doc);
1335        assert!(md.contains("```rust"));
1336        assert!(md.contains("fn main() {}"));
1337        assert!(md.contains("```"));
1338    }
1339
1340    #[test]
1341    fn md_tasks() {
1342        let doc = doc_with(vec![Block::Tasks {
1343            items: vec![
1344                TaskItem {
1345                    done: false,
1346                    text: "Write tests".into(),
1347                    assignee: None,
1348                },
1349                TaskItem {
1350                    done: true,
1351                    text: "Write parser".into(),
1352                    assignee: Some("brady".into()),
1353                },
1354            ],
1355            span: span(),
1356        }]);
1357        let md = to_markdown(&doc);
1358        assert!(md.contains("- [ ] Write tests"));
1359        assert!(md.contains("- [x] Write parser @brady"));
1360    }
1361
1362    #[test]
1363    fn md_decision() {
1364        let doc = doc_with(vec![Block::Decision {
1365            status: DecisionStatus::Accepted,
1366            date: Some("2026-02-10".into()),
1367            deciders: vec![],
1368            content: "We chose Rust.".into(),
1369            span: span(),
1370        }]);
1371        let md = to_markdown(&doc);
1372        assert!(md.contains("> **Decision** (accepted) (2026-02-10)"));
1373        assert!(md.contains("> We chose Rust."));
1374    }
1375
1376    #[test]
1377    fn md_metric() {
1378        let doc = doc_with(vec![Block::Metric {
1379            label: "MRR".into(),
1380            value: "$2K".into(),
1381            trend: Some(Trend::Up),
1382            unit: Some("USD".into()),
1383            span: span(),
1384        }]);
1385        let md = to_markdown(&doc);
1386        assert!(md.contains("**MRR**: $2K USD"));
1387        assert!(md.contains("\u{2191}")); // up arrow
1388    }
1389
1390    #[test]
1391    fn md_summary() {
1392        let doc = doc_with(vec![Block::Summary {
1393            content: "Executive overview.".into(),
1394            span: span(),
1395        }]);
1396        let md = to_markdown(&doc);
1397        assert!(md.contains("> *Executive overview.*"));
1398    }
1399
1400    #[test]
1401    fn md_figure() {
1402        let doc = doc_with(vec![Block::Figure {
1403            src: "diagram.png".into(),
1404            caption: Some("Architecture".into()),
1405            alt: Some("Diagram".into()),
1406            width: None,
1407            span: span(),
1408        }]);
1409        let md = to_markdown(&doc);
1410        assert!(md.contains("![Diagram](diagram.png)"));
1411        assert!(md.contains("*Architecture*"));
1412    }
1413
1414    // -- Web blocks ------------------------------------------------
1415
1416    #[test]
1417    fn md_cta() {
1418        let doc = doc_with(vec![Block::Cta {
1419            label: "Sign Up".into(),
1420            href: "/signup".into(),
1421            primary: true,
1422            icon: None,
1423            span: span(),
1424        }]);
1425        let md = to_markdown(&doc);
1426        assert_eq!(md, "[Sign Up](/signup)");
1427    }
1428
1429    #[test]
1430    fn md_hero_image() {
1431        let doc = doc_with(vec![Block::HeroImage {
1432            src: "hero.png".into(),
1433            alt: Some("Product shot".into()),
1434            span: span(),
1435        }]);
1436        let md = to_markdown(&doc);
1437        assert_eq!(md, "![Product shot](hero.png)");
1438    }
1439
1440    #[test]
1441    fn md_testimonial() {
1442        let doc = doc_with(vec![Block::Testimonial {
1443            content: "Great product!".into(),
1444            author: Some("Jane".into()),
1445            role: Some("Engineer".into()),
1446            company: None,
1447            span: span(),
1448        }]);
1449        let md = to_markdown(&doc);
1450        assert!(md.contains("> Great product!"));
1451        assert!(md.contains("\u{2014} Jane, Engineer"));
1452    }
1453
1454    #[test]
1455    fn md_style_invisible() {
1456        let doc = doc_with(vec![Block::Style {
1457            properties: vec![crate::types::StyleProperty {
1458                key: "accent".into(),
1459                value: "blue".into(),
1460            }],
1461            span: span(),
1462        }]);
1463        let md = to_markdown(&doc);
1464        assert!(md.is_empty());
1465    }
1466
1467    #[test]
1468    fn md_faq() {
1469        let doc = doc_with(vec![Block::Faq {
1470            items: vec![
1471                crate::types::FaqItem {
1472                    question: "Is it free?".into(),
1473                    answer: "Yes.".into(),
1474                },
1475                crate::types::FaqItem {
1476                    question: "Can I export?".into(),
1477                    answer: "PDF and HTML.".into(),
1478                },
1479            ],
1480            span: span(),
1481        }]);
1482        let md = to_markdown(&doc);
1483        assert!(md.contains("### Is it free?"));
1484        assert!(md.contains("Yes."));
1485        assert!(md.contains("### Can I export?"));
1486        assert!(md.contains("PDF and HTML."));
1487    }
1488
1489    #[test]
1490    fn md_pricing_table() {
1491        let doc = doc_with(vec![Block::PricingTable {
1492            headers: vec!["".into(), "Free".into(), "Pro".into()],
1493            rows: vec![vec!["Price".into(), "$0".into(), "$9/mo".into()]],
1494            span: span(),
1495        }]);
1496        let md = to_markdown(&doc);
1497        assert!(md.contains("Free | Pro"));
1498        assert!(md.contains("| --- | --- | --- |"));
1499        assert!(md.contains("| Price | $0 | $9/mo |"));
1500    }
1501
1502    #[test]
1503    fn md_site() {
1504        let doc = doc_with(vec![Block::Site {
1505            domain: Some("example.com".into()),
1506            properties: vec![
1507                crate::types::StyleProperty { key: "name".into(), value: "Test".into() },
1508            ],
1509            span: span(),
1510        }]);
1511        let md = to_markdown(&doc);
1512        assert!(md.contains("**Site Configuration**"));
1513        assert!(md.contains("domain: example.com"));
1514        assert!(md.contains("name: Test"));
1515    }
1516
1517    #[test]
1518    fn md_page_with_title() {
1519        let doc = doc_with(vec![Block::Page {
1520            route: "/".into(),
1521            layout: None,
1522            title: Some("Home".into()),
1523            sidebar: false,
1524            content: "Welcome to our site.".into(),
1525            children: vec![],
1526            span: span(),
1527        }]);
1528        let md = to_markdown(&doc);
1529        assert!(md.contains("## Home"));
1530        assert!(md.contains("Welcome to our site."));
1531    }
1532
1533    #[test]
1534    fn md_page_no_title() {
1535        let doc = doc_with(vec![Block::Page {
1536            route: "/about".into(),
1537            layout: None,
1538            title: None,
1539            sidebar: false,
1540            content: "# About Us\n\nWe build things.".into(),
1541            children: vec![],
1542            span: span(),
1543        }]);
1544        let md = to_markdown(&doc);
1545        assert!(md.contains("# About Us"));
1546        assert!(md.contains("We build things."));
1547    }
1548
1549    #[test]
1550    fn md_no_surfdoc_markers() {
1551        let doc = doc_with(vec![
1552            Block::Callout {
1553                callout_type: CalloutType::Info,
1554                title: None,
1555                content: "Hello".into(),
1556                span: span(),
1557            },
1558            Block::Code {
1559                lang: Some("rust".into()),
1560                file: None,
1561                highlight: vec![],
1562                content: "let x = 1;".into(),
1563                span: span(),
1564            },
1565            Block::Metric {
1566                label: "A".into(),
1567                value: "1".into(),
1568                trend: None,
1569                unit: None,
1570                span: span(),
1571            },
1572        ]);
1573        let md = to_markdown(&doc);
1574        // Ensure no :: markers exist (they belong to SurfDoc directives, not Markdown)
1575        assert!(
1576            !md.contains("::callout"),
1577            "Output should not contain ::callout markers"
1578        );
1579        assert!(
1580            !md.contains("::code"),
1581            "Output should not contain ::code markers"
1582        );
1583        assert!(
1584            !md.contains("::metric"),
1585            "Output should not contain ::metric markers"
1586        );
1587    }
1588
1589    #[test]
1590    fn md_before_after() {
1591        let doc = doc_with(vec![Block::BeforeAfter {
1592            before_items: vec![crate::types::BeforeAfterItem {
1593                label: "Manual".into(),
1594                detail: "Hand-written".into(),
1595            }],
1596            after_items: vec![crate::types::BeforeAfterItem {
1597                label: "Auto".into(),
1598                detail: "Generated".into(),
1599            }],
1600            transition: Some("SurfDoc".into()),
1601            span: span(),
1602        }]);
1603        let md = to_markdown(&doc);
1604        assert!(md.contains("**Before**"));
1605        assert!(md.contains("**After**"));
1606        assert!(md.contains("Manual"));
1607        assert!(md.contains("SurfDoc"));
1608    }
1609
1610    #[test]
1611    fn md_pipeline() {
1612        let doc = doc_with(vec![Block::Pipeline {
1613            steps: vec![
1614                crate::types::PipelineStep { label: "A".into(), description: Some("first".into()) },
1615                crate::types::PipelineStep { label: "B".into(), description: None },
1616            ],
1617            span: span(),
1618        }]);
1619        let md = to_markdown(&doc);
1620        assert!(md.contains("A (first)"));
1621        assert!(md.contains("\u{2192}"));
1622        assert!(md.contains("B"));
1623    }
1624
1625    #[test]
1626    fn md_section() {
1627        let doc = doc_with(vec![Block::Section {
1628            bg: Some("muted".into()),
1629            headline: Some("Features".into()),
1630            subtitle: Some("What we offer".into()),
1631            content: String::new(),
1632            children: vec![Block::Markdown {
1633                content: "Some content".into(),
1634                span: span(),
1635            }],
1636            span: span(),
1637        }]);
1638        let md = to_markdown(&doc);
1639        assert!(md.contains("## Features"));
1640        assert!(md.contains("What we offer"));
1641        assert!(md.contains("Some content"));
1642    }
1643
1644    #[test]
1645    fn md_product_card() {
1646        let doc = doc_with(vec![Block::ProductCard {
1647            title: "Surf".into(),
1648            subtitle: Some("Browser".into()),
1649            badge: Some("Available".into()),
1650            badge_color: None,
1651            body: "A great product.".into(),
1652            features: vec!["Fast".into(), "Secure".into()],
1653            cta_label: Some("Get it".into()),
1654            cta_href: Some("/download".into()),
1655            span: span(),
1656        }]);
1657        let md = to_markdown(&doc);
1658        assert!(md.contains("### Surf [Available]"));
1659        assert!(md.contains("*Browser*"));
1660        assert!(md.contains("A great product."));
1661        assert!(md.contains("- Fast"));
1662        assert!(md.contains("[Get it](/download)"));
1663    }
1664}