1use crate::citation;
7use crate::types::{Block, CalloutType, ChartType, DecisionStatus, Format, HttpMethod, ListDisplay, SurfDoc, Trend};
8
9pub 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
27fn 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 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 lines.push(format!("| {} |", headers.join(" | ")));
92 let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
94 lines.push(format!("| {} |", sep.join(" | ")));
95 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!("");
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 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 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!("")
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 String::new()
263 }
264
265 Block::Faq { items, .. } => {
266 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 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 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 if let Some(t) = title {
310 format!("## {t}\n\n{content}")
311 } else {
312 content.clone()
313 }
314 }
315
316 Block::Deck { properties, .. } => {
317 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 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 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!("{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 §ion.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!("")
671 }
672
673 Block::Toc { .. } => {
674 "*Table of Contents*".to_string()
675 }
676
677 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 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 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 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 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}")); }
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(""));
1411 assert!(md.contains("*Architecture*"));
1412 }
1413
1414 #[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, "");
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 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}