1use crate::citation::{Author, Reference, RefType};
9use crate::types::{
10 Block, CalloutType, ChartType, ColumnContent, DataFormat, DecisionStatus, EmbedType, FaqItem,
11 FeatureCard, Format, FooterSection, FormField, FormFieldType, FrontMatter, GalleryItem, HeroButton,
12 HttpMethod, ListDisplay, NavItem, RowState, SocialLink, Span, StatItem, StepItem,
13 StyleProperty, SurfDoc, TabPanel, TaskItem, Trend,
14};
15
16pub struct SurfDocBuilder {
37 front_matter: Option<FrontMatter>,
38 blocks: Vec<Block>,
39}
40
41impl Default for SurfDocBuilder {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl SurfDocBuilder {
48 pub fn new() -> Self {
50 SurfDocBuilder {
51 front_matter: None,
52 blocks: Vec::new(),
53 }
54 }
55
56 pub fn title(mut self, title: &str) -> Self {
60 self.ensure_front_matter().title = Some(title.to_string());
61 self
62 }
63
64 pub fn doc_type(mut self, dt: crate::types::DocType) -> Self {
66 self.ensure_front_matter().doc_type = Some(dt);
67 self
68 }
69
70 pub fn status(mut self, s: crate::types::DocStatus) -> Self {
72 self.ensure_front_matter().status = Some(s);
73 self
74 }
75
76 pub fn author(mut self, author: &str) -> Self {
78 self.ensure_front_matter().author = Some(author.to_string());
79 self
80 }
81
82 pub fn tags(mut self, tags: Vec<String>) -> Self {
84 self.ensure_front_matter().tags = Some(tags);
85 self
86 }
87
88 pub fn description(mut self, desc: &str) -> Self {
90 self.ensure_front_matter().description = Some(desc.to_string());
91 self
92 }
93
94 pub fn front_matter(mut self, fm: FrontMatter) -> Self {
96 self.front_matter = Some(fm);
97 self
98 }
99
100 pub fn markdown(mut self, content: &str) -> Self {
104 self.blocks.push(Block::Markdown {
105 content: content.to_string(),
106 span: Span::SYNTHETIC,
107 });
108 self
109 }
110
111 pub fn heading(mut self, level: u8, text: &str) -> Self {
113 let prefix = "#".repeat(level as usize);
114 self.blocks.push(Block::Markdown {
115 content: format!("{prefix} {text}"),
116 span: Span::SYNTHETIC,
117 });
118 self
119 }
120
121 pub fn callout(mut self, callout_type: CalloutType, content: &str) -> Self {
123 self.blocks.push(Block::Callout {
124 callout_type,
125 title: None,
126 content: content.to_string(),
127 span: Span::SYNTHETIC,
128 });
129 self
130 }
131
132 pub fn callout_titled(
134 mut self,
135 callout_type: CalloutType,
136 title: &str,
137 content: &str,
138 ) -> Self {
139 self.blocks.push(Block::Callout {
140 callout_type,
141 title: Some(title.to_string()),
142 content: content.to_string(),
143 span: Span::SYNTHETIC,
144 });
145 self
146 }
147
148 pub fn code(mut self, content: &str, lang: Option<&str>) -> Self {
150 self.blocks.push(Block::Code {
151 lang: lang.map(|s| s.to_string()),
152 file: None,
153 highlight: vec![],
154 content: content.to_string(),
155 span: Span::SYNTHETIC,
156 });
157 self
158 }
159
160 pub fn code_file(mut self, content: &str, lang: &str, file: &str) -> Self {
162 self.blocks.push(Block::Code {
163 lang: Some(lang.to_string()),
164 file: Some(file.to_string()),
165 highlight: vec![],
166 content: content.to_string(),
167 span: Span::SYNTHETIC,
168 });
169 self
170 }
171
172 pub fn data_table(mut self, headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
174 let mut raw_lines = Vec::new();
176 if !headers.is_empty() {
177 raw_lines.push(format!("| {} |", headers.join(" | ")));
178 let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
179 raw_lines.push(format!("| {} |", sep.join(" | ")));
180 }
181 for row in &rows {
182 raw_lines.push(format!("| {} |", row.join(" | ")));
183 }
184 let raw_content = raw_lines.join("\n");
185
186 self.blocks.push(Block::Data {
187 id: None,
188 format: DataFormat::Table,
189 sortable: false,
190 headers,
191 rows,
192 raw_content,
193 span: Span::SYNTHETIC,
194 });
195 self
196 }
197
198 pub fn task(mut self, text: &str, done: bool) -> Self {
200 if let Some(Block::Tasks { items, .. }) = self.blocks.last_mut() {
202 items.push(TaskItem {
203 done,
204 text: text.to_string(),
205 assignee: None,
206 });
207 } else {
208 self.blocks.push(Block::Tasks {
209 items: vec![TaskItem {
210 done,
211 text: text.to_string(),
212 assignee: None,
213 }],
214 span: Span::SYNTHETIC,
215 });
216 }
217 self
218 }
219
220 pub fn tasks(mut self, items: Vec<TaskItem>) -> Self {
222 self.blocks.push(Block::Tasks {
223 items,
224 span: Span::SYNTHETIC,
225 });
226 self
227 }
228
229 pub fn decision(mut self, status: DecisionStatus, content: &str) -> Self {
231 self.blocks.push(Block::Decision {
232 status,
233 date: None,
234 deciders: vec![],
235 content: content.to_string(),
236 span: Span::SYNTHETIC,
237 });
238 self
239 }
240
241 pub fn metric(mut self, label: &str, value: &str) -> Self {
243 self.blocks.push(Block::Metric {
244 label: label.to_string(),
245 value: value.to_string(),
246 trend: None,
247 unit: None,
248 span: Span::SYNTHETIC,
249 });
250 self
251 }
252
253 pub fn metric_with_trend(
255 mut self,
256 label: &str,
257 value: &str,
258 trend: Trend,
259 unit: Option<&str>,
260 ) -> Self {
261 self.blocks.push(Block::Metric {
262 label: label.to_string(),
263 value: value.to_string(),
264 trend: Some(trend),
265 unit: unit.map(|s| s.to_string()),
266 span: Span::SYNTHETIC,
267 });
268 self
269 }
270
271 pub fn summary(mut self, content: &str) -> Self {
273 self.blocks.push(Block::Summary {
274 content: content.to_string(),
275 span: Span::SYNTHETIC,
276 });
277 self
278 }
279
280 pub fn figure(mut self, src: &str) -> Self {
282 self.blocks.push(Block::Figure {
283 src: src.to_string(),
284 caption: None,
285 alt: None,
286 width: None,
287 span: Span::SYNTHETIC,
288 });
289 self
290 }
291
292 pub fn figure_with_caption(mut self, src: &str, caption: &str, alt: Option<&str>) -> Self {
294 self.blocks.push(Block::Figure {
295 src: src.to_string(),
296 caption: Some(caption.to_string()),
297 alt: alt.map(|s| s.to_string()),
298 width: None,
299 span: Span::SYNTHETIC,
300 });
301 self
302 }
303
304 pub fn quote(mut self, content: &str) -> Self {
306 self.blocks.push(Block::Quote {
307 content: content.to_string(),
308 attribution: None,
309 cite: None,
310 span: Span::SYNTHETIC,
311 });
312 self
313 }
314
315 pub fn quote_attributed(mut self, content: &str, attribution: &str) -> Self {
317 self.blocks.push(Block::Quote {
318 content: content.to_string(),
319 attribution: Some(attribution.to_string()),
320 cite: None,
321 span: Span::SYNTHETIC,
322 });
323 self
324 }
325
326 pub fn cta(mut self, label: &str, href: &str, primary: bool) -> Self {
328 self.blocks.push(Block::Cta {
329 label: label.to_string(),
330 href: href.to_string(),
331 primary,
332 icon: None,
333 span: Span::SYNTHETIC,
334 });
335 self
336 }
337
338 pub fn hero_image(mut self, src: &str, alt: Option<&str>) -> Self {
340 self.blocks.push(Block::HeroImage {
341 src: src.to_string(),
342 alt: alt.map(|s| s.to_string()),
343 span: Span::SYNTHETIC,
344 });
345 self
346 }
347
348 pub fn testimonial(
350 mut self,
351 content: &str,
352 author: Option<&str>,
353 role: Option<&str>,
354 company: Option<&str>,
355 ) -> Self {
356 self.blocks.push(Block::Testimonial {
357 content: content.to_string(),
358 author: author.map(|s| s.to_string()),
359 role: role.map(|s| s.to_string()),
360 company: company.map(|s| s.to_string()),
361 span: Span::SYNTHETIC,
362 });
363 self
364 }
365
366 pub fn style(mut self, properties: Vec<StyleProperty>) -> Self {
368 self.blocks.push(Block::Style {
369 properties,
370 span: Span::SYNTHETIC,
371 });
372 self
373 }
374
375 pub fn faq(mut self, items: Vec<FaqItem>) -> Self {
377 self.blocks.push(Block::Faq {
378 items,
379 span: Span::SYNTHETIC,
380 });
381 self
382 }
383
384 pub fn pricing_table(mut self, headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
386 self.blocks.push(Block::PricingTable {
387 headers,
388 rows,
389 span: Span::SYNTHETIC,
390 });
391 self
392 }
393
394 pub fn site(mut self, domain: Option<&str>, properties: Vec<StyleProperty>) -> Self {
396 self.blocks.push(Block::Site {
397 domain: domain.map(|s| s.to_string()),
398 properties,
399 span: Span::SYNTHETIC,
400 });
401 self
402 }
403
404 pub fn page(
406 mut self,
407 route: &str,
408 layout: Option<&str>,
409 title: Option<&str>,
410 content: &str,
411 ) -> Self {
412 let children = Vec::new(); self.blocks.push(Block::Page {
415 route: route.to_string(),
416 layout: layout.map(|s| s.to_string()),
417 title: title.map(|s| s.to_string()),
418 sidebar: false,
419 content: content.to_string(),
420 children,
421 span: Span::SYNTHETIC,
422 });
423 self
424 }
425
426 pub fn nav(mut self, items: Vec<NavItem>, logo: Option<&str>) -> Self {
428 self.blocks.push(Block::Nav {
429 items,
430 logo: logo.map(|s| s.to_string()),
431 groups: Vec::new(),
432 brand: None,
433 brand_reg: false,
434 cta: None,
435 drawer: false,
436 minimal: false,
437 span: Span::SYNTHETIC,
438 });
439 self
440 }
441
442 pub fn embed(
444 mut self,
445 src: &str,
446 embed_type: Option<EmbedType>,
447 title: Option<&str>,
448 ) -> Self {
449 self.blocks.push(Block::Embed {
450 src: src.to_string(),
451 embed_type,
452 width: None,
453 height: None,
454 title: title.map(|s| s.to_string()),
455 span: Span::SYNTHETIC,
456 });
457 self
458 }
459
460 pub fn form(mut self, fields: Vec<FormField>, submit_label: Option<&str>) -> Self {
462 self.blocks.push(Block::Form {
463 fields,
464 submit_label: submit_label.map(|s| s.to_string()),
465 action: None,
466 method: None,
467 honeypot: false,
468 span: Span::SYNTHETIC,
469 });
470 self
471 }
472
473 pub fn gallery(mut self, items: Vec<GalleryItem>, columns: Option<u32>) -> Self {
475 self.blocks.push(Block::Gallery {
476 items,
477 columns,
478 span: Span::SYNTHETIC,
479 });
480 self
481 }
482
483 pub fn footer(
485 mut self,
486 sections: Vec<FooterSection>,
487 copyright: Option<&str>,
488 social: Vec<SocialLink>,
489 ) -> Self {
490 self.blocks.push(Block::Footer {
491 sections,
492 copyright: copyright.map(|s| s.to_string()),
493 social,
494 brand: None,
495 brand_reg: false,
496 brand_logo: None,
497 tagline: None,
498 span: Span::SYNTHETIC,
499 });
500 self
501 }
502
503 pub fn tabs(mut self, tabs: Vec<TabPanel>) -> Self {
505 self.blocks.push(Block::Tabs {
506 tabs,
507 span: Span::SYNTHETIC,
508 });
509 self
510 }
511
512 pub fn columns(mut self, columns: Vec<ColumnContent>) -> Self {
514 self.blocks.push(Block::Columns {
515 columns,
516 span: Span::SYNTHETIC,
517 });
518 self
519 }
520
521 pub fn hero(
523 mut self,
524 headline: Option<&str>,
525 subtitle: Option<&str>,
526 badge: Option<&str>,
527 buttons: Vec<HeroButton>,
528 ) -> Self {
529 self.blocks.push(Block::Hero {
530 headline: headline.map(|s| s.to_string()),
531 subtitle: subtitle.map(|s| s.to_string()),
532 badge: badge.map(|s| s.to_string()),
533 align: "center".to_string(),
534 image: None,
535 image_alt: None,
536 layout: None,
537 transparent: false,
538 buttons,
539 content: String::new(),
540 span: Span::SYNTHETIC,
541 });
542 self
543 }
544
545 pub fn features(mut self, cards: Vec<FeatureCard>, cols: Option<u32>) -> Self {
547 self.blocks.push(Block::Features {
548 cards,
549 cols,
550 span: Span::SYNTHETIC,
551 });
552 self
553 }
554
555 pub fn steps(mut self, steps: Vec<StepItem>) -> Self {
557 self.blocks.push(Block::Steps {
558 steps,
559 span: Span::SYNTHETIC,
560 });
561 self
562 }
563
564 pub fn stats(mut self, items: Vec<StatItem>) -> Self {
566 self.blocks.push(Block::Stats {
567 items,
568 span: Span::SYNTHETIC,
569 });
570 self
571 }
572
573 pub fn comparison(
575 mut self,
576 headers: Vec<String>,
577 rows: Vec<Vec<String>>,
578 highlight: Option<&str>,
579 ) -> Self {
580 self.blocks.push(Block::Comparison {
581 headers,
582 rows,
583 highlight: highlight.map(|s| s.to_string()),
584 span: Span::SYNTHETIC,
585 });
586 self
587 }
588
589 pub fn logo(mut self, src: &str, alt: Option<&str>, size: Option<u32>) -> Self {
591 self.blocks.push(Block::Logo {
592 src: src.to_string(),
593 alt: alt.map(|s| s.to_string()),
594 size,
595 span: Span::SYNTHETIC,
596 });
597 self
598 }
599
600 pub fn toc(mut self, depth: u32) -> Self {
602 self.blocks.push(Block::Toc {
603 depth,
604 entries: Vec::new(),
605 span: Span::SYNTHETIC,
606 });
607 self
608 }
609
610 pub fn before_after(
612 mut self,
613 before_items: Vec<crate::types::BeforeAfterItem>,
614 after_items: Vec<crate::types::BeforeAfterItem>,
615 transition: Option<&str>,
616 ) -> Self {
617 self.blocks.push(Block::BeforeAfter {
618 before_items,
619 after_items,
620 transition: transition.map(|s| s.to_string()),
621 span: Span::SYNTHETIC,
622 });
623 self
624 }
625
626 pub fn pipeline(mut self, steps: Vec<crate::types::PipelineStep>) -> Self {
628 self.blocks.push(Block::Pipeline {
629 steps,
630 span: Span::SYNTHETIC,
631 });
632 self
633 }
634
635 pub fn section(
637 mut self,
638 bg: Option<&str>,
639 headline: Option<&str>,
640 subtitle: Option<&str>,
641 content: &str,
642 ) -> Self {
643 self.blocks.push(Block::Section {
644 bg: bg.map(|s| s.to_string()),
645 headline: headline.map(|s| s.to_string()),
646 subtitle: subtitle.map(|s| s.to_string()),
647 content: content.to_string(),
648 children: Vec::new(),
649 span: Span::SYNTHETIC,
650 });
651 self
652 }
653
654 #[allow(clippy::too_many_arguments)]
656 pub fn product_card(
657 mut self,
658 title: &str,
659 subtitle: Option<&str>,
660 badge: Option<&str>,
661 badge_color: Option<&str>,
662 body: &str,
663 features: Vec<String>,
664 cta_label: Option<&str>,
665 cta_href: Option<&str>,
666 ) -> Self {
667 self.blocks.push(Block::ProductCard {
668 title: title.to_string(),
669 subtitle: subtitle.map(|s| s.to_string()),
670 badge: badge.map(|s| s.to_string()),
671 badge_color: badge_color.map(|s| s.to_string()),
672 body: body.to_string(),
673 features,
674 cta_label: cta_label.map(|s| s.to_string()),
675 cta_href: cta_href.map(|s| s.to_string()),
676 span: Span::SYNTHETIC,
677 });
678 self
679 }
680
681 pub fn app(mut self, name: &str, binary: Option<&str>, region: Option<&str>, port: Option<u32>, children: Vec<Block>) -> Self {
683 self.blocks.push(Block::App {
684 name: name.to_string(),
685 binary: binary.map(|s| s.to_string()),
686 region: region.map(|s| s.to_string()),
687 port,
688 platform: None,
689 auth: None,
690 content: String::new(),
691 children,
692 span: Span::SYNTHETIC,
693 });
694 self
695 }
696
697 pub fn deploy(mut self, env: &str, app: Option<&str>, machines: Option<u32>, memory: Option<u32>) -> Self {
699 self.blocks.push(Block::Deploy {
700 env: Some(env.to_string()),
701 app: app.map(|s| s.to_string()),
702 machines,
703 memory,
704 auto_stop: None,
705 min_machines: None,
706 strategy: None,
707 properties: Vec::new(),
708 span: Span::SYNTHETIC,
709 });
710 self
711 }
712
713 pub fn infra_database(mut self, name: Option<&str>, shared_auth: bool) -> Self {
715 self.blocks.push(Block::InfraDatabase {
716 name: name.map(|s| s.to_string()),
717 shared_auth,
718 volume_gb: None,
719 properties: Vec::new(),
720 span: Span::SYNTHETIC,
721 });
722 self
723 }
724
725 pub fn build(self) -> SurfDoc {
727 let source = to_surf_source_inner(&self.front_matter, &self.blocks);
728 SurfDoc {
729 front_matter: self.front_matter,
730 blocks: self.blocks,
731 source,
732 }
733 }
734
735 fn ensure_front_matter(&mut self) -> &mut FrontMatter {
738 if self.front_matter.is_none() {
739 self.front_matter = Some(FrontMatter::default());
740 }
741 self.front_matter.as_mut().unwrap()
742 }
743}
744
745pub fn to_surf_source(doc: &SurfDoc) -> String {
754 to_surf_source_inner(&doc.front_matter, &doc.blocks)
755}
756
757fn to_surf_source_inner(front_matter: &Option<FrontMatter>, blocks: &[Block]) -> String {
758 let mut out = String::new();
759
760 if let Some(fm) = front_matter {
762 out.push_str(&serialize_front_matter(fm));
763 }
764
765 for (i, block) in blocks.iter().enumerate() {
767 if i > 0 || front_matter.is_some() {
768 out.push('\n');
769 }
770 out.push_str(&serialize_block(block));
771 out.push('\n');
772 }
773
774 out
775}
776
777fn serialize_front_matter(fm: &FrontMatter) -> String {
782 let mut lines = Vec::new();
783 lines.push("---".to_string());
784
785 if let Some(title) = &fm.title {
786 lines.push(format!("title: \"{}\"", escape_yaml_string(title)));
787 }
788 if let Some(dt) = &fm.doc_type {
789 lines.push(format!("type: {}", doc_type_str(*dt)));
790 }
791 if let Some(s) = &fm.status {
792 lines.push(format!("status: {}", doc_status_str(*s)));
793 }
794 if let Some(scope) = &fm.scope {
795 lines.push(format!("scope: {}", scope_str(*scope)));
796 }
797 if let Some(tags) = &fm.tags {
798 let tag_strs: Vec<String> = tags.iter().cloned().collect();
799 lines.push(format!("tags: [{}]", tag_strs.join(", ")));
800 }
801 if let Some(created) = &fm.created {
802 lines.push(format!("created: \"{}\"", escape_yaml_string(created)));
803 }
804 if let Some(updated) = &fm.updated {
805 lines.push(format!("updated: \"{}\"", escape_yaml_string(updated)));
806 }
807 if let Some(author) = &fm.author {
808 lines.push(format!("author: \"{}\"", escape_yaml_string(author)));
809 }
810 if let Some(confidence) = &fm.confidence {
811 lines.push(format!("confidence: {}", confidence_str(*confidence)));
812 }
813 if let Some(version) = &fm.version {
814 lines.push(format!("version: {version}"));
815 }
816 if let Some(contributors) = &fm.contributors {
817 let cs: Vec<String> = contributors.iter().map(|c| format!("\"{}\"", escape_yaml_string(c))).collect();
818 lines.push(format!("contributors: [{}]", cs.join(", ")));
819 }
820 if let Some(description) = &fm.description {
821 lines.push(format!("description: \"{}\"", escape_yaml_string(description)));
822 }
823 if let Some(workspace) = &fm.workspace {
824 lines.push(format!("workspace: \"{}\"", escape_yaml_string(workspace)));
825 }
826 if let Some(decision) = &fm.decision {
827 lines.push(format!("decision: \"{}\"", escape_yaml_string(decision)));
828 }
829 if let Some(related) = &fm.related
830 && !related.is_empty() {
831 lines.push("related:".to_string());
832 for r in related {
833 let mut entry = format!(" - path: \"{}\"", escape_yaml_string(&r.path));
834 if let Some(rel) = &r.relationship {
835 entry = format!(
836 " - path: \"{}\"\n relationship: {}",
837 escape_yaml_string(&r.path),
838 relationship_str(*rel)
839 );
840 }
841 lines.push(entry);
842 }
843 }
844 for (key, value) in &fm.extra {
846 lines.push(format!("{key}: {}", serde_yaml_value_to_inline(value)));
847 }
848
849 lines.push("---".to_string());
850 let mut result = lines.join("\n");
851 result.push('\n');
852 result
853}
854
855fn escape_yaml_string(s: &str) -> String {
856 s.replace('\\', "\\\\").replace('"', "\\\"")
857}
858
859fn doc_type_str(dt: crate::types::DocType) -> &'static str {
860 use crate::types::DocType;
861 match dt {
862 DocType::Doc => "doc",
863 DocType::Guide => "guide",
864 DocType::Conversation => "conversation",
865 DocType::Plan => "plan",
866 DocType::Agent => "agent",
867 DocType::Preference => "preference",
868 DocType::Report => "report",
869 DocType::Proposal => "proposal",
870 DocType::Incident => "incident",
871 DocType::Review => "review",
872 DocType::App => "app",
873 DocType::Manifest => "manifest",
874 DocType::Website => "website",
875 DocType::Web => "web",
876 DocType::Deck => "deck",
877 DocType::Slides => "slides",
878 DocType::Presentation => "presentation",
879 DocType::Paper => "paper",
880 }
881}
882
883fn doc_status_str(s: crate::types::DocStatus) -> &'static str {
884 use crate::types::DocStatus;
885 match s {
886 DocStatus::Draft => "draft",
887 DocStatus::Active => "active",
888 DocStatus::Closed => "closed",
889 DocStatus::Archived => "archived",
890 }
891}
892
893fn scope_str(s: crate::types::Scope) -> &'static str {
894 use crate::types::Scope;
895 match s {
896 Scope::Personal => "personal",
897 Scope::WorkspacePrivate => "workspace-private",
898 Scope::Workspace => "workspace",
899 Scope::Repo => "repo",
900 Scope::Public => "public",
901 }
902}
903
904fn confidence_str(c: crate::types::Confidence) -> &'static str {
905 use crate::types::Confidence;
906 match c {
907 Confidence::Low => "low",
908 Confidence::Medium => "medium",
909 Confidence::High => "high",
910 }
911}
912
913fn relationship_str(r: crate::types::Relationship) -> &'static str {
914 use crate::types::Relationship;
915 match r {
916 Relationship::Produces => "produces",
917 Relationship::Consumes => "consumes",
918 Relationship::References => "references",
919 Relationship::Supersedes => "supersedes",
920 }
921}
922
923fn serde_yaml_value_to_inline(value: &serde_yaml::Value) -> String {
924 match value {
925 serde_yaml::Value::String(s) => format!("\"{}\"", escape_yaml_string(s)),
926 serde_yaml::Value::Number(n) => n.to_string(),
927 serde_yaml::Value::Bool(b) => b.to_string(),
928 serde_yaml::Value::Null => "null".to_string(),
929 _ => serde_yaml::to_string(value).unwrap_or_default().trim().to_string(),
930 }
931}
932
933fn serialize_block(block: &Block) -> String {
938 match block {
939 Block::Markdown { content, .. } => {
940 let trimmed = content.trim_matches('\n');
944 trimmed.to_string()
945 }
946
947 Block::Callout {
948 callout_type,
949 title,
950 content,
951 ..
952 } => {
953 let type_str = callout_type_str(*callout_type);
954 let attrs = match title {
955 Some(t) => format!("[type={type_str} title=\"{}\"]", escape_attr(t)),
956 None => format!("[type={type_str}]"),
957 };
958 if content.is_empty() {
959 format!("::callout{attrs}\n::")
960 } else {
961 format!("::callout{attrs}\n{content}\n::")
962 }
963 }
964
965 Block::Diagram {
966 diagram_type,
967 title,
968 content,
969 ..
970 } => {
971 let mut attr_parts = Vec::new();
972 if !diagram_type.is_empty() {
973 attr_parts.push(format!("type={diagram_type}"));
974 }
975 if let Some(t) = title {
976 attr_parts.push(format!("title=\"{}\"", escape_attr(t)));
977 }
978 let attrs = if attr_parts.is_empty() {
979 String::new()
980 } else {
981 format!("[{}]", attr_parts.join(" "))
982 };
983 if content.is_empty() {
984 format!("::diagram{attrs}\n::")
985 } else {
986 format!("::diagram{attrs}\n{content}\n::")
987 }
988 }
989
990 Block::Data {
991 id,
992 format,
993 sortable,
994 headers,
995 rows,
996 ..
997 } => {
998 let mut attr_parts = Vec::new();
999 if let Some(id) = id {
1000 attr_parts.push(format!("id=\"{}\"", escape_attr(id)));
1001 }
1002 let fmt = match format {
1003 DataFormat::Table => "table",
1004 DataFormat::Csv => "csv",
1005 DataFormat::Json => "json",
1006 };
1007 attr_parts.push(format!("format={fmt}"));
1008 if *sortable {
1009 attr_parts.push("sortable".to_string());
1010 }
1011 let attrs = format!("[{}]", attr_parts.join(" "));
1012
1013 let mut content_lines = Vec::new();
1014 if !headers.is_empty() {
1015 content_lines.push(format!("| {} |", headers.join(" | ")));
1016 let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
1017 content_lines.push(format!("| {} |", sep.join(" | ")));
1018 }
1019 for row in rows {
1020 content_lines.push(format!("| {} |", row.join(" | ")));
1021 }
1022 let content = content_lines.join("\n");
1023 if content.is_empty() {
1024 format!("::data{attrs}\n::")
1025 } else {
1026 format!("::data{attrs}\n{content}\n::")
1027 }
1028 }
1029
1030 Block::Code {
1031 lang,
1032 file,
1033 content,
1034 highlight,
1035 ..
1036 } => {
1037 let mut attr_parts = Vec::new();
1038 if let Some(l) = lang {
1039 attr_parts.push(format!("lang={l}"));
1040 }
1041 if let Some(f) = file {
1042 attr_parts.push(format!("file=\"{}\"", escape_attr(f)));
1043 }
1044 if !highlight.is_empty() {
1045 attr_parts.push(format!("highlight=\"{}\"", highlight.join(",")));
1046 }
1047 let attrs = if attr_parts.is_empty() {
1048 String::new()
1049 } else {
1050 format!("[{}]", attr_parts.join(" "))
1051 };
1052 if content.is_empty() {
1053 format!("::code{attrs}\n::")
1054 } else {
1055 format!("::code{attrs}\n{content}\n::")
1056 }
1057 }
1058
1059 Block::Tasks { items, .. } => {
1060 let mut lines = Vec::new();
1061 for item in items {
1062 let check = if item.done { "x" } else { " " };
1063 match &item.assignee {
1064 Some(a) => lines.push(format!("- [{check}] {} @{a}", item.text)),
1065 None => lines.push(format!("- [{check}] {}", item.text)),
1066 }
1067 }
1068 let content = lines.join("\n");
1069 format!("::tasks\n{content}\n::")
1070 }
1071
1072 Block::Decision {
1073 status,
1074 date,
1075 deciders,
1076 content,
1077 ..
1078 } => {
1079 let mut attr_parts = Vec::new();
1080 attr_parts.push(format!("status={}", decision_status_str(*status)));
1081 if let Some(d) = date {
1082 attr_parts.push(format!("date=\"{}\"", escape_attr(d)));
1083 }
1084 if !deciders.is_empty() {
1085 attr_parts.push(format!("deciders=\"{}\"", deciders.join(",")));
1086 }
1087 let attrs = format!("[{}]", attr_parts.join(" "));
1088 if content.is_empty() {
1089 format!("::decision{attrs}\n::")
1090 } else {
1091 format!("::decision{attrs}\n{content}\n::")
1092 }
1093 }
1094
1095 Block::Metric {
1096 label,
1097 value,
1098 trend,
1099 unit,
1100 ..
1101 } => {
1102 let mut attr_parts = Vec::new();
1103 attr_parts.push(format!("label=\"{}\"", escape_attr(label)));
1104 attr_parts.push(format!("value=\"{}\"", escape_attr(value)));
1105 if let Some(t) = trend {
1106 attr_parts.push(format!("trend={}", trend_str(*t)));
1107 }
1108 if let Some(u) = unit {
1109 attr_parts.push(format!("unit=\"{}\"", escape_attr(u)));
1110 }
1111 let attrs = format!("[{}]", attr_parts.join(" "));
1112 format!("::metric{attrs}\n::")
1113 }
1114
1115 Block::Summary { content, .. } => {
1116 if content.is_empty() {
1117 "::summary\n::".to_string()
1118 } else {
1119 format!("::summary\n{content}\n::")
1120 }
1121 }
1122
1123 Block::Cite { reference, .. } => cite_to_surf(reference),
1124
1125 Block::Bibliography { style, .. } => match style {
1126 Some(s) => format!("::bibliography[style={}]\n::", citation_format_slug(*s)),
1127 None => "::bibliography\n::".to_string(),
1128 },
1129
1130 Block::Figure {
1131 src,
1132 caption,
1133 alt,
1134 width,
1135 ..
1136 } => {
1137 let mut attr_parts = Vec::new();
1138 attr_parts.push(format!("src=\"{}\"", escape_attr(src)));
1139 if let Some(c) = caption {
1140 attr_parts.push(format!("caption=\"{}\"", escape_attr(c)));
1141 }
1142 if let Some(a) = alt {
1143 attr_parts.push(format!("alt=\"{}\"", escape_attr(a)));
1144 }
1145 if let Some(w) = width {
1146 attr_parts.push(format!("width=\"{}\"", escape_attr(w)));
1147 }
1148 let attrs = format!("[{}]", attr_parts.join(" "));
1149 format!("::figure{attrs}\n::")
1150 }
1151
1152 Block::Tabs { tabs, .. } => {
1153 let mut content_parts = Vec::new();
1154 for tab in tabs {
1155 content_parts.push(format!("## {}", tab.label));
1156 if !tab.content.is_empty() {
1157 content_parts.push(tab.content.clone());
1158 }
1159 }
1160 let content = content_parts.join("\n");
1161 format!("::tabs\n{content}\n::")
1162 }
1163
1164 Block::Columns { columns, .. } => {
1165 let mut content_parts = Vec::new();
1166 for col in columns {
1167 content_parts.push(":::column".to_string());
1168 content_parts.push(col.content.clone());
1169 content_parts.push(":::".to_string());
1170 }
1171 let content = content_parts.join("\n");
1172 format!("::columns\n{content}\n::")
1173 }
1174
1175 Block::Quote {
1176 content,
1177 attribution,
1178 cite,
1179 ..
1180 } => {
1181 let mut attr_parts = Vec::new();
1182 if let Some(a) = attribution {
1183 attr_parts.push(format!("by=\"{}\"", escape_attr(a)));
1184 }
1185 if let Some(c) = cite {
1186 attr_parts.push(format!("cite=\"{}\"", escape_attr(c)));
1187 }
1188 let attrs = if attr_parts.is_empty() {
1189 String::new()
1190 } else {
1191 format!("[{}]", attr_parts.join(" "))
1192 };
1193 if content.is_empty() {
1194 format!("::quote{attrs}\n::")
1195 } else {
1196 format!("::quote{attrs}\n{content}\n::")
1197 }
1198 }
1199
1200 Block::Cta {
1201 label,
1202 href,
1203 primary,
1204 icon,
1205 ..
1206 } => {
1207 let mut attr_parts = Vec::new();
1208 attr_parts.push(format!("label=\"{}\"", escape_attr(label)));
1209 attr_parts.push(format!("href=\"{}\"", escape_attr(href)));
1210 if *primary {
1211 attr_parts.push("primary".to_string());
1212 }
1213 if let Some(i) = icon {
1214 attr_parts.push(format!("icon=\"{}\"", escape_attr(i)));
1215 }
1216 let attrs = format!("[{}]", attr_parts.join(" "));
1217 format!("::cta{attrs}\n::")
1218 }
1219
1220 Block::Nav { items, logo, groups, brand, brand_reg, cta, drawer, .. } => {
1221 let mut attr_parts = Vec::new();
1222 if *drawer {
1223 attr_parts.push("drawer".to_string());
1224 }
1225 if let Some(b) = brand {
1226 attr_parts.push(format!("brand=\"{}\"", escape_attr(b)));
1227 }
1228 if *brand_reg {
1229 attr_parts.push("reg".to_string());
1230 }
1231 if let Some(l) = logo {
1232 attr_parts.push(format!("logo=\"{}\"", escape_attr(l)));
1233 }
1234 if let Some(c) = cta {
1235 attr_parts.push(format!("cta-label=\"{}\"", escape_attr(&c.label)));
1236 attr_parts.push(format!("cta-href=\"{}\"", escape_attr(&c.href)));
1237 }
1238 let attrs = if attr_parts.is_empty() {
1239 String::new()
1240 } else {
1241 format!("[{}]", attr_parts.join(" "))
1242 };
1243 let mut content_lines = Vec::new();
1244 for item in items {
1245 content_lines.push(serialize_nav_item(item));
1246 }
1247 for g in groups {
1248 if let Some(label) = &g.label {
1249 content_lines.push(format!("## {}", label));
1250 }
1251 for item in &g.items {
1252 content_lines.push(serialize_nav_item(item));
1253 }
1254 }
1255 let content = content_lines.join("\n");
1256 if content.is_empty() {
1257 format!("::nav{attrs}\n::")
1258 } else {
1259 format!("::nav{attrs}\n{content}\n::")
1260 }
1261 }
1262
1263 Block::HeroImage { src, alt, .. } => {
1264 let mut attr_parts = Vec::new();
1265 attr_parts.push(format!("src=\"{}\"", escape_attr(src)));
1266 if let Some(a) = alt {
1267 attr_parts.push(format!("alt=\"{}\"", escape_attr(a)));
1268 }
1269 let attrs = format!("[{}]", attr_parts.join(" "));
1270 format!("::hero-image{attrs}\n::")
1271 }
1272
1273 Block::Testimonial {
1274 content,
1275 author,
1276 role,
1277 company,
1278 ..
1279 } => {
1280 let mut attr_parts = Vec::new();
1281 if let Some(a) = author {
1282 attr_parts.push(format!("author=\"{}\"", escape_attr(a)));
1283 }
1284 if let Some(r) = role {
1285 attr_parts.push(format!("role=\"{}\"", escape_attr(r)));
1286 }
1287 if let Some(c) = company {
1288 attr_parts.push(format!("company=\"{}\"", escape_attr(c)));
1289 }
1290 let attrs = if attr_parts.is_empty() {
1291 String::new()
1292 } else {
1293 format!("[{}]", attr_parts.join(" "))
1294 };
1295 if content.is_empty() {
1296 format!("::testimonial{attrs}\n::")
1297 } else {
1298 format!("::testimonial{attrs}\n{content}\n::")
1299 }
1300 }
1301
1302 Block::Style { properties, .. } => {
1303 let mut lines = Vec::new();
1304 for p in properties {
1305 lines.push(format!("{}: {}", p.key, p.value));
1306 }
1307 let content = lines.join("\n");
1308 if content.is_empty() {
1309 "::style\n::".to_string()
1310 } else {
1311 format!("::style\n{content}\n::")
1312 }
1313 }
1314
1315 Block::Faq { items, .. } => {
1316 let mut content_parts = Vec::new();
1317 for item in items {
1318 content_parts.push(format!("### {}", item.question));
1319 if !item.answer.is_empty() {
1320 content_parts.push(item.answer.clone());
1321 }
1322 }
1323 let content = content_parts.join("\n");
1324 format!("::faq\n{content}\n::")
1325 }
1326
1327 Block::PricingTable { headers, rows, .. } => {
1328 let mut content_lines = Vec::new();
1329 if !headers.is_empty() {
1330 content_lines.push(format!("| {} |", headers.join(" | ")));
1331 let sep: Vec<&str> = headers.iter().map(|_| "---").collect();
1332 content_lines.push(format!("| {} |", sep.join(" | ")));
1333 }
1334 for row in rows {
1335 content_lines.push(format!("| {} |", row.join(" | ")));
1336 }
1337 let content = content_lines.join("\n");
1338 if content.is_empty() {
1339 "::pricing-table\n::".to_string()
1340 } else {
1341 format!("::pricing-table\n{content}\n::")
1342 }
1343 }
1344
1345 Block::Site {
1346 domain,
1347 properties,
1348 ..
1349 } => {
1350 let mut attr_parts = Vec::new();
1351 if let Some(d) = domain {
1352 attr_parts.push(format!("domain=\"{}\"", escape_attr(d)));
1353 }
1354 let attrs = if attr_parts.is_empty() {
1355 String::new()
1356 } else {
1357 format!("[{}]", attr_parts.join(" "))
1358 };
1359 let mut content_lines = Vec::new();
1360 for p in properties {
1361 content_lines.push(format!("{}: {}", p.key, p.value));
1362 }
1363 let content = content_lines.join("\n");
1364 if content.is_empty() {
1365 format!("::site{attrs}\n::")
1366 } else {
1367 format!("::site{attrs}\n{content}\n::")
1368 }
1369 }
1370
1371 Block::Page {
1372 route,
1373 layout,
1374 title,
1375 sidebar,
1376 content,
1377 ..
1378 } => {
1379 let mut attr_parts = Vec::new();
1380 attr_parts.push(format!("route=\"{}\"", escape_attr(route)));
1381 if let Some(l) = layout {
1382 attr_parts.push(format!("layout=\"{}\"", escape_attr(l)));
1383 }
1384 if let Some(t) = title {
1385 attr_parts.push(format!("title=\"{}\"", escape_attr(t)));
1386 }
1387 if *sidebar {
1388 attr_parts.push("sidebar".to_string());
1389 }
1390 let attrs = format!("[{}]", attr_parts.join(" "));
1391 if content.is_empty() {
1392 format!("::page{attrs}\n::")
1393 } else {
1394 format!("::page{attrs}\n{content}\n::")
1395 }
1396 }
1397
1398 Block::Deck { properties, .. } => {
1399 let mut content_lines = Vec::new();
1400 for p in properties {
1401 content_lines.push(format!("{}: {}", p.key, p.value));
1402 }
1403 let content = content_lines.join("\n");
1404 if content.is_empty() {
1405 "::deck\n::".to_string()
1406 } else {
1407 format!("::deck\n{content}\n::")
1408 }
1409 }
1410
1411 Block::Slide {
1412 layout,
1413 kicker,
1414 notes,
1415 content,
1416 ..
1417 } => {
1418 let mut attr_parts = Vec::new();
1419 if let Some(l) = layout {
1420 attr_parts.push(format!("layout={}", l.css_class()));
1421 }
1422 if let Some(k) = kicker {
1423 attr_parts.push(format!("kicker=\"{}\"", escape_attr(k)));
1424 }
1425 if let Some(n) = notes {
1426 attr_parts.push(format!("notes=\"{}\"", escape_attr(n)));
1427 }
1428 let attrs = if attr_parts.is_empty() {
1429 String::new()
1430 } else {
1431 format!("[{}]", attr_parts.join(" "))
1432 };
1433 if content.is_empty() {
1434 format!("::slide{attrs}\n::")
1435 } else {
1436 format!("::slide{attrs}\n{content}\n::")
1437 }
1438 }
1439
1440 Block::Embed {
1441 src,
1442 embed_type,
1443 width,
1444 height,
1445 title,
1446 ..
1447 } => {
1448 let mut attr_parts = Vec::new();
1449 attr_parts.push(format!("src=\"{}\"", escape_attr(src)));
1450 if let Some(et) = embed_type {
1451 attr_parts.push(format!("type={}", embed_type_str(*et)));
1452 }
1453 if let Some(w) = width {
1454 attr_parts.push(format!("width=\"{}\"", escape_attr(w)));
1455 }
1456 if let Some(h) = height {
1457 attr_parts.push(format!("height=\"{}\"", escape_attr(h)));
1458 }
1459 if let Some(t) = title {
1460 attr_parts.push(format!("title=\"{}\"", escape_attr(t)));
1461 }
1462 let attrs = format!("[{}]", attr_parts.join(" "));
1463 format!("::embed{attrs}\n::")
1464 }
1465
1466 Block::Form {
1467 fields,
1468 submit_label,
1469 action,
1470 method,
1471 honeypot,
1472 ..
1473 } => {
1474 let mut attr_parts = Vec::new();
1475 if let Some(s) = submit_label {
1476 attr_parts.push(format!("submit=\"{}\"", escape_attr(s)));
1477 }
1478 if let Some(a) = action {
1479 attr_parts.push(format!("action=\"{}\"", escape_attr(a)));
1480 }
1481 if let Some(m) = method {
1482 attr_parts.push(format!("method=\"{}\"", escape_attr(m)));
1483 }
1484 if *honeypot {
1485 attr_parts.push("honeypot".to_string());
1486 }
1487 let attrs = if attr_parts.is_empty() {
1488 String::new()
1489 } else {
1490 format!("[{}]", attr_parts.join(" "))
1491 };
1492 let mut content_lines = Vec::new();
1493 for field in fields {
1494 let req = if field.required { " *" } else { "" };
1495 let type_str = form_field_type_str(field.field_type);
1496 match field.field_type {
1497 FormFieldType::Select if !field.options.is_empty() => {
1498 content_lines.push(format!(
1499 "- {} (select: {}){req}",
1500 field.label,
1501 field.options.join(" | ")
1502 ));
1503 }
1504 _ => {
1505 if let Some(ph) = &field.placeholder {
1506 content_lines.push(format!(
1507 "- {} ({type_str}, \"{ph}\"){req}",
1508 field.label
1509 ));
1510 } else {
1511 content_lines.push(format!("- {} ({type_str}){req}", field.label));
1512 }
1513 }
1514 }
1515 }
1516 let content = content_lines.join("\n");
1517 if content.is_empty() {
1518 format!("::form{attrs}\n::")
1519 } else {
1520 format!("::form{attrs}\n{content}\n::")
1521 }
1522 }
1523
1524 Block::Gallery { items, .. } => {
1525 let mut content_lines = Vec::new();
1526 for item in items {
1527 let alt = item.alt.as_deref().unwrap_or("");
1528 let suffix = match (&item.category, &item.caption) {
1529 (Some(cat), Some(cap)) => format!(" {cat}: {cap}"),
1530 (None, Some(cap)) => format!(" {cap}"),
1531 (Some(cat), None) => format!(" {cat}:"),
1532 (None, None) => String::new(),
1533 };
1534 content_lines.push(format!("{suffix}", item.src));
1535 }
1536 let content = content_lines.join("\n");
1537 if content.is_empty() {
1538 "::gallery\n::".to_string()
1539 } else {
1540 format!("::gallery\n{content}\n::")
1541 }
1542 }
1543
1544 Block::Footer {
1545 sections,
1546 copyright,
1547 social,
1548 brand,
1549 brand_reg,
1550 brand_logo,
1551 tagline,
1552 ..
1553 } => {
1554 let mut attr_parts = Vec::new();
1555 if let Some(b) = brand {
1556 attr_parts.push(format!("brand=\"{}\"", escape_attr(b)));
1557 }
1558 if *brand_reg {
1559 attr_parts.push("reg".to_string());
1560 }
1561 if let Some(l) = brand_logo {
1562 attr_parts.push(format!("logo=\"{}\"", escape_attr(l)));
1563 }
1564 if let Some(t) = tagline {
1565 attr_parts.push(format!("tagline=\"{}\"", escape_attr(t)));
1566 }
1567 let attrs = if attr_parts.is_empty() {
1568 String::new()
1569 } else {
1570 format!("[{}]", attr_parts.join(" "))
1571 };
1572 let mut content_lines = Vec::new();
1573 for section in sections {
1574 content_lines.push(format!("## {}", section.heading));
1575 for link in §ion.links {
1576 if link.href.is_empty() {
1577 content_lines.push(format!("- {}", link.label));
1578 } else {
1579 content_lines.push(format!("- [{}]({})", link.label, link.href));
1580 }
1581 }
1582 }
1583 for link in social {
1584 content_lines.push(format!("@{} {}", link.platform, link.href));
1585 }
1586 if let Some(cr) = copyright {
1587 content_lines.push(cr.clone());
1588 }
1589 let content = content_lines.join("\n");
1590 if content.is_empty() {
1591 format!("::footer{attrs}\n::")
1592 } else {
1593 format!("::footer{attrs}\n{content}\n::")
1594 }
1595 }
1596
1597 Block::Unknown {
1598 name,
1599 attrs,
1600 content,
1601 ..
1602 } => {
1603 let attrs_str = if attrs.is_empty() {
1604 String::new()
1605 } else {
1606 format!("[{}]", serialize_attrs(attrs))
1607 };
1608 if content.is_empty() {
1609 format!("::{name}{attrs_str}\n::")
1610 } else {
1611 format!("::{name}{attrs_str}\n{content}\n::")
1612 }
1613 }
1614
1615 Block::Details { title, open, content, .. } => {
1616 let mut attrs_parts = Vec::new();
1617 if let Some(t) = title {
1618 attrs_parts.push(format!("title=\"{}\"", escape_attr(t)));
1619 }
1620 if *open {
1621 attrs_parts.push("open".to_string());
1622 }
1623 let attrs_str = if attrs_parts.is_empty() {
1624 String::new()
1625 } else {
1626 format!("[{}]", attrs_parts.join(" "))
1627 };
1628 format!("::details{attrs_str}\n{content}\n::")
1629 }
1630
1631 Block::Divider { label, .. } => match label {
1632 Some(l) => format!("::divider[label=\"{}\"]", escape_attr(l)),
1633 None => "::divider".to_string(),
1634 },
1635
1636 Block::Hero {
1637 headline,
1638 subtitle,
1639 badge,
1640 align,
1641 image,
1642 image_alt,
1643 layout,
1644 transparent,
1645 buttons,
1646 ..
1647 } => {
1648 let mut attrs_parts = Vec::new();
1649 if align != "center" {
1650 attrs_parts.push(format!("align=\"{}\"", escape_attr(align)));
1651 }
1652 if let Some(b) = badge {
1653 attrs_parts.push(format!("badge=\"{}\"", escape_attr(b)));
1654 }
1655 if let Some(img) = image {
1656 attrs_parts.push(format!("image=\"{}\"", escape_attr(img)));
1657 }
1658 if let Some(a) = image_alt {
1659 attrs_parts.push(format!("image-alt=\"{}\"", escape_attr(a)));
1660 }
1661 if let Some(l) = layout {
1662 attrs_parts.push(format!("layout=\"{}\"", escape_attr(l)));
1663 }
1664 if *transparent {
1665 attrs_parts.push("transparent".to_string());
1666 }
1667 let attrs_str = if attrs_parts.is_empty() {
1668 String::new()
1669 } else {
1670 format!("[{}]", attrs_parts.join(" "))
1671 };
1672 let mut content_lines = Vec::new();
1673 if let Some(h) = headline {
1674 content_lines.push(format!("# {h}"));
1675 content_lines.push(String::new());
1676 }
1677 if let Some(s) = subtitle {
1678 content_lines.push(s.clone());
1679 content_lines.push(String::new());
1680 }
1681 for btn in buttons {
1682 let mut flags: Vec<&str> = Vec::new();
1683 if btn.primary { flags.push("primary"); }
1684 if btn.external { flags.push("external"); }
1685 let suffix = if flags.is_empty() { String::new() } else { format!("{{{}}}", flags.join(" ")) };
1686 content_lines.push(format!("[{}]({}){}", btn.label, btn.href, suffix));
1687 }
1688 format!("::hero{attrs_str}\n{}\n::", content_lines.join("\n"))
1689 }
1690
1691 Block::Banner {
1692 headline,
1693 subtitle,
1694 buttons,
1695 id,
1696 ..
1697 } => {
1698 let attrs_str = id
1699 .as_ref()
1700 .map(|i| format!("[id=\"{}\"]", escape_attr(i)))
1701 .unwrap_or_default();
1702 let mut content_lines = Vec::new();
1703 if let Some(h) = headline {
1704 content_lines.push(format!("# {h}"));
1705 content_lines.push(String::new());
1706 }
1707 if let Some(s) = subtitle {
1708 content_lines.push(s.clone());
1709 content_lines.push(String::new());
1710 }
1711 for btn in buttons {
1712 let mut flags: Vec<&str> = Vec::new();
1713 if btn.primary { flags.push("primary"); }
1714 if btn.external { flags.push("external"); }
1715 let suffix = if flags.is_empty() { String::new() } else { format!("{{{}}}", flags.join(" ")) };
1716 content_lines.push(format!("[{}]({}){}", btn.label, btn.href, suffix));
1717 }
1718 format!("::banner{attrs_str}\n{}\n::", content_lines.join("\n").trim_end())
1719 }
1720
1721 Block::ProductGrid { groups, tiles, .. } => {
1722 let mut content_lines = Vec::new();
1723 for group in groups {
1724 let brace = group.cols.map(|c| format!(" {{cols={c}}}")).unwrap_or_default();
1725 if let Some(label) = &group.label {
1726 content_lines.push(format!("### {label}{brace}"));
1727 } else if !brace.is_empty() {
1728 content_lines.push(format!("###{brace}"));
1729 }
1730 for item in &group.items {
1731 let emblem = item.emblem.as_deref().unwrap_or("");
1732 let tagline = item.tagline.as_deref().unwrap_or("");
1733 let cta1 = match (&item.cta1_label, &item.cta1_href) {
1736 (Some(l), Some(h)) => Some(format!("[{l}]({h})")),
1737 _ => None,
1738 };
1739 let cta2 = match (&item.cta2_label, &item.cta2_href) {
1740 (Some(l), Some(h)) => Some(format!("[{l}]({h})")),
1741 _ => None,
1742 };
1743 let mut tail = String::new();
1744 if item.bg.is_some() || cta1.is_some() || cta2.is_some() {
1745 tail.push_str(&format!(" | {}", item.bg.as_deref().unwrap_or("")));
1746 }
1747 if let Some(c) = cta1 {
1748 tail.push_str(&format!(" | {c}"));
1749 }
1750 if let Some(c) = cta2 {
1751 tail.push_str(&format!(" | {c}"));
1752 }
1753 content_lines.push(format!(
1754 "- {} | {} | {} | {}{}",
1755 item.name, emblem, item.href, tagline, tail
1756 ));
1757 }
1758 }
1759 let attrs_str = if *tiles { "[tiles]" } else { "" };
1760 format!("::product-grid{attrs_str}\n{}\n::", content_lines.join("\n"))
1761 }
1762
1763 Block::PostGrid { title, subtitle, items, .. } => {
1764 let mut attr_parts = Vec::new();
1765 if let Some(t) = title {
1766 attr_parts.push(format!("title=\"{}\"", escape_attr(t)));
1767 }
1768 if let Some(s) = subtitle {
1769 attr_parts.push(format!("subtitle=\"{}\"", escape_attr(s)));
1770 }
1771 let attrs_str = if attr_parts.is_empty() {
1772 String::new()
1773 } else {
1774 format!("[{}]", attr_parts.join(" "))
1775 };
1776 let mut content_lines = Vec::new();
1777 for item in items {
1778 let ext = if item.external { "{external}" } else { "" };
1779 let meta = item.meta.as_deref().unwrap_or("");
1780 let excerpt = item.excerpt.as_deref().unwrap_or("");
1781 let image = item.image.as_deref().unwrap_or("");
1782 content_lines.push(format!(
1783 "- [{}]({}){} | {} | {} | {}",
1784 item.title, item.href, ext, meta, excerpt, image
1785 ));
1786 }
1787 format!("::post-grid{attrs_str}\n{}\n::", content_lines.join("\n"))
1788 }
1789
1790 Block::Gate { title, subtitle, action, field_label, submit_label, error, .. } => {
1791 let mut attr_parts = Vec::new();
1792 if let Some(t) = title {
1793 attr_parts.push(format!("title=\"{}\"", escape_attr(t)));
1794 }
1795 if let Some(s) = subtitle {
1796 attr_parts.push(format!("subtitle=\"{}\"", escape_attr(s)));
1797 }
1798 if !action.is_empty() {
1799 attr_parts.push(format!("action=\"{}\"", escape_attr(action)));
1800 }
1801 if let Some(f) = field_label {
1802 attr_parts.push(format!("field=\"{}\"", escape_attr(f)));
1803 }
1804 if let Some(s) = submit_label {
1805 attr_parts.push(format!("submit=\"{}\"", escape_attr(s)));
1806 }
1807 if let Some(e) = error {
1808 attr_parts.push(format!("error=\"{}\"", escape_attr(e)));
1809 }
1810 let attrs_str = if attr_parts.is_empty() {
1811 String::new()
1812 } else {
1813 format!("[{}]", attr_parts.join(" "))
1814 };
1815 format!("::gate{attrs_str}\n::")
1816 }
1817
1818 Block::Features { cards, cols, .. } => {
1819 let attrs_str = cols
1820 .map(|c| format!("[cols={}]", c))
1821 .unwrap_or_default();
1822 let mut content_lines = Vec::new();
1823 for card in cards {
1824 let icon_str = card
1825 .icon
1826 .as_ref()
1827 .map(|i| format!(" {{icon={i}}}"))
1828 .unwrap_or_default();
1829 content_lines.push(format!("### {}{}", card.title, icon_str));
1830 content_lines.push(String::new());
1831 if !card.body.is_empty() {
1832 content_lines.push(card.body.clone());
1833 content_lines.push(String::new());
1834 }
1835 if let (Some(label), Some(href)) = (&card.link_label, &card.link_href) {
1836 content_lines.push(format!("[{label}]({href})"));
1837 content_lines.push(String::new());
1838 }
1839 }
1840 format!("::features{attrs_str}\n{}\n::", content_lines.join("\n").trim())
1841 }
1842
1843 Block::Steps { steps, .. } => {
1844 let mut content_lines = Vec::new();
1845 for step in steps {
1846 let time_str = step
1847 .time
1848 .as_ref()
1849 .map(|t| format!(" {{time=\"{t}\"}}"))
1850 .unwrap_or_default();
1851 content_lines.push(format!("### {}{}", step.title, time_str));
1852 content_lines.push(String::new());
1853 if !step.body.is_empty() {
1854 content_lines.push(step.body.clone());
1855 content_lines.push(String::new());
1856 }
1857 }
1858 format!("::steps\n{}\n::", content_lines.join("\n").trim())
1859 }
1860
1861 Block::Stats { items, .. } => {
1862 let mut content_lines = Vec::new();
1863 for item in items {
1864 let color_str = item
1865 .color
1866 .as_ref()
1867 .map(|c| format!(" color=\"{c}\""))
1868 .unwrap_or_default();
1869 content_lines.push(format!(
1870 "- {} {{label=\"{}\"{}}}",
1871 item.value, escape_attr(&item.label), color_str
1872 ));
1873 }
1874 format!("::stats\n{}\n::", content_lines.join("\n"))
1875 }
1876
1877 Block::Comparison {
1878 headers,
1879 rows,
1880 highlight,
1881 ..
1882 } => {
1883 let attrs_str = highlight
1884 .as_ref()
1885 .map(|h| format!("[highlight=\"{}\"]", escape_attr(h)))
1886 .unwrap_or_default();
1887 let mut content_lines = Vec::new();
1888 content_lines.push(format!("| {} |", headers.join(" | ")));
1889 content_lines.push(format!(
1890 "| {} |",
1891 headers.iter().map(|_| "---").collect::<Vec<_>>().join(" | ")
1892 ));
1893 for row in rows {
1894 content_lines.push(format!("| {} |", row.join(" | ")));
1895 }
1896 format!("::comparison{attrs_str}\n{}\n::", content_lines.join("\n"))
1897 }
1898
1899 Block::Logo { src, alt, size, .. } => {
1900 let mut attrs_parts = vec![format!("src=\"{}\"", escape_attr(src))];
1901 if let Some(a) = alt {
1902 attrs_parts.push(format!("alt=\"{}\"", escape_attr(a)));
1903 }
1904 if let Some(s) = size {
1905 attrs_parts.push(format!("size={}", s));
1906 }
1907 format!("::logo[{}]", attrs_parts.join(" "))
1908 }
1909
1910 Block::Toc { depth, .. } => {
1911 if *depth == 3 {
1912 "::toc".to_string()
1913 } else {
1914 format!("::toc[depth={}]", depth)
1915 }
1916 }
1917
1918 Block::BeforeAfter {
1919 before_items,
1920 after_items,
1921 transition,
1922 ..
1923 } => {
1924 let attrs_str = transition
1925 .as_ref()
1926 .map(|t| format!("[transition=\"{}\"]", escape_attr(t)))
1927 .unwrap_or_default();
1928 let mut content_lines = Vec::new();
1929 content_lines.push("### Before".to_string());
1930 for item in before_items {
1931 content_lines.push(format!("- {} | {}", item.label, item.detail));
1932 }
1933 content_lines.push(String::new());
1934 content_lines.push("### After".to_string());
1935 for item in after_items {
1936 content_lines.push(format!("- {} | {}", item.label, item.detail));
1937 }
1938 format!("::before-after{attrs_str}\n{}\n::", content_lines.join("\n"))
1939 }
1940
1941 Block::Pipeline { steps, .. } => {
1942 let content_lines: Vec<String> = steps
1943 .iter()
1944 .map(|s| match &s.description {
1945 Some(d) => format!("{} | {}", s.label, d),
1946 None => s.label.clone(),
1947 })
1948 .collect();
1949 format!("::pipeline\n{}\n::", content_lines.join("\n"))
1950 }
1951
1952 Block::Section {
1953 bg,
1954 headline,
1955 subtitle,
1956 content,
1957 ..
1958 } => {
1959 let attrs_str = bg
1960 .as_ref()
1961 .map(|b| format!("[bg=\"{}\"]", escape_attr(b)))
1962 .unwrap_or_default();
1963 if content.is_empty() {
1964 let mut inner = Vec::new();
1965 if let Some(h) = headline {
1966 inner.push(format!("## {h}"));
1967 }
1968 if let Some(s) = subtitle {
1969 inner.push(s.clone());
1970 }
1971 if inner.is_empty() {
1972 format!("::section{attrs_str}\n::")
1973 } else {
1974 format!("::section{attrs_str}\n{}\n::", inner.join("\n"))
1975 }
1976 } else {
1977 format!("::section{attrs_str}\n{content}\n::")
1978 }
1979 }
1980
1981 Block::ProductCard {
1982 title,
1983 subtitle,
1984 badge,
1985 badge_color,
1986 body,
1987 features,
1988 cta_label,
1989 cta_href,
1990 ..
1991 } => {
1992 let mut attrs_parts = Vec::new();
1993 if let Some(b) = badge {
1994 attrs_parts.push(format!("badge=\"{}\"", escape_attr(b)));
1995 }
1996 if let Some(bc) = badge_color {
1997 attrs_parts.push(format!("badge-color=\"{}\"", escape_attr(bc)));
1998 }
1999 let attrs_str = if attrs_parts.is_empty() {
2000 String::new()
2001 } else {
2002 format!("[{}]", attrs_parts.join(" "))
2003 };
2004 let mut content_lines = Vec::new();
2005 content_lines.push(format!("## {title}"));
2006 if let Some(s) = subtitle {
2007 content_lines.push(s.clone());
2008 }
2009 content_lines.push(String::new());
2010 if !body.is_empty() {
2011 content_lines.push(body.clone());
2012 content_lines.push(String::new());
2013 }
2014 for f in features {
2015 content_lines.push(format!("- {f}"));
2016 }
2017 if let (Some(label), Some(href)) = (cta_label, cta_href) {
2018 content_lines.push(String::new());
2019 content_lines.push(format!("[{label}]({href})"));
2020 }
2021 format!("::product-card{attrs_str}\n{}\n::", content_lines.join("\n"))
2022 }
2023
2024 Block::List { source, display, item_template, filters, sort, preload, .. } => {
2027 let mut attrs_parts = Vec::new();
2028 attrs_parts.push(format!("source=\"{}\"", escape_attr(source)));
2029 let display_str = match display {
2030 ListDisplay::Card => "card",
2031 ListDisplay::Table => "table",
2032 ListDisplay::Compact => "compact",
2033 };
2034 attrs_parts.push(format!("display={display_str}"));
2035 if *preload {
2036 attrs_parts.push("preload".to_string());
2037 }
2038 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2039 let mut content_lines = Vec::new();
2040 if !item_template.is_empty() {
2041 content_lines.push(item_template.clone());
2042 content_lines.push(String::new());
2043 }
2044 for f in filters {
2045 content_lines.push(format!("filter: {}", f.field));
2046 }
2047 if let Some(s) = sort {
2048 let dir = if s.descending { " desc" } else { " asc" };
2049 content_lines.push(format!("sort: {}{dir}", s.field));
2050 }
2051 format!("::list{attrs_str}\n{}\n::", content_lines.join("\n"))
2052 }
2053
2054 Block::Board { source, columns, card_template, preload, .. } => {
2055 let mut attrs_parts = vec![format!("source=\"{}\"", escape_attr(source))];
2056 if *preload {
2057 attrs_parts.push("preload".to_string());
2058 }
2059 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2060 let mut content_lines = Vec::new();
2061 if !columns.is_empty() {
2062 content_lines.push(format!("columns: {}", columns.join(" | ")));
2063 }
2064 if let Some(tmpl) = card_template {
2065 content_lines.push(format!("card-template: {tmpl}"));
2066 }
2067 format!("::board{attrs_str}\n{}\n::", content_lines.join("\n"))
2068 }
2069
2070 Block::Action { method, target, label, fields, confirm, .. } => {
2071 let method_str = match method {
2072 HttpMethod::Get => "get",
2073 HttpMethod::Post => "post",
2074 HttpMethod::Put => "put",
2075 HttpMethod::Patch => "patch",
2076 HttpMethod::Delete => "delete",
2077 };
2078 let mut attrs_parts = vec![
2079 format!("method={method_str}"),
2080 format!("target=\"{}\"", escape_attr(target)),
2081 format!("label=\"{}\"", escape_attr(label)),
2082 ];
2083 if let Some(c) = confirm {
2084 attrs_parts.push(format!("confirm=\"{}\"", escape_attr(c)));
2085 }
2086 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2087 let mut content_lines = Vec::new();
2088 for field in fields {
2089 let type_str = match field.field_type {
2090 FormFieldType::Text => "text",
2091 FormFieldType::Email => "email",
2092 FormFieldType::Tel => "tel",
2093 FormFieldType::Date => "date",
2094 FormFieldType::Number => "number",
2095 FormFieldType::Password => "password",
2096 FormFieldType::Select => "select",
2097 FormFieldType::Textarea => "textarea",
2098 };
2099 let req = if field.required { " *" } else { "" };
2100 if field.field_type == FormFieldType::Select && !field.options.is_empty() {
2101 content_lines.push(format!(
2102 "- {} (select: {}){req}",
2103 field.label,
2104 field.options.join(" | "),
2105 ));
2106 } else {
2107 content_lines.push(format!("- {} ({type_str}){req}", field.label));
2108 }
2109 }
2110 format!("::action{attrs_str}\n{}\n::", content_lines.join("\n"))
2111 }
2112
2113 Block::FilterBar { target_selector, fields, .. } => {
2114 let attrs_str = format!("[target=\"{}\"]", escape_attr(target_selector));
2115 let mut content_lines = Vec::new();
2116 for field in fields {
2117 content_lines.push(format!(
2118 "- {} (select: {})",
2119 field.label,
2120 field.options.join(" | "),
2121 ));
2122 }
2123 format!("::filter-bar{attrs_str}\n{}\n::", content_lines.join("\n"))
2124 }
2125
2126 Block::Search { source, placeholder, .. } => {
2127 let mut attrs_parts = vec![format!("source=\"{}\"", escape_attr(source))];
2128 if let Some(ph) = placeholder {
2129 attrs_parts.push(format!("placeholder=\"{}\"", escape_attr(ph)));
2130 }
2131 format!("::search[{}]\n::", attrs_parts.join(" "))
2132 }
2133
2134 Block::Dashboard { source, refresh, .. } => {
2135 let mut attrs_parts = vec![format!("source=\"{}\"", escape_attr(source))];
2136 if let Some(r) = refresh {
2137 attrs_parts.push(format!("refresh={r}"));
2138 }
2139 format!("::dashboard[{}]\n::", attrs_parts.join(" "))
2140 }
2141
2142 Block::ChatInput { action, placeholder, modes, .. } => {
2143 let mut attrs_parts = vec![format!("action=\"{}\"", escape_attr(action))];
2144 if let Some(ph) = placeholder {
2145 attrs_parts.push(format!("placeholder=\"{}\"", escape_attr(ph)));
2146 }
2147 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2148 let mut content_lines = Vec::new();
2149 if !modes.is_empty() {
2150 content_lines.push(format!("modes: {}", modes.join(" | ")));
2151 }
2152 format!("::chat-input{attrs_str}\n{}\n::", content_lines.join("\n"))
2153 }
2154
2155 Block::Feed { source, stream, .. } => {
2156 let mut attrs_parts = vec![format!("source=\"{}\"", escape_attr(source))];
2157 if *stream {
2158 attrs_parts.push("stream".to_string());
2159 }
2160 format!("::feed[{}]\n::", attrs_parts.join(" "))
2161 }
2162
2163 Block::Store {
2164 title,
2165 currency,
2166 items,
2167 ..
2168 } => {
2169 let mut attrs_parts = Vec::new();
2170 if let Some(t) = title {
2171 attrs_parts.push(format!("title=\"{}\"", escape_attr(t)));
2172 }
2173 if let Some(c) = currency {
2174 attrs_parts.push(format!("currency=\"{}\"", escape_attr(c)));
2175 }
2176 let attrs_str = if attrs_parts.is_empty() {
2177 String::new()
2178 } else {
2179 format!("[{}]", attrs_parts.join(" "))
2180 };
2181 let mut content_lines = Vec::new();
2182 let mut last_cat: Option<String> = None;
2183 for it in items {
2184 if it.category != last_cat {
2185 if let Some(c) = &it.category {
2186 content_lines.push(format!("category: {c}"));
2187 }
2188 last_cat = it.category.clone();
2189 }
2190 let mut parts = vec![it.name.clone(), it.price.clone()];
2191 if let Some(b) = &it.blurb {
2192 parts.push(b.clone());
2193 }
2194 if let Some(bd) = &it.badge {
2195 if it.blurb.is_none() {
2197 parts.push(String::new());
2198 }
2199 parts.push(bd.clone());
2200 }
2201 content_lines.push(format!("item: {}", parts.join(" | ")));
2202 }
2203 format!("::store{attrs_str}\n{}\n::", content_lines.join("\n"))
2204 }
2205
2206 Block::Booking {
2207 title,
2208 service_label,
2209 services,
2210 days,
2211 ..
2212 } => {
2213 let mut attrs_parts = Vec::new();
2214 if let Some(t) = title {
2215 attrs_parts.push(format!("title=\"{}\"", escape_attr(t)));
2216 }
2217 if let Some(l) = service_label {
2218 attrs_parts.push(format!("service-label=\"{}\"", escape_attr(l)));
2219 }
2220 let attrs_str = if attrs_parts.is_empty() {
2221 String::new()
2222 } else {
2223 format!("[{}]", attrs_parts.join(" "))
2224 };
2225 let mut content_lines = Vec::new();
2226 for s in services {
2227 let mut parts = vec![s.name.clone()];
2228 if let Some(d) = &s.duration {
2229 parts.push(d.clone());
2230 }
2231 if let Some(p) = &s.price {
2232 parts.push(p.clone());
2233 }
2234 content_lines.push(format!("service: {}", parts.join(" | ")));
2235 }
2236 for d in days {
2237 let slots = if d.slots.is_empty() {
2238 "full".to_string()
2239 } else {
2240 d.slots.join(", ")
2241 };
2242 content_lines.push(format!("day: {} | {}", d.date, slots));
2243 }
2244 format!("::booking{attrs_str}\n{}\n::", content_lines.join("\n"))
2245 }
2246
2247 Block::Editor { source, lang, preview, .. } => {
2248 let mut attrs_parts = Vec::new();
2249 if let Some(s) = source {
2250 attrs_parts.push(format!("source=\"{}\"", escape_attr(s)));
2251 }
2252 if let Some(l) = lang {
2253 attrs_parts.push(format!("lang={l}"));
2254 }
2255 if *preview {
2256 attrs_parts.push("preview".to_string());
2257 }
2258 let attrs_str = if attrs_parts.is_empty() {
2259 String::new()
2260 } else {
2261 format!("[{}]", attrs_parts.join(" "))
2262 };
2263 format!("::editor{attrs_str}\n::")
2264 }
2265
2266 Block::Chart { chart_type, source, period, title, data, .. } => {
2267 let type_str = match chart_type {
2268 ChartType::Line => "line",
2269 ChartType::Bar => "bar",
2270 ChartType::Pie => "pie",
2271 ChartType::Area => "area",
2272 ChartType::Scatter => "scatter",
2273 ChartType::Donut => "donut",
2274 ChartType::StackedBar => "stacked-bar",
2275 ChartType::Radar => "radar",
2276 };
2277 let mut attrs_parts = vec![format!("type={type_str}")];
2278 if !source.is_empty() {
2279 attrs_parts.push(format!("source=\"{}\"", escape_attr(source)));
2280 }
2281 if let Some(p) = period {
2282 attrs_parts.push(format!("period={p}"));
2283 }
2284 if let Some(t) = title {
2285 attrs_parts.push(format!("title=\"{}\"", escape_attr(t)));
2286 }
2287 if let Some(d) = data {
2289 let mut body = String::new();
2290 body.push_str("| label | ");
2291 body.push_str(&d.series.iter().map(|s| s.name.clone()).collect::<Vec<_>>().join(" | "));
2292 body.push_str(" |");
2293 for (i, cat) in d.categories.iter().enumerate() {
2294 body.push_str(&format!("\n{cat} | "));
2295 let cells: Vec<String> = d
2296 .series
2297 .iter()
2298 .map(|s| s.values.get(i).map(|v| format!("{v}")).unwrap_or_default())
2299 .collect();
2300 body.push_str(&cells.join(" | "));
2301 }
2302 return format!("::chart[{}]\n{body}\n::", attrs_parts.join(" "));
2303 }
2304 format!("::chart[{}]\n::", attrs_parts.join(" "))
2305 }
2306
2307 Block::SplitPane { ratio, .. } => {
2308 format!("::split-pane[ratio=\"{ratio}\"]\n::")
2309 }
2310
2311 Block::App { name, binary, region, port, platform, auth, content, .. } => {
2314 let mut attrs_parts = vec![format!("name=\"{}\"", escape_attr(name))];
2315 if let Some(b) = binary {
2316 attrs_parts.push(format!("binary=\"{}\"", escape_attr(b)));
2317 }
2318 if let Some(r) = region {
2319 attrs_parts.push(format!("region=\"{}\"", escape_attr(r)));
2320 }
2321 if let Some(p) = port {
2322 attrs_parts.push(format!("port={p}"));
2323 }
2324 if let Some(pl) = platform {
2325 attrs_parts.push(format!("platform=\"{}\"", escape_attr(pl)));
2326 }
2327 if let Some(a) = auth {
2328 attrs_parts.push(format!("auth=\"{}\"", escape_attr(a)));
2329 }
2330 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2331 if content.is_empty() {
2332 format!("::app{attrs_str}\n::")
2333 } else {
2334 format!("::app{attrs_str}\n{content}\n::")
2335 }
2336 }
2337
2338 Block::Build { base, runtime, edition, properties, .. } => {
2339 let mut attrs_parts = Vec::new();
2340 if let Some(b) = base {
2341 attrs_parts.push(format!("base=\"{}\"", escape_attr(b)));
2342 }
2343 if let Some(r) = runtime {
2344 attrs_parts.push(format!("runtime=\"{}\"", escape_attr(r)));
2345 }
2346 if let Some(e) = edition {
2347 attrs_parts.push(format!("edition=\"{}\"", escape_attr(e)));
2348 }
2349 let attrs_str = if attrs_parts.is_empty() {
2350 String::new()
2351 } else {
2352 format!("[{}]", attrs_parts.join(" "))
2353 };
2354 let mut content_lines = Vec::new();
2355 for p in properties {
2356 content_lines.push(format!("{}: {}", p.key, p.value));
2357 }
2358 let content = content_lines.join("\n");
2359 if content.is_empty() {
2360 format!("::build{attrs_str}\n::")
2361 } else {
2362 format!("::build{attrs_str}\n{content}\n::")
2363 }
2364 }
2365
2366 Block::InfraDatabase { name, shared_auth, volume_gb, properties, .. } => {
2367 let mut attrs_parts = Vec::new();
2368 if let Some(n) = name {
2369 attrs_parts.push(format!("name=\"{}\"", escape_attr(n)));
2370 }
2371 if *shared_auth {
2372 attrs_parts.push("shared-auth".to_string());
2373 }
2374 if let Some(v) = volume_gb {
2375 attrs_parts.push(format!("volume-gb={v}"));
2376 }
2377 let attrs_str = if attrs_parts.is_empty() {
2378 String::new()
2379 } else {
2380 format!("[{}]", attrs_parts.join(" "))
2381 };
2382 let mut content_lines = Vec::new();
2383 for p in properties {
2384 content_lines.push(format!("{}: {}", p.key, p.value));
2385 }
2386 let content = content_lines.join("\n");
2387 if content.is_empty() {
2388 format!("::database{attrs_str}\n::")
2389 } else {
2390 format!("::database{attrs_str}\n{content}\n::")
2391 }
2392 }
2393
2394 Block::Deploy { env, app, machines, memory, auto_stop, min_machines, strategy, properties, .. } => {
2395 let mut attrs_parts = Vec::new();
2396 if let Some(e) = env {
2397 attrs_parts.push(format!("env=\"{}\"", escape_attr(e)));
2398 }
2399 if let Some(a) = app {
2400 attrs_parts.push(format!("app=\"{}\"", escape_attr(a)));
2401 }
2402 if let Some(m) = machines {
2403 attrs_parts.push(format!("machines={m}"));
2404 }
2405 if let Some(mem) = memory {
2406 attrs_parts.push(format!("memory={mem}"));
2407 }
2408 if let Some(a) = auto_stop {
2409 attrs_parts.push(format!("auto-stop=\"{}\"", escape_attr(a)));
2410 }
2411 if let Some(min) = min_machines {
2412 attrs_parts.push(format!("min-machines={min}"));
2413 }
2414 if let Some(s) = strategy {
2415 attrs_parts.push(format!("strategy=\"{}\"", escape_attr(s)));
2416 }
2417 let attrs_str = if attrs_parts.is_empty() {
2418 String::new()
2419 } else {
2420 format!("[{}]", attrs_parts.join(" "))
2421 };
2422 let mut content_lines = Vec::new();
2423 for p in properties {
2424 content_lines.push(format!("{}: {}", p.key, p.value));
2425 }
2426 let content = content_lines.join("\n");
2427 if content.is_empty() {
2428 format!("::deploy{attrs_str}\n::")
2429 } else {
2430 format!("::deploy{attrs_str}\n{content}\n::")
2431 }
2432 }
2433
2434 Block::InfraEnv { tier, entries, .. } => {
2435 let attrs_str = tier
2436 .as_ref()
2437 .map(|t| format!("[tier=\"{}\"]", escape_attr(t)))
2438 .unwrap_or_default();
2439 let mut content_lines = Vec::new();
2440 for e in entries {
2441 match &e.default_value {
2442 Some(v) => content_lines.push(format!("{}: {}", e.name, v)),
2443 None => content_lines.push(e.name.clone()),
2444 }
2445 }
2446 let content = content_lines.join("\n");
2447 if content.is_empty() {
2448 format!("::env{attrs_str}\n::")
2449 } else {
2450 format!("::env{attrs_str}\n{content}\n::")
2451 }
2452 }
2453
2454 Block::Health { path, method, grace, interval, timeout, .. } => {
2455 let mut attrs_parts = Vec::new();
2456 if let Some(p) = path {
2457 attrs_parts.push(format!("path=\"{}\"", escape_attr(p)));
2458 }
2459 if let Some(m) = method {
2460 attrs_parts.push(format!("method=\"{}\"", escape_attr(m)));
2461 }
2462 if let Some(g) = grace {
2463 attrs_parts.push(format!("grace=\"{}\"", escape_attr(g)));
2464 }
2465 if let Some(i) = interval {
2466 attrs_parts.push(format!("interval=\"{}\"", escape_attr(i)));
2467 }
2468 if let Some(t) = timeout {
2469 attrs_parts.push(format!("timeout=\"{}\"", escape_attr(t)));
2470 }
2471 let attrs_str = if attrs_parts.is_empty() {
2472 String::new()
2473 } else {
2474 format!("[{}]", attrs_parts.join(" "))
2475 };
2476 format!("::health{attrs_str}\n::")
2477 }
2478
2479 Block::Concurrency { concurrency_type, hard_limit, soft_limit, force_https, .. } => {
2480 let mut attrs_parts = Vec::new();
2481 if let Some(ct) = concurrency_type {
2482 attrs_parts.push(format!("type=\"{}\"", escape_attr(ct)));
2483 }
2484 if let Some(h) = hard_limit {
2485 attrs_parts.push(format!("hard-limit={h}"));
2486 }
2487 if let Some(s) = soft_limit {
2488 attrs_parts.push(format!("soft-limit={s}"));
2489 }
2490 if *force_https {
2491 attrs_parts.push("force-https".to_string());
2492 }
2493 let attrs_str = if attrs_parts.is_empty() {
2494 String::new()
2495 } else {
2496 format!("[{}]", attrs_parts.join(" "))
2497 };
2498 format!("::concurrency{attrs_str}\n::")
2499 }
2500
2501 Block::Cicd { provider, properties, .. } => {
2502 let attrs_str = provider
2503 .as_ref()
2504 .map(|p| format!("[provider=\"{}\"]", escape_attr(p)))
2505 .unwrap_or_default();
2506 let mut content_lines = Vec::new();
2507 for p in properties {
2508 content_lines.push(format!("{}: {}", p.key, p.value));
2509 }
2510 let content = content_lines.join("\n");
2511 if content.is_empty() {
2512 format!("::cicd{attrs_str}\n::")
2513 } else {
2514 format!("::cicd{attrs_str}\n{content}\n::")
2515 }
2516 }
2517
2518 Block::Smoke { script, checks, .. } => {
2519 let attrs_str = script
2520 .as_ref()
2521 .map(|s| format!("[script=\"{}\"]", escape_attr(s)))
2522 .unwrap_or_default();
2523 let mut content_lines = Vec::new();
2524 for c in checks {
2525 content_lines.push(format!("{} {} {}", c.method, c.path, c.expected));
2526 }
2527 let content = content_lines.join("\n");
2528 if content.is_empty() {
2529 format!("::smoke{attrs_str}\n::")
2530 } else {
2531 format!("::smoke{attrs_str}\n{content}\n::")
2532 }
2533 }
2534
2535 Block::Domains { entries, .. } => {
2536 let mut content_lines = Vec::new();
2537 for e in entries {
2538 content_lines.push(e.domain.clone());
2539 }
2540 let content = content_lines.join("\n");
2541 if content.is_empty() {
2542 "::domains\n::".to_string()
2543 } else {
2544 format!("::domains\n{content}\n::")
2545 }
2546 }
2547
2548 Block::Crates { entries, .. } => {
2549 let mut content_lines = Vec::new();
2550 for e in entries {
2551 let src = e.source.as_deref().unwrap_or("*");
2552 content_lines.push(format!("{} = \"{src}\"", e.name));
2553 }
2554 let content = content_lines.join("\n");
2555 if content.is_empty() {
2556 "::crates\n::".to_string()
2557 } else {
2558 format!("::crates\n{content}\n::")
2559 }
2560 }
2561
2562 Block::DeployUrls { entries, .. } => {
2563 let mut content_lines = Vec::new();
2564 for e in entries {
2565 content_lines.push(format!("{}: {}", e.key, e.value));
2566 }
2567 let content = content_lines.join("\n");
2568 if content.is_empty() {
2569 "::deploy-urls\n::".to_string()
2570 } else {
2571 format!("::deploy-urls\n{content}\n::")
2572 }
2573 }
2574
2575 Block::Volumes { entries, .. } => {
2576 let mut content_lines = Vec::new();
2577 for e in entries {
2578 content_lines.push(format!("{}: {}", e.name, e.mount));
2579 }
2580 let content = content_lines.join("\n");
2581 if content.is_empty() {
2582 "::volumes\n::".to_string()
2583 } else {
2584 format!("::volumes\n{content}\n::")
2585 }
2586 }
2587
2588 Block::Model { name, fields, .. } => {
2589 let attrs_str = if name.is_empty() {
2590 String::new()
2591 } else {
2592 format!("[name=\"{}\"]", escape_attr(name))
2593 };
2594 let mut content_lines = Vec::new();
2595 for f in fields {
2596 let type_str = model_field_type_surf(&f.field_type);
2597 let constraints: Vec<String> = f.constraints.iter().map(|c| constraint_surf(c)).collect();
2598 if constraints.is_empty() {
2599 content_lines.push(format!("- {}: {}", f.name, type_str));
2600 } else {
2601 content_lines.push(format!("- {}: {} [{}]", f.name, type_str, constraints.join(", ")));
2602 }
2603 }
2604 let content = content_lines.join("\n");
2605 if content.is_empty() {
2606 format!("::model{attrs_str}\n::")
2607 } else {
2608 format!("::model{attrs_str}\n{content}\n::")
2609 }
2610 }
2611
2612 Block::Route { method, path, auth, returns, body, handler, content, .. } => {
2613 let method_str = match method {
2614 HttpMethod::Get => "GET",
2615 HttpMethod::Post => "POST",
2616 HttpMethod::Put => "PUT",
2617 HttpMethod::Patch => "PATCH",
2618 HttpMethod::Delete => "DELETE",
2619 };
2620 let mut attrs_parts = vec![format!("method={method_str}")];
2621 if !path.is_empty() {
2622 attrs_parts.push(format!("path=\"{}\"", escape_attr(path)));
2623 }
2624 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2625 let mut content_lines = Vec::new();
2626 if let Some(a) = auth { content_lines.push(format!("auth: {a}")); }
2627 if let Some(r) = returns { content_lines.push(format!("returns: {r}")); }
2628 if let Some(b) = body { content_lines.push(format!("body: {b}")); }
2629 if let Some(h) = handler {
2630 content_lines.push("```rust".to_string());
2631 content_lines.push(h.clone());
2632 content_lines.push("```".to_string());
2633 }
2634 if !content.is_empty() { content_lines.push(content.clone()); }
2635 let content = content_lines.join("\n");
2636 if content.is_empty() {
2637 format!("::route{attrs_str}\n::")
2638 } else {
2639 format!("::route{attrs_str}\n{content}\n::")
2640 }
2641 }
2642
2643 Block::Auth { provider, session, roles, default_role, .. } => {
2644 let provider_str = match provider {
2645 crate::types::AuthProvider::Email => "email",
2646 crate::types::AuthProvider::OAuth => "oauth",
2647 crate::types::AuthProvider::ApiKey => "api-key",
2648 crate::types::AuthProvider::Token => "token",
2649 };
2650 let attrs_str = format!("[provider={provider_str}]");
2651 let mut content_lines = Vec::new();
2652 if let Some(s) = session { content_lines.push(format!("session: {s}")); }
2653 if !roles.is_empty() { content_lines.push(format!("roles: {}", roles.join(", "))); }
2654 if let Some(dr) = default_role { content_lines.push(format!("default_role: {dr}")); }
2655 let content = content_lines.join("\n");
2656 if content.is_empty() {
2657 format!("::auth{attrs_str}\n::")
2658 } else {
2659 format!("::auth{attrs_str}\n{content}\n::")
2660 }
2661 }
2662
2663 Block::Binding { source, target, events, .. } => {
2664 let mut attrs_parts = Vec::new();
2665 if !source.is_empty() { attrs_parts.push(format!("source=\"{}\"", escape_attr(source))); }
2666 if !target.is_empty() { attrs_parts.push(format!("target=\"{}\"", escape_attr(target))); }
2667 let attrs_str = if attrs_parts.is_empty() {
2668 String::new()
2669 } else {
2670 format!("[{}]", attrs_parts.join(" "))
2671 };
2672 let mut content_lines = Vec::new();
2673 for e in events {
2674 content_lines.push(format!("{}: {}", e.event, e.action));
2675 }
2676 let content = content_lines.join("\n");
2677 if content.is_empty() {
2678 format!("::binding{attrs_str}\n::")
2679 } else {
2680 format!("::binding{attrs_str}\n{content}\n::")
2681 }
2682 }
2683
2684 Block::Schema { name, fields, .. } => {
2685 let attrs_str = if name.is_empty() {
2686 String::new()
2687 } else {
2688 format!("[name=\"{}\"]", escape_attr(name))
2689 };
2690 let content_lines: Vec<String> = fields.iter().map(|f| {
2691 let constraints = if f.constraints.is_empty() {
2692 String::new()
2693 } else {
2694 format!(" {}", f.constraints.iter().map(|c| constraint_surf(c)).collect::<Vec<_>>().join(" "))
2695 };
2696 format!("- {} ({}){}", f.name, model_field_type_surf(&f.field_type), constraints)
2697 }).collect();
2698 let content = content_lines.join("\n");
2699 if content.is_empty() {
2700 format!("::schema{attrs_str}\n::")
2701 } else {
2702 format!("::schema{attrs_str}\n{content}\n::")
2703 }
2704 }
2705
2706 Block::Use { crates, .. } => {
2707 if crates.iter().all(|c| c.version.is_none() && c.features.is_empty()) {
2708 let names: Vec<String> = crates.iter().map(|c| c.name.clone()).collect();
2709 format!("::use[{}]\n::", names.join(", "))
2710 } else {
2711 let content_lines: Vec<String> = crates.iter().map(|c| {
2712 let mut parts = vec![c.name.clone()];
2713 if let Some(v) = &c.version { parts.push(v.clone()); }
2714 if !c.features.is_empty() {
2715 parts.push(format!("[{}]", c.features.join(", ")));
2716 }
2717 format!("- {}", parts.join(" "))
2718 }).collect();
2719 format!("::use\n{}\n::", content_lines.join("\n"))
2720 }
2721 }
2722
2723 Block::AppEnv { vars, .. } => {
2724 if vars.iter().all(|v| !v.required && v.description.is_none()) {
2725 let names: Vec<String> = vars.iter().map(|v| v.name.clone()).collect();
2726 format!("::app-env[{}]\n::", names.join(", "))
2727 } else {
2728 let content_lines: Vec<String> = vars.iter().map(|v| {
2729 let mut parts = vec![v.name.clone()];
2730 if v.required { parts.push("*".to_string()); }
2731 if let Some(d) = &v.description { parts.push(format!("\"{}\"", d)); }
2732 format!("- {}", parts.join(" "))
2733 }).collect();
2734 format!("::app-env\n{}\n::", content_lines.join("\n"))
2735 }
2736 }
2737
2738 Block::AppDeploy { region, scale, domain, memory, properties, .. } => {
2739 let mut attrs_parts = Vec::new();
2740 if let Some(r) = region { attrs_parts.push(format!("region=\"{}\"", escape_attr(r))); }
2741 if let Some(s) = scale { attrs_parts.push(format!("scale=\"{s}\"")); }
2742 if let Some(d) = domain { attrs_parts.push(format!("domain=\"{}\"", escape_attr(d))); }
2743 if let Some(m) = memory { attrs_parts.push(format!("memory=\"{}\"", escape_attr(m))); }
2744 let attrs_str = if attrs_parts.is_empty() {
2745 String::new()
2746 } else {
2747 format!("[{}]", attrs_parts.join(" "))
2748 };
2749 let content_lines: Vec<String> = properties.iter().map(|(k, v)| format!("{k}: {v}")).collect();
2750 let content = content_lines.join("\n");
2751 if content.is_empty() {
2752 format!("::app-deploy{attrs_str}\n::")
2753 } else {
2754 format!("::app-deploy{attrs_str}\n{content}\n::")
2755 }
2756 }
2757
2758 Block::Row { icon, title, description, href, state, .. } => {
2759 let mut attrs_parts = vec![format!("icon={icon}")];
2760 if let Some(h) = href { attrs_parts.push(format!("href=\"{}\"", escape_attr(h))); }
2761 match state { RowState::Loading => attrs_parts.push("state=loading".to_string()), RowState::Empty => attrs_parts.push("state=empty".to_string()), _ => {} }
2762 let attrs_str = format!("[{}]", attrs_parts.join(", "));
2763 format!("::row{attrs_str}\n{title}\n{description}\n::")
2764 }
2765
2766 Block::InfoCard { intent, title, subtitle, summary, image, facts, steps, state, .. } => {
2767 let mut attrs_parts = vec![format!("intent={intent}")];
2768 if let Some(img) = image { attrs_parts.push(format!("image=\"{}\"", escape_attr(img))); }
2769 match state { RowState::Loading => attrs_parts.push("state=loading".to_string()), RowState::Empty => attrs_parts.push("state=empty".to_string()), _ => {} }
2770 let attrs_str = format!("[{}]", attrs_parts.join(", "));
2771 let mut lines = vec![format!("# {title}")];
2772 if !subtitle.is_empty() { lines.push(subtitle.clone()); }
2773 if !summary.is_empty() { lines.push(String::new()); lines.push(summary.clone()); }
2774 for fact in facts { lines.push(format!("{}: {}", fact[0], fact[1])); }
2775 for (i, step) in steps.iter().enumerate() { lines.push(format!("{}. {}", i + 1, step)); }
2776 format!("::infocard{attrs_str}\n{}\n::", lines.join("\n"))
2777 }
2778
2779 Block::AppShell { layout, children, .. } => {
2782 let attrs_str = format!("[layout=\"{}\"]", escape_attr(layout));
2783 let inner = serialize_children(children);
2784 if inner.is_empty() {
2785 format!("::app-shell{attrs_str}\n::")
2786 } else {
2787 format!("::app-shell{attrs_str}\n{inner}\n::")
2788 }
2789 }
2790
2791 Block::Sidebar { position, collapsible, width, children, .. } => {
2792 let mut attrs_parts = vec![format!("position={position}")];
2793 if *collapsible { attrs_parts.push("collapsible=true".to_string()); }
2794 if let Some(w) = width { attrs_parts.push(format!("width={w}")); }
2795 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2796 let inner = serialize_children(children);
2797 if inner.is_empty() {
2798 format!("::sidebar{attrs_str}\n::")
2799 } else {
2800 format!("::sidebar{attrs_str}\n{inner}\n::")
2801 }
2802 }
2803
2804 Block::Panel { position, resizable, height, desktop_only, children, .. } => {
2805 let mut attrs_parts = vec![format!("position={position}")];
2806 if *resizable { attrs_parts.push("resizable=true".to_string()); }
2807 if let Some(h) = height { attrs_parts.push(format!("height={h}")); }
2808 if *desktop_only { attrs_parts.push("desktop-only=true".to_string()); }
2809 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2810 let inner = serialize_children(children);
2811 if inner.is_empty() {
2812 format!("::panel{attrs_str}\n::")
2813 } else {
2814 format!("::panel{attrs_str}\n{inner}\n::")
2815 }
2816 }
2817
2818 Block::TabBar { active, items, .. } => {
2819 let attrs_str = match active {
2820 Some(a) => format!("[active=\"{}\"]", escape_attr(a)),
2821 None => String::new(),
2822 };
2823 let mut lines = Vec::new();
2824 for item in items {
2825 lines.push(format!("- {} \"{}\"", item.id, escape_attr(&item.label)));
2826 }
2827 let content = lines.join("\n");
2828 if content.is_empty() {
2829 format!("::tab-bar{attrs_str}\n::")
2830 } else {
2831 format!("::tab-bar{attrs_str}\n{content}\n::")
2832 }
2833 }
2834
2835 Block::TabContent { tab, children, .. } => {
2836 let attrs_str = format!("[tab=\"{}\"]", escape_attr(tab));
2837 let inner = serialize_children(children);
2838 if inner.is_empty() {
2839 format!("::tab-content{attrs_str}\n::")
2840 } else {
2841 format!("::tab-content{attrs_str}\n{inner}\n::")
2842 }
2843 }
2844
2845 Block::Toolbar { items, .. } => {
2846 let mut lines = Vec::new();
2847 for item in items {
2848 match item {
2849 crate::types::ToolbarItem::Button { label, action, icon, style, disabled } => {
2850 let mut parts = Vec::new();
2851 if let Some(l) = label { parts.push(format!("label=\"{}\"", escape_attr(l))); }
2852 if let Some(a) = action { parts.push(format!("action={a}")); }
2853 if let Some(i) = icon { parts.push(format!("icon={i}")); }
2854 if let Some(s) = style { parts.push(format!("style={s}")); }
2855 if *disabled { parts.push("disabled=true".to_string()); }
2856 lines.push(format!("- button[{}]", parts.join(" ")));
2857 }
2858 crate::types::ToolbarItem::Separator => lines.push("- separator".to_string()),
2859 crate::types::ToolbarItem::Spacer => lines.push("- spacer".to_string()),
2860 crate::types::ToolbarItem::Badge { value, color } => {
2861 let mut parts = vec![format!("value=\"{}\"", escape_attr(value))];
2862 if let Some(c) = color { parts.push(format!("color={c}")); }
2863 lines.push(format!("- badge[{}]", parts.join(" ")));
2864 }
2865 crate::types::ToolbarItem::Dropdown { label, options, action } => {
2866 let mut parts = vec![format!("label=\"{}\"", escape_attr(label))];
2867 if let Some(o) = options { parts.push(format!("options=\"{}\"", escape_attr(o))); }
2868 if let Some(a) = action { parts.push(format!("action={a}")); }
2869 lines.push(format!("- dropdown[{}]", parts.join(" ")));
2870 }
2871 crate::types::ToolbarItem::Text { value, editable, action } => {
2872 let mut parts = vec![format!("value=\"{}\"", escape_attr(value))];
2873 if *editable { parts.push("editable=true".to_string()); }
2874 if let Some(a) = action { parts.push(format!("action={a}")); }
2875 lines.push(format!("- text[{}]", parts.join(" ")));
2876 }
2877 }
2878 }
2879 let content = lines.join("\n");
2880 if content.is_empty() {
2881 "::toolbar\n::".to_string()
2882 } else {
2883 format!("::toolbar\n{content}\n::")
2884 }
2885 }
2886
2887 Block::Drawer { name, position, width, trigger, children, .. } => {
2888 let mut attrs_parts = vec![format!("name=\"{}\"", escape_attr(name)), format!("position={position}")];
2889 if let Some(w) = width { attrs_parts.push(format!("width={w}")); }
2890 if let Some(t) = trigger { attrs_parts.push(format!("trigger={t}")); }
2891 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2892 let inner = serialize_children(children);
2893 if inner.is_empty() {
2894 format!("::drawer{attrs_str}\n::")
2895 } else {
2896 format!("::drawer{attrs_str}\n{inner}\n::")
2897 }
2898 }
2899
2900 Block::Modal { name, title, children, .. } => {
2901 let mut attrs_parts = vec![format!("name=\"{}\"", escape_attr(name))];
2902 if let Some(t) = title { attrs_parts.push(format!("title=\"{}\"", escape_attr(t))); }
2903 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2904 let inner = serialize_children(children);
2905 if inner.is_empty() {
2906 format!("::modal{attrs_str}\n::")
2907 } else {
2908 format!("::modal{attrs_str}\n{inner}\n::")
2909 }
2910 }
2911
2912 Block::CommandPalette { trigger, items, .. } => {
2913 let attrs_str = match trigger {
2914 Some(t) => format!("[trigger=\"{}\"]", escape_attr(t)),
2915 None => String::new(),
2916 };
2917 let mut lines = Vec::new();
2918 for item in items {
2919 let mut parts = Vec::new();
2920 if let Some(d) = &item.description { parts.push(format!("description=\"{}\"", escape_attr(d))); }
2921 if let Some(a) = &item.action { parts.push(format!("action={a}")); }
2922 if let Some(i) = &item.icon { parts.push(format!("icon={i}")); }
2923 if let Some(g) = &item.group { parts.push(format!("group={g}")); }
2924 let extra = if parts.is_empty() { String::new() } else { format!(" {}", parts.join(" ")) };
2925 lines.push(format!("- \"{}\"{extra}", escape_attr(&item.label)));
2926 }
2927 let content = lines.join("\n");
2928 if content.is_empty() {
2929 format!("::command-palette{attrs_str}\n::")
2930 } else {
2931 format!("::command-palette{attrs_str}\n{content}\n::")
2932 }
2933 }
2934
2935 Block::CodeEditor { lang, source, line_numbers, content, .. } => {
2936 let mut attrs_parts = Vec::new();
2937 if let Some(l) = lang { attrs_parts.push(format!("lang={l}")); }
2938 if let Some(s) = source { attrs_parts.push(format!("source={s}")); }
2939 if *line_numbers { attrs_parts.push("line-numbers=true".to_string()); }
2940 let attrs_str = if attrs_parts.is_empty() {
2941 String::new()
2942 } else {
2943 format!("[{}]", attrs_parts.join(" "))
2944 };
2945 if content.is_empty() {
2946 format!("::code-editor{attrs_str}\n::")
2947 } else {
2948 format!("::code-editor{attrs_str}\n{content}\n::")
2949 }
2950 }
2951
2952 Block::BlockEditor { source, .. } => {
2953 let attrs_str = match source {
2954 Some(s) => format!("[source={s}]"),
2955 None => String::new(),
2956 };
2957 format!("::block-editor{attrs_str}\n::")
2958 }
2959
2960 Block::Terminal { shell, cwd, .. } => {
2961 let mut attrs_parts = Vec::new();
2962 if let Some(s) = shell { attrs_parts.push(format!("shell={s}")); }
2963 if let Some(c) = cwd { attrs_parts.push(format!("cwd={c}")); }
2964 let attrs_str = if attrs_parts.is_empty() {
2965 String::new()
2966 } else {
2967 format!("[{}]", attrs_parts.join(" "))
2968 };
2969 format!("::terminal{attrs_str}\n::")
2970 }
2971
2972 Block::NavTree { source, on_select, on_rename, on_delete, .. } => {
2973 let mut attrs_parts = Vec::new();
2974 if let Some(s) = source { attrs_parts.push(format!("source={s}")); }
2975 if let Some(s) = on_select { attrs_parts.push(format!("on-select={s}")); }
2976 if let Some(s) = on_rename { attrs_parts.push(format!("on-rename={s}")); }
2977 if let Some(s) = on_delete { attrs_parts.push(format!("on-delete={s}")); }
2978 let attrs_str = if attrs_parts.is_empty() {
2979 String::new()
2980 } else {
2981 format!("[{}]", attrs_parts.join(" "))
2982 };
2983 format!("::nav-tree{attrs_str}\n::")
2984 }
2985
2986 Block::Badge { value, color, .. } => {
2987 let mut attrs_parts = vec![format!("value=\"{}\"", escape_attr(value))];
2988 if let Some(c) = color { attrs_parts.push(format!("color={c}")); }
2989 let attrs_str = format!("[{}]", attrs_parts.join(" "));
2990 format!("::badge{attrs_str}\n::")
2991 }
2992
2993 Block::SuggestionChips { source, max, dismissible, .. } => {
2994 let mut attrs_parts = Vec::new();
2995 if let Some(s) = source { attrs_parts.push(format!("source={s}")); }
2996 if let Some(m) = max { attrs_parts.push(format!("max={m}")); }
2997 if *dismissible { attrs_parts.push("dismissible=true".to_string()); }
2998 let attrs_str = if attrs_parts.is_empty() {
2999 String::new()
3000 } else {
3001 format!("[{}]", attrs_parts.join(" "))
3002 };
3003 format!("::suggestion-chips{attrs_str}\n::")
3004 }
3005
3006 Block::ChatThread { source, on_action, .. } => {
3007 let mut attrs_parts = Vec::new();
3008 if let Some(s) = source { attrs_parts.push(format!("source={s}")); }
3009 if let Some(a) = on_action { attrs_parts.push(format!("on-action={a}")); }
3010 let attrs_str = if attrs_parts.is_empty() {
3011 String::new()
3012 } else {
3013 format!("[{}]", attrs_parts.join(" "))
3014 };
3015 format!("::chat-thread{attrs_str}\n::")
3016 }
3017
3018 Block::ChatInputSimple { placeholder, action, .. } => {
3019 let mut attrs_parts = Vec::new();
3020 if let Some(p) = placeholder { attrs_parts.push(format!("placeholder=\"{}\"", escape_attr(p))); }
3021 if let Some(a) = action { attrs_parts.push(format!("action={a}")); }
3022 let attrs_str = if attrs_parts.is_empty() {
3023 String::new()
3024 } else {
3025 format!("[{}]", attrs_parts.join(" "))
3026 };
3027 format!("::chat-input-simple{attrs_str}\n::")
3028 }
3029
3030 Block::Progress { source, steps, .. } => {
3031 let attrs_str = match source {
3032 Some(s) => format!("[source={s}]"),
3033 None => String::new(),
3034 };
3035 let mut lines = Vec::new();
3036 for step in steps {
3037 let marker = match step.status.as_str() {
3038 "done" => "\u{2713}",
3039 "active" => "\u{25CF}",
3040 _ => "\u{25CB}",
3041 };
3042 lines.push(format!("- {} {marker}", step.label));
3043 }
3044 let content = lines.join("\n");
3045 if content.is_empty() {
3046 format!("::progress{attrs_str}\n::")
3047 } else {
3048 format!("::progress{attrs_str}\n{content}\n::")
3049 }
3050 }
3051
3052 Block::LogStream { source, tail, .. } => {
3053 let mut attrs_parts = Vec::new();
3054 if let Some(s) = source { attrs_parts.push(format!("source={s}")); }
3055 if let Some(t) = tail { attrs_parts.push(format!("tail={t}")); }
3056 let attrs_str = if attrs_parts.is_empty() {
3057 String::new()
3058 } else {
3059 format!("[{}]", attrs_parts.join(" "))
3060 };
3061 format!("::log-stream{attrs_str}\n::")
3062 }
3063
3064 Block::ProblemList { source, .. } => {
3065 let attrs_str = match source {
3066 Some(s) => format!("[source={s}]"),
3067 None => String::new(),
3068 };
3069 format!("::problem-list{attrs_str}\n::")
3070 }
3071 }
3072}
3073
3074fn serialize_children(children: &[Block]) -> String {
3080 let parts: Vec<String> = children.iter().map(|b| serialize_block(b)).collect();
3081 parts.join("\n\n")
3082}
3083
3084fn callout_type_str(ct: CalloutType) -> &'static str {
3085 match ct {
3086 CalloutType::Info => "info",
3087 CalloutType::Warning => "warning",
3088 CalloutType::Danger => "danger",
3089 CalloutType::Tip => "tip",
3090 CalloutType::Note => "note",
3091 CalloutType::Success => "success",
3092 CalloutType::Context => "context",
3093 }
3094}
3095
3096fn decision_status_str(ds: DecisionStatus) -> &'static str {
3097 match ds {
3098 DecisionStatus::Proposed => "proposed",
3099 DecisionStatus::Accepted => "accepted",
3100 DecisionStatus::Rejected => "rejected",
3101 DecisionStatus::Superseded => "superseded",
3102 }
3103}
3104
3105fn trend_str(t: Trend) -> &'static str {
3106 match t {
3107 Trend::Up => "up",
3108 Trend::Down => "down",
3109 Trend::Flat => "flat",
3110 }
3111}
3112
3113fn citation_format_slug(f: Format) -> &'static str {
3114 match f {
3115 Format::Ieee => "ieee",
3116 Format::Acm => "acm",
3117 Format::Article => "article",
3118 Format::Mla => "mla",
3119 Format::Apa => "apa",
3120 Format::Chicago => "chicago",
3121 }
3122}
3123
3124fn ref_type_str(t: RefType) -> &'static str {
3125 match t {
3126 RefType::Article => "article",
3127 RefType::Book => "book",
3128 RefType::Chapter => "chapter",
3129 RefType::Web => "web",
3130 RefType::Report => "report",
3131 RefType::Conference => "conference",
3132 }
3133}
3134
3135fn authors_to_string(authors: &[Author]) -> String {
3137 authors
3138 .iter()
3139 .map(|a| match &a.given {
3140 Some(g) => format!("{}, {}", a.family, g),
3141 None => a.family.clone(),
3142 })
3143 .collect::<Vec<_>>()
3144 .join("; ")
3145}
3146
3147fn cite_to_surf(r: &Reference) -> String {
3149 let mut lines: Vec<String> = Vec::new();
3150 let attrs = format!("[key={} type={}]", r.key, ref_type_str(r.ref_type));
3151 if !r.authors.is_empty() {
3152 lines.push(format!("author = {}", authors_to_string(&r.authors)));
3153 }
3154 if !r.editors.is_empty() {
3155 lines.push(format!("editor = {}", authors_to_string(&r.editors)));
3156 }
3157 let field = |name: &str, v: &Option<String>| v.as_ref().map(|x| format!("{name} = {x}"));
3158 for l in [
3159 field("title", &r.title),
3160 field("container", &r.container),
3161 field("publisher", &r.publisher),
3162 field("year", &r.year),
3163 field("volume", &r.volume),
3164 field("issue", &r.issue),
3165 field("pages", &r.pages),
3166 field("url", &r.url),
3167 field("doi", &r.doi),
3168 field("accessed", &r.accessed),
3169 field("edition", &r.edition),
3170 ]
3171 .into_iter()
3172 .flatten()
3173 {
3174 lines.push(l);
3175 }
3176 if lines.is_empty() {
3177 format!("::cite{attrs}\n::")
3178 } else {
3179 format!("::cite{attrs}\n{}\n::", lines.join("\n"))
3180 }
3181}
3182
3183fn embed_type_str(et: EmbedType) -> &'static str {
3184 match et {
3185 EmbedType::Map => "map",
3186 EmbedType::Video => "video",
3187 EmbedType::Audio => "audio",
3188 EmbedType::Generic => "generic",
3189 }
3190}
3191
3192fn form_field_type_str(ft: FormFieldType) -> &'static str {
3193 match ft {
3194 FormFieldType::Text => "text",
3195 FormFieldType::Email => "email",
3196 FormFieldType::Tel => "tel",
3197 FormFieldType::Date => "date",
3198 FormFieldType::Number => "number",
3199 FormFieldType::Password => "password",
3200 FormFieldType::Select => "select",
3201 FormFieldType::Textarea => "textarea",
3202 }
3203}
3204
3205fn escape_attr(s: &str) -> String {
3207 s.replace('\\', "\\\\").replace('"', "\\\"")
3208}
3209
3210fn serialize_nav_item(item: &crate::types::NavItem) -> String {
3213 let mut brace = Vec::new();
3214 if let Some(icon) = &item.icon {
3215 brace.push(format!("icon={}", icon));
3216 }
3217 if let Some(image) = &item.image {
3218 brace.push(format!("image={}", image));
3219 }
3220 if item.external {
3221 brace.push("external".to_string());
3222 }
3223 if brace.is_empty() {
3224 format!("- [{}]({})", item.label, item.href)
3225 } else {
3226 format!("- [{}]({}) {{{}}}", item.label, item.href, brace.join(" "))
3227 }
3228}
3229
3230fn model_field_type_surf(ft: &crate::types::ModelFieldType) -> String {
3231 use crate::types::ModelFieldType;
3232 match ft {
3233 ModelFieldType::Uuid => "uuid".to_string(),
3234 ModelFieldType::String => "string".to_string(),
3235 ModelFieldType::Int => "int".to_string(),
3236 ModelFieldType::Float => "float".to_string(),
3237 ModelFieldType::Bool => "bool".to_string(),
3238 ModelFieldType::Datetime => "datetime".to_string(),
3239 ModelFieldType::Text => "text".to_string(),
3240 ModelFieldType::Json => "json".to_string(),
3241 ModelFieldType::Money => "money".to_string(),
3242 ModelFieldType::Image => "image".to_string(),
3243 ModelFieldType::Email => "email".to_string(),
3244 ModelFieldType::Url => "url".to_string(),
3245 ModelFieldType::Enum(variants) => format!("enum({})", variants.join(", ")),
3246 ModelFieldType::Ref(target) => format!("ref({target})"),
3247 }
3248}
3249
3250fn constraint_surf(c: &crate::types::FieldConstraint) -> String {
3251 use crate::types::FieldConstraint;
3252 match c {
3253 FieldConstraint::Primary => "primary".to_string(),
3254 FieldConstraint::Auto => "auto".to_string(),
3255 FieldConstraint::Required => "required".to_string(),
3256 FieldConstraint::Optional => "optional".to_string(),
3257 FieldConstraint::Unique => "unique".to_string(),
3258 FieldConstraint::Index => "index".to_string(),
3259 FieldConstraint::Max(n) => format!("max={n}"),
3260 FieldConstraint::Min(n) => format!("min={n}"),
3261 FieldConstraint::Default(v) => format!("default={v}"),
3262 }
3263}
3264
3265fn serialize_attrs(attrs: &crate::types::Attrs) -> String {
3267 let mut parts = Vec::new();
3268 for (key, value) in attrs {
3269 match value {
3270 crate::types::AttrValue::String(s) => {
3271 if s.contains(' ') || s.contains('"') {
3272 parts.push(format!("{key}=\"{}\"", escape_attr(s)));
3273 } else {
3274 parts.push(format!("{key}={s}"));
3275 }
3276 }
3277 crate::types::AttrValue::Number(n) => parts.push(format!("{key}={n}")),
3278 crate::types::AttrValue::Bool(true) => parts.push(key.clone()),
3279 crate::types::AttrValue::Bool(false) => parts.push(format!("{key}=false")),
3280 crate::types::AttrValue::Null => parts.push(format!("{key}=null")),
3281 }
3282 }
3283 parts.join(" ")
3284}
3285
3286#[cfg(test)]
3291mod tests {
3292 use super::*;
3293 use crate::parse;
3294 use crate::types::*;
3295
3296 #[test]
3299 fn test_empty_doc() {
3300 let doc = SurfDocBuilder::new().build();
3301 assert!(doc.front_matter.is_none());
3302 assert!(doc.blocks.is_empty());
3303 }
3304
3305 #[test]
3306 fn test_with_front_matter() {
3307 let doc = SurfDocBuilder::new()
3308 .title("My Doc")
3309 .doc_type(DocType::Guide)
3310 .status(DocStatus::Active)
3311 .author("Brady")
3312 .build();
3313
3314 let fm = doc.front_matter.unwrap();
3315 assert_eq!(fm.title.unwrap(), "My Doc");
3316 assert_eq!(fm.doc_type.unwrap(), DocType::Guide);
3317 assert_eq!(fm.status.unwrap(), DocStatus::Active);
3318 assert_eq!(fm.author.unwrap(), "Brady");
3319 }
3320
3321 #[test]
3322 fn test_front_matter_tags_and_description() {
3323 let doc = SurfDocBuilder::new()
3324 .tags(vec!["rust".into(), "parser".into()])
3325 .description("A test document")
3326 .build();
3327
3328 let fm = doc.front_matter.unwrap();
3329 assert_eq!(fm.tags.unwrap(), vec!["rust", "parser"]);
3330 assert_eq!(fm.description.unwrap(), "A test document");
3331 }
3332
3333 #[test]
3334 fn test_set_full_front_matter() {
3335 let mut fm = FrontMatter::default();
3336 fm.title = Some("Override".into());
3337 fm.version = Some(3);
3338
3339 let doc = SurfDocBuilder::new().front_matter(fm).build();
3340 let fm = doc.front_matter.unwrap();
3341 assert_eq!(fm.title.unwrap(), "Override");
3342 assert_eq!(fm.version.unwrap(), 3);
3343 }
3344
3345 #[test]
3346 fn test_markdown_block() {
3347 let doc = SurfDocBuilder::new()
3348 .markdown("Hello **world**")
3349 .build();
3350
3351 assert_eq!(doc.blocks.len(), 1);
3352 match &doc.blocks[0] {
3353 Block::Markdown { content, .. } => assert_eq!(content, "Hello **world**"),
3354 _ => panic!("Expected Markdown block"),
3355 }
3356 }
3357
3358 #[test]
3359 fn test_heading_sugar() {
3360 let doc = SurfDocBuilder::new().heading(2, "Foo").build();
3361
3362 assert_eq!(doc.blocks.len(), 1);
3363 match &doc.blocks[0] {
3364 Block::Markdown { content, .. } => assert_eq!(content, "## Foo"),
3365 _ => panic!("Expected Markdown block"),
3366 }
3367 }
3368
3369 #[test]
3370 fn test_callout() {
3371 let doc = SurfDocBuilder::new()
3372 .callout(CalloutType::Warning, "Be careful!")
3373 .build();
3374
3375 match &doc.blocks[0] {
3376 Block::Callout {
3377 callout_type,
3378 title,
3379 content,
3380 ..
3381 } => {
3382 assert_eq!(*callout_type, CalloutType::Warning);
3383 assert!(title.is_none());
3384 assert_eq!(content, "Be careful!");
3385 }
3386 _ => panic!("Expected Callout block"),
3387 }
3388 }
3389
3390 #[test]
3391 fn test_callout_titled() {
3392 let doc = SurfDocBuilder::new()
3393 .callout_titled(CalloutType::Tip, "Pro Tip", "Use the builder!")
3394 .build();
3395
3396 match &doc.blocks[0] {
3397 Block::Callout {
3398 callout_type,
3399 title,
3400 content,
3401 ..
3402 } => {
3403 assert_eq!(*callout_type, CalloutType::Tip);
3404 assert_eq!(title.as_deref(), Some("Pro Tip"));
3405 assert_eq!(content, "Use the builder!");
3406 }
3407 _ => panic!("Expected Callout block"),
3408 }
3409 }
3410
3411 #[test]
3412 fn test_code_block() {
3413 let doc = SurfDocBuilder::new()
3414 .code("fn main() {}", Some("rust"))
3415 .build();
3416
3417 match &doc.blocks[0] {
3418 Block::Code {
3419 lang,
3420 file,
3421 content,
3422 ..
3423 } => {
3424 assert_eq!(lang.as_deref(), Some("rust"));
3425 assert!(file.is_none());
3426 assert_eq!(content, "fn main() {}");
3427 }
3428 _ => panic!("Expected Code block"),
3429 }
3430 }
3431
3432 #[test]
3433 fn test_code_file() {
3434 let doc = SurfDocBuilder::new()
3435 .code_file("fn main() {}", "rust", "src/main.rs")
3436 .build();
3437
3438 match &doc.blocks[0] {
3439 Block::Code {
3440 lang,
3441 file,
3442 content,
3443 ..
3444 } => {
3445 assert_eq!(lang.as_deref(), Some("rust"));
3446 assert_eq!(file.as_deref(), Some("src/main.rs"));
3447 assert_eq!(content, "fn main() {}");
3448 }
3449 _ => panic!("Expected Code block"),
3450 }
3451 }
3452
3453 #[test]
3454 fn test_data_table() {
3455 let doc = SurfDocBuilder::new()
3456 .data_table(
3457 vec!["Name".into(), "Age".into()],
3458 vec![vec!["Alice".into(), "30".into()]],
3459 )
3460 .build();
3461
3462 match &doc.blocks[0] {
3463 Block::Data {
3464 headers, rows, ..
3465 } => {
3466 assert_eq!(headers, &vec!["Name".to_string(), "Age".to_string()]);
3467 assert_eq!(rows.len(), 1);
3468 assert_eq!(rows[0], vec!["Alice".to_string(), "30".to_string()]);
3469 }
3470 _ => panic!("Expected Data block"),
3471 }
3472 }
3473
3474 #[test]
3475 fn test_single_task() {
3476 let doc = SurfDocBuilder::new()
3477 .task("Write tests", false)
3478 .task("Ship it", true)
3479 .build();
3480
3481 assert_eq!(doc.blocks.len(), 1);
3483 match &doc.blocks[0] {
3484 Block::Tasks { items, .. } => {
3485 assert_eq!(items.len(), 2);
3486 assert!(!items[0].done);
3487 assert_eq!(items[0].text, "Write tests");
3488 assert!(items[1].done);
3489 assert_eq!(items[1].text, "Ship it");
3490 }
3491 _ => panic!("Expected Tasks block"),
3492 }
3493 }
3494
3495 #[test]
3496 fn test_tasks() {
3497 let items = vec![
3498 TaskItem {
3499 done: true,
3500 text: "Done".into(),
3501 assignee: Some("brady".into()),
3502 },
3503 TaskItem {
3504 done: false,
3505 text: "Todo".into(),
3506 assignee: None,
3507 },
3508 ];
3509 let doc = SurfDocBuilder::new().tasks(items).build();
3510
3511 match &doc.blocks[0] {
3512 Block::Tasks { items, .. } => {
3513 assert_eq!(items.len(), 2);
3514 assert_eq!(items[0].assignee.as_deref(), Some("brady"));
3515 }
3516 _ => panic!("Expected Tasks block"),
3517 }
3518 }
3519
3520 #[test]
3521 fn test_decision() {
3522 let doc = SurfDocBuilder::new()
3523 .decision(DecisionStatus::Accepted, "We chose Rust.")
3524 .build();
3525
3526 match &doc.blocks[0] {
3527 Block::Decision {
3528 status, content, ..
3529 } => {
3530 assert_eq!(*status, DecisionStatus::Accepted);
3531 assert_eq!(content, "We chose Rust.");
3532 }
3533 _ => panic!("Expected Decision block"),
3534 }
3535 }
3536
3537 #[test]
3538 fn test_metric() {
3539 let doc = SurfDocBuilder::new().metric("MRR", "$2K").build();
3540
3541 match &doc.blocks[0] {
3542 Block::Metric {
3543 label,
3544 value,
3545 trend,
3546 ..
3547 } => {
3548 assert_eq!(label, "MRR");
3549 assert_eq!(value, "$2K");
3550 assert!(trend.is_none());
3551 }
3552 _ => panic!("Expected Metric block"),
3553 }
3554 }
3555
3556 #[test]
3557 fn test_metric_with_trend() {
3558 let doc = SurfDocBuilder::new()
3559 .metric_with_trend("Revenue", "$10K", Trend::Up, Some("USD"))
3560 .build();
3561
3562 match &doc.blocks[0] {
3563 Block::Metric {
3564 label,
3565 value,
3566 trend,
3567 unit,
3568 ..
3569 } => {
3570 assert_eq!(label, "Revenue");
3571 assert_eq!(value, "$10K");
3572 assert_eq!(*trend, Some(Trend::Up));
3573 assert_eq!(unit.as_deref(), Some("USD"));
3574 }
3575 _ => panic!("Expected Metric block"),
3576 }
3577 }
3578
3579 #[test]
3580 fn test_fluent_chaining() {
3581 let doc = SurfDocBuilder::new()
3582 .title("Test")
3583 .heading(1, "Hello")
3584 .callout(CalloutType::Info, "Note")
3585 .code("x = 1", Some("python"))
3586 .summary("A summary")
3587 .build();
3588
3589 assert!(doc.front_matter.is_some());
3590 assert_eq!(doc.blocks.len(), 4);
3591 }
3592
3593 #[test]
3594 fn test_default_impl() {
3595 let builder: SurfDocBuilder = Default::default();
3596 let doc = builder.build();
3597 assert!(doc.front_matter.is_none());
3598 assert!(doc.blocks.is_empty());
3599 }
3600
3601 #[test]
3604 fn test_serialize_markdown() {
3605 let doc = SurfDocBuilder::new()
3606 .markdown("# Hello World\n\nSome text here.")
3607 .build();
3608 let source = to_surf_source(&doc);
3609 assert!(source.contains("# Hello World"));
3610 assert!(source.contains("Some text here."));
3611 assert!(!source.contains("::"));
3613 }
3614
3615 #[test]
3616 fn test_serialize_callout() {
3617 let doc = SurfDocBuilder::new()
3618 .callout(CalloutType::Warning, "Careful!")
3619 .build();
3620 let source = to_surf_source(&doc);
3621 assert!(source.contains("::callout[type=warning]"));
3622 assert!(source.contains("Careful!"));
3623 assert!(source.contains("\n::"));
3624 }
3625
3626 #[test]
3627 fn test_serialize_callout_with_title() {
3628 let doc = SurfDocBuilder::new()
3629 .callout_titled(CalloutType::Info, "My Title", "Content here")
3630 .build();
3631 let source = to_surf_source(&doc);
3632 assert!(source.contains("::callout[type=info title=\"My Title\"]"));
3633 assert!(source.contains("Content here"));
3634 }
3635
3636 #[test]
3637 fn test_serialize_code() {
3638 let doc = SurfDocBuilder::new()
3639 .code_file("fn main() {}", "rust", "src/main.rs")
3640 .build();
3641 let source = to_surf_source(&doc);
3642 assert!(source.contains("::code[lang=rust file=\"src/main.rs\"]"));
3643 assert!(source.contains("fn main() {}"));
3644 }
3645
3646 #[test]
3647 fn test_serialize_front_matter() {
3648 let doc = SurfDocBuilder::new()
3649 .title("My Doc")
3650 .doc_type(DocType::Guide)
3651 .status(DocStatus::Active)
3652 .author("Brady")
3653 .tags(vec!["rust".into(), "test".into()])
3654 .build();
3655 let source = to_surf_source(&doc);
3656 assert!(source.starts_with("---\n"));
3657 assert!(source.contains("title: \"My Doc\""));
3658 assert!(source.contains("type: guide"));
3659 assert!(source.contains("status: active"));
3660 assert!(source.contains("author: \"Brady\""));
3661 assert!(source.contains("tags: [rust, test]"));
3662 }
3663
3664 #[test]
3665 fn test_serialize_metric() {
3666 let doc = SurfDocBuilder::new()
3667 .metric_with_trend("MRR", "$2K", Trend::Up, Some("USD"))
3668 .build();
3669 let source = to_surf_source(&doc);
3670 assert!(source.contains("::metric[label=\"MRR\" value=\"$2K\" trend=up unit=\"USD\"]"));
3671 }
3672
3673 #[test]
3674 fn test_serialize_tasks() {
3675 let doc = SurfDocBuilder::new()
3676 .tasks(vec![
3677 TaskItem {
3678 done: true,
3679 text: "Done".into(),
3680 assignee: Some("brady".into()),
3681 },
3682 TaskItem {
3683 done: false,
3684 text: "Todo".into(),
3685 assignee: None,
3686 },
3687 ])
3688 .build();
3689 let source = to_surf_source(&doc);
3690 assert!(source.contains("::tasks"));
3691 assert!(source.contains("- [x] Done @brady"));
3692 assert!(source.contains("- [ ] Todo"));
3693 }
3694
3695 #[test]
3696 fn test_serialize_decision() {
3697 let doc = SurfDocBuilder::new()
3698 .decision(DecisionStatus::Accepted, "Use Rust.")
3699 .build();
3700 let source = to_surf_source(&doc);
3701 assert!(source.contains("::decision[status=accepted]"));
3702 assert!(source.contains("Use Rust."));
3703 }
3704
3705 #[test]
3706 fn test_serialize_figure() {
3707 let doc = SurfDocBuilder::new()
3708 .figure_with_caption("arch.png", "Architecture", Some("Diagram"))
3709 .build();
3710 let source = to_surf_source(&doc);
3711 assert!(source.contains("::figure[src=\"arch.png\" caption=\"Architecture\" alt=\"Diagram\"]"));
3712 }
3713
3714 #[test]
3715 fn test_serialize_quote() {
3716 let doc = SurfDocBuilder::new()
3717 .quote_attributed("The future is here.", "Alan Kay")
3718 .build();
3719 let source = to_surf_source(&doc);
3720 assert!(source.contains("::quote[by=\"Alan Kay\"]"));
3721 assert!(source.contains("The future is here."));
3722 }
3723
3724 #[test]
3725 fn test_serialize_cta() {
3726 let doc = SurfDocBuilder::new()
3727 .cta("Sign Up", "/signup", true)
3728 .build();
3729 let source = to_surf_source(&doc);
3730 assert!(source.contains("::cta[label=\"Sign Up\" href=\"/signup\" primary]"));
3731 }
3732
3733 #[test]
3734 fn test_serialize_hero_image() {
3735 let doc = SurfDocBuilder::new()
3736 .hero_image("hero.png", Some("Hero"))
3737 .build();
3738 let source = to_surf_source(&doc);
3739 assert!(source.contains("::hero-image[src=\"hero.png\" alt=\"Hero\"]"));
3740 }
3741
3742 #[test]
3743 fn test_serialize_hero_button_flags_round_trip() {
3744 let doc = SurfDocBuilder::new()
3745 .hero(
3746 Some("H"),
3747 None,
3748 None,
3749 vec![
3750 HeroButton { label: "Go".into(), href: "https://x.com".into(), primary: true, external: true },
3751 HeroButton { label: "Docs".into(), href: "https://y.com".into(), primary: false, external: true },
3752 HeroButton { label: "Home".into(), href: "/".into(), primary: false, external: false },
3753 ],
3754 )
3755 .build();
3756 let source = to_surf_source(&doc);
3757 assert!(source.contains("[Go](https://x.com){primary external}"));
3758 assert!(source.contains("[Docs](https://y.com){external}"));
3759 assert!(source.contains("[Home](/)\n"));
3760 let reparsed = crate::parse(&source).doc;
3762 let hero = reparsed.blocks.iter().find_map(|b| match b {
3763 Block::Hero { buttons, .. } => Some(buttons.clone()),
3764 _ => None,
3765 }).expect("hero present");
3766 assert!(hero[0].primary && hero[0].external);
3767 assert!(!hero[1].primary && hero[1].external);
3768 assert!(!hero[2].primary && !hero[2].external);
3769 }
3770
3771 #[test]
3772 fn test_serialize_summary() {
3773 let doc = SurfDocBuilder::new()
3774 .summary("Executive overview.")
3775 .build();
3776 let source = to_surf_source(&doc);
3777 assert!(source.contains("::summary\nExecutive overview.\n::"));
3778 }
3779
3780 #[test]
3781 fn test_serialize_site() {
3782 let doc = SurfDocBuilder::new()
3783 .site(
3784 Some("example.com"),
3785 vec![StyleProperty {
3786 key: "theme".into(),
3787 value: "dark".into(),
3788 }],
3789 )
3790 .build();
3791 let source = to_surf_source(&doc);
3792 assert!(source.contains("::site[domain=\"example.com\"]"));
3793 assert!(source.contains("theme: dark"));
3794 }
3795
3796 #[test]
3797 fn test_serialize_page() {
3798 let doc = SurfDocBuilder::new()
3799 .page("/about", None, Some("About Us"), "We build things.")
3800 .build();
3801 let source = to_surf_source(&doc);
3802 assert!(source.contains("::page[route=\"/about\" title=\"About Us\"]"));
3803 assert!(source.contains("We build things."));
3804 }
3805
3806 #[test]
3807 fn test_serialize_tabs() {
3808 let doc = SurfDocBuilder::new()
3809 .tabs(vec![
3810 TabPanel {
3811 label: "Overview".into(),
3812 content: "Tab 1 content".into(),
3813 },
3814 TabPanel {
3815 label: "Details".into(),
3816 content: "Tab 2 content".into(),
3817 },
3818 ])
3819 .build();
3820 let source = to_surf_source(&doc);
3821 assert!(source.contains("::tabs"));
3822 assert!(source.contains("## Overview\nTab 1 content"));
3823 assert!(source.contains("## Details\nTab 2 content"));
3824 }
3825
3826 #[test]
3827 fn test_serialize_columns() {
3828 let doc = SurfDocBuilder::new()
3829 .columns(vec![
3830 ColumnContent {
3831 content: "Left column".into(),
3832 },
3833 ColumnContent {
3834 content: "Right column".into(),
3835 },
3836 ])
3837 .build();
3838 let source = to_surf_source(&doc);
3839 assert!(source.contains("::columns"));
3840 assert!(source.contains(":::column\nLeft column\n:::"));
3841 assert!(source.contains(":::column\nRight column\n:::"));
3842 }
3843
3844 #[test]
3845 fn test_serialize_testimonial() {
3846 let doc = SurfDocBuilder::new()
3847 .testimonial("Amazing tool!", Some("Jane"), Some("CTO"), Some("Acme"))
3848 .build();
3849 let source = to_surf_source(&doc);
3850 assert!(source.contains("::testimonial[author=\"Jane\" role=\"CTO\" company=\"Acme\"]"));
3851 assert!(source.contains("Amazing tool!"));
3852 }
3853
3854 #[test]
3855 fn test_serialize_style() {
3856 let doc = SurfDocBuilder::new()
3857 .style(vec![
3858 StyleProperty {
3859 key: "accent".into(),
3860 value: "#6366f1".into(),
3861 },
3862 ])
3863 .build();
3864 let source = to_surf_source(&doc);
3865 assert!(source.contains("::style\naccent: #6366f1\n::"));
3866 }
3867
3868 #[test]
3869 fn test_serialize_faq() {
3870 let doc = SurfDocBuilder::new()
3871 .faq(vec![FaqItem {
3872 question: "Is it free?".into(),
3873 answer: "Yes.".into(),
3874 }])
3875 .build();
3876 let source = to_surf_source(&doc);
3877 assert!(source.contains("::faq"));
3878 assert!(source.contains("### Is it free?\nYes."));
3879 }
3880
3881 #[test]
3882 fn test_serialize_pricing_table() {
3883 let doc = SurfDocBuilder::new()
3884 .pricing_table(
3885 vec!["Plan".into(), "Price".into()],
3886 vec![vec!["Free".into(), "$0".into()]],
3887 )
3888 .build();
3889 let source = to_surf_source(&doc);
3890 assert!(source.contains("::pricing-table"));
3891 assert!(source.contains("| Plan | Price |"));
3892 assert!(source.contains("| Free | $0 |"));
3893 }
3894
3895 #[test]
3896 fn test_serialize_nav() {
3897 let doc = SurfDocBuilder::new()
3898 .nav(
3899 vec![NavItem {
3900 label: "Home".into(),
3901 href: "/".into(),
3902 icon: None,
3903 image: None, external: false,
3904 }],
3905 None,
3906 )
3907 .build();
3908 let source = to_surf_source(&doc);
3909 assert!(source.contains("::nav"));
3910 assert!(source.contains("- [Home](/)"));
3911 }
3912
3913 #[test]
3914 fn test_serialize_embed() {
3915 let doc = SurfDocBuilder::new()
3916 .embed("https://youtube.com/watch?v=123", Some(EmbedType::Video), Some("My Video"))
3917 .build();
3918 let source = to_surf_source(&doc);
3919 assert!(source.contains("::embed[src=\"https://youtube.com/watch?v=123\" type=video title=\"My Video\"]"));
3920 }
3921
3922 #[test]
3923 fn test_serialize_gallery() {
3924 let doc = SurfDocBuilder::new()
3925 .gallery(
3926 vec![GalleryItem {
3927 src: "photo.jpg".into(),
3928 caption: Some("A photo".into()),
3929 alt: Some("Photo".into()),
3930 category: None,
3931 }],
3932 Some(3),
3933 )
3934 .build();
3935 let source = to_surf_source(&doc);
3936 assert!(source.contains("::gallery"));
3937 assert!(source.contains(" A photo"));
3938 }
3939
3940 #[test]
3941 fn test_serialize_footer() {
3942 let doc = SurfDocBuilder::new()
3943 .footer(
3944 vec![FooterSection {
3945 heading: "Links".into(),
3946 links: vec![NavItem {
3947 label: "Home".into(),
3948 href: "/".into(),
3949 icon: None,
3950 image: None, external: false,
3951 }],
3952 }],
3953 Some("(c) 2026 CloudSurf"),
3954 vec![SocialLink {
3955 platform: "twitter".into(),
3956 href: "https://twitter.com/cloudsurf".into(),
3957 }],
3958 )
3959 .build();
3960 let source = to_surf_source(&doc);
3961 assert!(source.contains("::footer"));
3962 assert!(source.contains("## Links"));
3963 assert!(source.contains("- [Home](/)"));
3964 assert!(source.contains("@twitter https://twitter.com/cloudsurf"));
3965 assert!(source.contains("(c) 2026 CloudSurf"));
3966 }
3967
3968 #[test]
3971 fn test_roundtrip_basic() {
3972 let original = SurfDocBuilder::new()
3973 .heading(1, "Hello")
3974 .callout(CalloutType::Info, "Important note")
3975 .markdown("Some text here.")
3976 .build();
3977
3978 let source = to_surf_source(&original);
3979 let parsed = parse::parse(&source);
3980
3981 assert!(
3982 parsed.diagnostics.is_empty(),
3983 "Parse diagnostics: {:?}",
3984 parsed.diagnostics
3985 );
3986 assert_eq!(parsed.doc.blocks.len(), original.blocks.len());
3987
3988 match &parsed.doc.blocks[0] {
3990 Block::Markdown { content, .. } => assert!(content.contains("# Hello")),
3991 _ => panic!("Expected Markdown block, got {:?}", parsed.doc.blocks[0]),
3992 }
3993
3994 match &parsed.doc.blocks[1] {
3996 Block::Callout {
3997 callout_type,
3998 content,
3999 ..
4000 } => {
4001 assert_eq!(*callout_type, CalloutType::Info);
4002 assert_eq!(content, "Important note");
4003 }
4004 _ => panic!("Expected Callout block, got {:?}", parsed.doc.blocks[1]),
4005 }
4006
4007 match &parsed.doc.blocks[2] {
4009 Block::Markdown { content, .. } => assert!(content.contains("Some text here.")),
4010 _ => panic!("Expected Markdown block, got {:?}", parsed.doc.blocks[2]),
4011 }
4012 }
4013
4014 #[test]
4015 fn test_roundtrip_front_matter() {
4016 let original = SurfDocBuilder::new()
4017 .title("Round Trip Test")
4018 .doc_type(DocType::Guide)
4019 .status(DocStatus::Active)
4020 .author("Brady")
4021 .tags(vec!["test".into(), "roundtrip".into()])
4022 .description("Testing round-trip")
4023 .markdown("Body text.")
4024 .build();
4025
4026 let source = to_surf_source(&original);
4027 let parsed = parse::parse(&source);
4028
4029 assert!(
4030 parsed.diagnostics.is_empty(),
4031 "Parse diagnostics: {:?}",
4032 parsed.diagnostics
4033 );
4034
4035 let fm = parsed.doc.front_matter.as_ref().unwrap();
4036 assert_eq!(fm.title.as_deref(), Some("Round Trip Test"));
4037 assert_eq!(fm.doc_type, Some(DocType::Guide));
4038 assert_eq!(fm.status, Some(DocStatus::Active));
4039 assert_eq!(fm.author.as_deref(), Some("Brady"));
4040 assert_eq!(
4041 fm.tags.as_ref().unwrap(),
4042 &vec!["test".to_string(), "roundtrip".to_string()]
4043 );
4044 assert_eq!(fm.description.as_deref(), Some("Testing round-trip"));
4045 }
4046
4047 #[test]
4048 fn test_roundtrip_all_blocks() {
4049 let original = SurfDocBuilder::new()
4050 .title("All Blocks")
4051 .heading(1, "Introduction")
4052 .callout(CalloutType::Warning, "Watch out!")
4053 .code("fn main() {}", Some("rust"))
4054 .tasks(vec![
4055 TaskItem {
4056 done: true,
4057 text: "Implement parser".into(),
4058 assignee: None,
4059 },
4060 TaskItem {
4061 done: false,
4062 text: "Write tests".into(),
4063 assignee: Some("brady".into()),
4064 },
4065 ])
4066 .decision(DecisionStatus::Accepted, "We chose Rust.")
4067 .metric_with_trend("MRR", "$2K", Trend::Up, Some("USD"))
4068 .summary("A summary of the document.")
4069 .figure_with_caption("diagram.png", "Architecture", Some("Diagram"))
4070 .quote_attributed("The future is here.", "Alan Kay")
4071 .cta("Sign Up", "/signup", true)
4072 .hero_image("hero.png", Some("Hero shot"))
4073 .testimonial("Great tool!", Some("Jane"), Some("CTO"), None)
4074 .data_table(
4075 vec!["Name".into(), "Value".into()],
4076 vec![vec!["A".into(), "1".into()]],
4077 )
4078 .build();
4079
4080 let source = to_surf_source(&original);
4081 let parsed = parse::parse(&source);
4082
4083 assert!(
4084 parsed.diagnostics.is_empty(),
4085 "Parse diagnostics: {:?}\nSource:\n{}",
4086 parsed.diagnostics,
4087 source
4088 );
4089
4090 let blocks = &parsed.doc.blocks;
4092 assert_eq!(
4093 blocks.len(),
4094 original.blocks.len(),
4095 "Block count mismatch.\nSource:\n{}\nParsed blocks: {:?}",
4096 source,
4097 blocks
4098 );
4099
4100 assert!(matches!(&blocks[0], Block::Markdown { .. }), "Block 0: Markdown heading");
4102 assert!(matches!(&blocks[1], Block::Callout { .. }), "Block 1: Callout");
4103 assert!(matches!(&blocks[2], Block::Code { .. }), "Block 2: Code");
4104 assert!(matches!(&blocks[3], Block::Tasks { .. }), "Block 3: Tasks");
4105 assert!(matches!(&blocks[4], Block::Decision { .. }), "Block 4: Decision");
4106 assert!(matches!(&blocks[5], Block::Metric { .. }), "Block 5: Metric");
4107 assert!(matches!(&blocks[6], Block::Summary { .. }), "Block 6: Summary");
4108 assert!(matches!(&blocks[7], Block::Figure { .. }), "Block 7: Figure");
4109 assert!(matches!(&blocks[8], Block::Quote { .. }), "Block 8: Quote");
4110 assert!(matches!(&blocks[9], Block::Cta { .. }), "Block 9: Cta");
4111 assert!(matches!(&blocks[10], Block::HeroImage { .. }), "Block 10: HeroImage");
4112 assert!(matches!(&blocks[11], Block::Testimonial { .. }), "Block 11: Testimonial");
4113 assert!(matches!(&blocks[12], Block::Data { .. }), "Block 12: Data");
4114
4115 match &blocks[1] {
4117 Block::Callout {
4118 callout_type,
4119 content,
4120 ..
4121 } => {
4122 assert_eq!(*callout_type, CalloutType::Warning);
4123 assert_eq!(content, "Watch out!");
4124 }
4125 _ => unreachable!(),
4126 }
4127
4128 match &blocks[2] {
4129 Block::Code { lang, content, .. } => {
4130 assert_eq!(lang.as_deref(), Some("rust"));
4131 assert_eq!(content, "fn main() {}");
4132 }
4133 _ => unreachable!(),
4134 }
4135
4136 match &blocks[3] {
4137 Block::Tasks { items, .. } => {
4138 assert_eq!(items.len(), 2);
4139 assert!(items[0].done);
4140 assert_eq!(items[0].text, "Implement parser");
4141 assert!(!items[1].done);
4142 assert_eq!(items[1].text, "Write tests");
4143 assert_eq!(items[1].assignee.as_deref(), Some("brady"));
4144 }
4145 _ => unreachable!(),
4146 }
4147
4148 match &blocks[5] {
4149 Block::Metric {
4150 label,
4151 value,
4152 trend,
4153 unit,
4154 ..
4155 } => {
4156 assert_eq!(label, "MRR");
4157 assert_eq!(value, "$2K");
4158 assert_eq!(*trend, Some(Trend::Up));
4159 assert_eq!(unit.as_deref(), Some("USD"));
4160 }
4161 _ => unreachable!(),
4162 }
4163
4164 match &blocks[8] {
4165 Block::Quote {
4166 content,
4167 attribution,
4168 ..
4169 } => {
4170 assert_eq!(content, "The future is here.");
4171 assert_eq!(attribution.as_deref(), Some("Alan Kay"));
4172 }
4173 _ => unreachable!(),
4174 }
4175
4176 match &blocks[10] {
4177 Block::HeroImage { src, alt, .. } => {
4178 assert_eq!(src, "hero.png");
4179 assert_eq!(alt.as_deref(), Some("Hero shot"));
4180 }
4181 _ => unreachable!(),
4182 }
4183 }
4184
4185 #[test]
4186 fn test_roundtrip_style_faq_pricing() {
4187 let original = SurfDocBuilder::new()
4188 .style(vec![
4189 StyleProperty {
4190 key: "accent".into(),
4191 value: "#6366f1".into(),
4192 },
4193 StyleProperty {
4194 key: "theme".into(),
4195 value: "dark".into(),
4196 },
4197 ])
4198 .faq(vec![
4199 FaqItem {
4200 question: "Is it free?".into(),
4201 answer: "Yes.".into(),
4202 },
4203 FaqItem {
4204 question: "Can I export?".into(),
4205 answer: "PDF and HTML.".into(),
4206 },
4207 ])
4208 .pricing_table(
4209 vec!["".into(), "Free".into(), "Pro".into()],
4210 vec![vec!["Price".into(), "$0".into(), "$9/mo".into()]],
4211 )
4212 .build();
4213
4214 let source = to_surf_source(&original);
4215 let parsed = parse::parse(&source);
4216
4217 assert!(
4218 parsed.diagnostics.is_empty(),
4219 "Parse diagnostics: {:?}\nSource:\n{}",
4220 parsed.diagnostics,
4221 source
4222 );
4223
4224 assert_eq!(parsed.doc.blocks.len(), 3);
4225
4226 match &parsed.doc.blocks[0] {
4227 Block::Style { properties, .. } => {
4228 assert_eq!(properties.len(), 2);
4229 assert_eq!(properties[0].key, "accent");
4230 assert_eq!(properties[0].value, "#6366f1");
4231 }
4232 _ => panic!("Expected Style block"),
4233 }
4234
4235 match &parsed.doc.blocks[1] {
4236 Block::Faq { items, .. } => {
4237 assert_eq!(items.len(), 2);
4238 assert_eq!(items[0].question, "Is it free?");
4239 assert_eq!(items[0].answer, "Yes.");
4240 }
4241 _ => panic!("Expected Faq block"),
4242 }
4243
4244 match &parsed.doc.blocks[2] {
4245 Block::PricingTable { headers, rows, .. } => {
4246 assert_eq!(headers.len(), 3);
4247 assert_eq!(rows.len(), 1);
4248 }
4249 _ => panic!("Expected PricingTable block"),
4250 }
4251 }
4252
4253 #[test]
4254 fn test_roundtrip_site_and_page() {
4255 let original = SurfDocBuilder::new()
4256 .site(
4257 Some("example.com"),
4258 vec![
4259 StyleProperty {
4260 key: "name".into(),
4261 value: "Test Site".into(),
4262 },
4263 StyleProperty {
4264 key: "theme".into(),
4265 value: "dark".into(),
4266 },
4267 ],
4268 )
4269 .page("/", Some("hero"), Some("Home"), "Welcome to our site.")
4270 .page("/about", None, Some("About"), "About us content.")
4271 .build();
4272
4273 let source = to_surf_source(&original);
4274 let parsed = parse::parse(&source);
4275
4276 assert!(
4277 parsed.diagnostics.is_empty(),
4278 "Parse diagnostics: {:?}\nSource:\n{}",
4279 parsed.diagnostics,
4280 source
4281 );
4282
4283 assert_eq!(parsed.doc.blocks.len(), 3);
4284
4285 match &parsed.doc.blocks[0] {
4286 Block::Site {
4287 domain,
4288 properties,
4289 ..
4290 } => {
4291 assert_eq!(domain.as_deref(), Some("example.com"));
4292 assert_eq!(properties.len(), 2);
4293 }
4294 _ => panic!("Expected Site block, got {:?}", parsed.doc.blocks[0]),
4295 }
4296
4297 match &parsed.doc.blocks[1] {
4298 Block::Page {
4299 route,
4300 layout,
4301 title,
4302 ..
4303 } => {
4304 assert_eq!(route, "/");
4305 assert_eq!(layout.as_deref(), Some("hero"));
4306 assert_eq!(title.as_deref(), Some("Home"));
4307 }
4308 _ => panic!("Expected Page block, got {:?}", parsed.doc.blocks[1]),
4309 }
4310
4311 match &parsed.doc.blocks[2] {
4312 Block::Page {
4313 route,
4314 title,
4315 ..
4316 } => {
4317 assert_eq!(route, "/about");
4318 assert_eq!(title.as_deref(), Some("About"));
4319 }
4320 _ => panic!("Expected Page block, got {:?}", parsed.doc.blocks[2]),
4321 }
4322 }
4323
4324 #[test]
4325 fn test_roundtrip_tabs_and_columns() {
4326 let original = SurfDocBuilder::new()
4327 .tabs(vec![
4328 TabPanel {
4329 label: "Overview".into(),
4330 content: "Overview content here.".into(),
4331 },
4332 TabPanel {
4333 label: "Details".into(),
4334 content: "Details content here.".into(),
4335 },
4336 ])
4337 .columns(vec![
4338 ColumnContent {
4339 content: "Left column content".into(),
4340 },
4341 ColumnContent {
4342 content: "Right column content".into(),
4343 },
4344 ])
4345 .build();
4346
4347 let source = to_surf_source(&original);
4348 let parsed = parse::parse(&source);
4349
4350 assert!(
4351 parsed.diagnostics.is_empty(),
4352 "Parse diagnostics: {:?}\nSource:\n{}",
4353 parsed.diagnostics,
4354 source
4355 );
4356
4357 assert_eq!(parsed.doc.blocks.len(), 2);
4358
4359 match &parsed.doc.blocks[0] {
4360 Block::Tabs { tabs, .. } => {
4361 assert_eq!(tabs.len(), 2);
4362 assert_eq!(tabs[0].label, "Overview");
4363 assert_eq!(tabs[0].content, "Overview content here.");
4364 assert_eq!(tabs[1].label, "Details");
4365 assert_eq!(tabs[1].content, "Details content here.");
4366 }
4367 _ => panic!("Expected Tabs block"),
4368 }
4369
4370 match &parsed.doc.blocks[1] {
4371 Block::Columns { columns, .. } => {
4372 assert_eq!(columns.len(), 2);
4373 assert_eq!(columns[0].content, "Left column content");
4374 assert_eq!(columns[1].content, "Right column content");
4375 }
4376 _ => panic!("Expected Columns block"),
4377 }
4378 }
4379
4380 #[test]
4381 fn test_roundtrip_nav_embed_gallery_footer() {
4382 let original = SurfDocBuilder::new()
4383 .nav(
4384 vec![
4385 NavItem {
4386 label: "Home".into(),
4387 href: "/".into(),
4388 icon: None,
4389 image: None, external: false,
4390 },
4391 NavItem {
4392 label: "About".into(),
4393 href: "/about".into(),
4394 icon: None,
4395 image: None, external: false,
4396 },
4397 ],
4398 None,
4399 )
4400 .embed(
4401 "https://example.com/video",
4402 Some(EmbedType::Video),
4403 Some("Demo Video"),
4404 )
4405 .gallery(
4406 vec![GalleryItem {
4407 src: "photo.jpg".into(),
4408 caption: Some("A photo".into()),
4409 alt: Some("Photo".into()),
4410 category: None,
4411 }],
4412 Some(3),
4413 )
4414 .footer(
4415 vec![FooterSection {
4416 heading: "Links".into(),
4417 links: vec![NavItem {
4418 label: "Home".into(),
4419 href: "/".into(),
4420 icon: None,
4421 image: None, external: false,
4422 }],
4423 }],
4424 Some("(c) 2026 Test"),
4425 vec![SocialLink {
4426 platform: "twitter".into(),
4427 href: "https://twitter.com/test".into(),
4428 }],
4429 )
4430 .build();
4431
4432 let source = to_surf_source(&original);
4433 let parsed = parse::parse(&source);
4434
4435 assert!(
4436 parsed.diagnostics.is_empty(),
4437 "Parse diagnostics: {:?}\nSource:\n{}",
4438 parsed.diagnostics,
4439 source
4440 );
4441
4442 assert_eq!(parsed.doc.blocks.len(), 4);
4443 assert!(matches!(&parsed.doc.blocks[0], Block::Nav { .. }));
4444 assert!(matches!(&parsed.doc.blocks[1], Block::Embed { .. }));
4445 assert!(matches!(&parsed.doc.blocks[2], Block::Gallery { .. }));
4446 assert!(matches!(&parsed.doc.blocks[3], Block::Footer { .. }));
4447
4448 match &parsed.doc.blocks[0] {
4450 Block::Nav { items, .. } => {
4451 assert_eq!(items.len(), 2);
4452 assert_eq!(items[0].label, "Home");
4453 assert_eq!(items[1].label, "About");
4454 }
4455 _ => unreachable!(),
4456 }
4457
4458 match &parsed.doc.blocks[3] {
4460 Block::Footer {
4461 sections,
4462 copyright,
4463 social,
4464 ..
4465 } => {
4466 assert_eq!(sections.len(), 1);
4467 assert_eq!(sections[0].heading, "Links");
4468 assert_eq!(copyright.as_deref(), Some("(c) 2026 Test"));
4469 assert_eq!(social.len(), 1);
4470 assert_eq!(social[0].platform, "twitter");
4471 }
4472 _ => unreachable!(),
4473 }
4474 }
4475
4476 #[test]
4477 fn test_existing_fixture_roundtrip() {
4478 let fixture = include_str!("../tests/fixtures/single.surf");
4479 let first_parse = parse::parse(fixture);
4480 assert!(
4481 first_parse.diagnostics.is_empty(),
4482 "First parse diagnostics: {:?}",
4483 first_parse.diagnostics
4484 );
4485
4486 let serialized = to_surf_source(&first_parse.doc);
4487 let second_parse = parse::parse(&serialized);
4488 assert!(
4489 second_parse.diagnostics.is_empty(),
4490 "Second parse diagnostics: {:?}\nSerialized:\n{}",
4491 second_parse.diagnostics,
4492 serialized
4493 );
4494
4495 assert_eq!(
4497 first_parse.doc.blocks.len(),
4498 second_parse.doc.blocks.len(),
4499 "Block count mismatch in fixture round-trip"
4500 );
4501
4502 let fm1 = first_parse.doc.front_matter.as_ref().unwrap();
4504 let fm2 = second_parse.doc.front_matter.as_ref().unwrap();
4505 assert_eq!(fm1.title, fm2.title);
4506 assert_eq!(fm1.doc_type, fm2.doc_type);
4507 assert_eq!(fm1.status, fm2.status);
4508
4509 for (i, (b1, b2)) in first_parse
4511 .doc
4512 .blocks
4513 .iter()
4514 .zip(second_parse.doc.blocks.iter())
4515 .enumerate()
4516 {
4517 assert_eq!(
4518 std::mem::discriminant(b1),
4519 std::mem::discriminant(b2),
4520 "Block {} type mismatch: {:?} vs {:?}",
4521 i,
4522 b1,
4523 b2
4524 );
4525 }
4526 }
4527
4528 #[test]
4529 fn test_roundtrip_diagram() {
4530 let cases = [
4534 "::diagram[type=architecture title=\"System\"]\nweb: Web Frontend\napi: API\nweb -> api: HTTPS\napi <-> web\n::",
4535 "::diagram[type=erd title=\"Data Model\"]\nusers: id pk, email unique\norders: id pk, user_id fk\nusers 1--* orders: places\n::",
4536 "::diagram[type=flowchart title=\"Later\"]\nstart => end\n::",
4538 "::diagram[type=architecture]\na -> b\n::",
4540 "::diagram[type=erd title=\"Empty\"]\n::",
4542 ];
4543
4544 for case in cases {
4545 let first = parse::parse(case);
4546 assert!(
4547 first.diagnostics.is_empty(),
4548 "Parse diagnostics for {case:?}: {:?}",
4549 first.diagnostics
4550 );
4551 let source = to_surf_source(&first.doc);
4552 let second = parse::parse(&source);
4553
4554 let (Block::Diagram { diagram_type: t1, title: ti1, content: c1, .. },
4555 Block::Diagram { diagram_type: t2, title: ti2, content: c2, .. }) =
4556 (&first.doc.blocks[0], &second.doc.blocks[0])
4557 else {
4558 panic!(
4559 "Expected Diagram blocks for {case:?}, got {:?} / {:?}",
4560 first.doc.blocks, second.doc.blocks
4561 );
4562 };
4563 assert_eq!(t1, t2, "diagram_type drifted for {case:?}");
4564 assert_eq!(ti1, ti2, "title drifted for {case:?}");
4565 assert_eq!(c1, c2, "content drifted for {case:?}");
4566 }
4567 }
4568
4569 #[test]
4570 fn test_diagram_serialize_attrs() {
4571 let titled = SurfDoc {
4573 front_matter: None,
4574 blocks: vec![Block::Diagram {
4575 diagram_type: "erd".to_string(),
4576 title: Some("Data \"v2\"".to_string()),
4577 content: "users: id pk".to_string(),
4578 span: Span::SYNTHETIC,
4579 }],
4580 source: String::new(),
4581 };
4582 let source = to_surf_source(&titled);
4583 assert!(source.contains("::diagram[type=erd title=\"Data \\\"v2\\\"\"]"));
4584 assert!(source.contains("users: id pk"));
4585
4586 let bare = SurfDoc {
4588 front_matter: None,
4589 blocks: vec![Block::Diagram {
4590 diagram_type: String::new(),
4591 title: None,
4592 content: "a -> b".to_string(),
4593 span: Span::SYNTHETIC,
4594 }],
4595 source: String::new(),
4596 };
4597 let source = to_surf_source(&bare);
4598 assert!(source.contains("::diagram\na -> b\n::"));
4599 assert!(!source.contains("::diagram["));
4600 }
4601
4602 #[test]
4603 fn test_double_roundtrip() {
4604 let doc = SurfDocBuilder::new()
4606 .title("Double Trip")
4607 .heading(1, "Hello")
4608 .callout(CalloutType::Info, "Note")
4609 .code("let x = 1;", Some("rust"))
4610 .metric("MRR", "$5K")
4611 .build();
4612
4613 let source1 = to_surf_source(&doc);
4614 let parsed1 = parse::parse(&source1);
4615 let source2 = to_surf_source(&parsed1.doc);
4616 let parsed2 = parse::parse(&source2);
4617
4618 assert_eq!(
4619 parsed1.doc.blocks.len(),
4620 parsed2.doc.blocks.len(),
4621 "Block count changed between round-trips"
4622 );
4623
4624 assert_eq!(
4626 source2,
4627 to_surf_source(&parsed2.doc),
4628 "Third serialization differs from second"
4629 );
4630 }
4631}