Skip to main content

surf_parse/
builder.rs

1//! Programmatic SurfDoc builder and `.surf` source serializer.
2//!
3//! The [`SurfDocBuilder`] provides a fluent API for constructing `SurfDoc`
4//! documents without writing raw `.surf` text.  The [`to_surf_source`] function
5//! serializes any `SurfDoc` back to valid `.surf` format that round-trips
6//! through [`crate::parse`].
7
8use 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
16// -----------------------------------------------------------------------
17// SurfDocBuilder
18// -----------------------------------------------------------------------
19
20/// Fluent builder for constructing `SurfDoc` documents programmatically.
21///
22/// # Example
23///
24/// ```
25/// use surf_parse::builder::SurfDocBuilder;
26/// use surf_parse::types::CalloutType;
27///
28/// let doc = SurfDocBuilder::new()
29///     .title("My Doc")
30///     .heading(1, "Welcome")
31///     .callout(CalloutType::Info, "Important note")
32///     .build();
33///
34/// assert_eq!(doc.blocks.len(), 2);
35/// ```
36pub 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    /// Create a new empty builder.
49    pub fn new() -> Self {
50        SurfDocBuilder {
51            front_matter: None,
52            blocks: Vec::new(),
53        }
54    }
55
56    // -- Front matter setters -------------------------------------------
57
58    /// Set the document title.
59    pub fn title(mut self, title: &str) -> Self {
60        self.ensure_front_matter().title = Some(title.to_string());
61        self
62    }
63
64    /// Set the document type.
65    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    /// Set the document status.
71    pub fn status(mut self, s: crate::types::DocStatus) -> Self {
72        self.ensure_front_matter().status = Some(s);
73        self
74    }
75
76    /// Set the document author.
77    pub fn author(mut self, author: &str) -> Self {
78        self.ensure_front_matter().author = Some(author.to_string());
79        self
80    }
81
82    /// Set the document tags.
83    pub fn tags(mut self, tags: Vec<String>) -> Self {
84        self.ensure_front_matter().tags = Some(tags);
85        self
86    }
87
88    /// Set the document description.
89    pub fn description(mut self, desc: &str) -> Self {
90        self.ensure_front_matter().description = Some(desc.to_string());
91        self
92    }
93
94    /// Set the entire front matter at once.
95    pub fn front_matter(mut self, fm: FrontMatter) -> Self {
96        self.front_matter = Some(fm);
97        self
98    }
99
100    // -- Block methods --------------------------------------------------
101
102    /// Add a raw markdown block.
103    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    /// Add a markdown heading (syntactic sugar for `markdown("## text")`).
112    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    /// Add a callout block.
122    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    /// Add a callout block with a title.
133    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    /// Add a code block with optional language.
149    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    /// Add a code block with language and file path.
161    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    /// Add a data table block.
173    pub fn data_table(mut self, headers: Vec<String>, rows: Vec<Vec<String>>) -> Self {
174        // Build raw_content from headers + rows for round-tripping
175        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    /// Add a single task item.
199    pub fn task(mut self, text: &str, done: bool) -> Self {
200        // If the last block is a Tasks block, append to it.
201        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    /// Add a tasks block with multiple items.
221    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    /// Add a decision block.
230    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    /// Add a metric block.
242    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    /// Add a metric block with trend and optional unit.
254    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    /// Add a summary block.
272    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    /// Add a figure block.
281    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    /// Add a figure block with caption and optional alt text.
293    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    /// Add a quote block.
305    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    /// Add a quote block with attribution.
316    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    /// Add a call-to-action block.
327    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    /// Add a hero image block.
339    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    /// Add a testimonial block.
349    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    /// Add a style block.
367    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    /// Add an FAQ block.
376    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    /// Add a pricing table block.
385    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    /// Add a site configuration block.
395    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    /// Add a page block.
405    pub fn page(
406        mut self,
407        route: &str,
408        layout: Option<&str>,
409        title: Option<&str>,
410        content: &str,
411    ) -> Self {
412        // Parse children from content (same as the parser does)
413        let children = Vec::new(); // children are re-parsed on round-trip
414        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    /// Add a navigation block.
427    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    /// Add an embed block.
443    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    /// Add a form block.
461    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    /// Add a gallery block.
474    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    /// Add a footer block.
484    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    /// Add a tabs block.
504    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    /// Add a columns block.
513    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    /// Add a hero section block.
522    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    /// Add a features card grid block.
546    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    /// Add a steps block.
556    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    /// Add a stats block.
565    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    /// Add a comparison table block.
574    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    /// Add a logo block.
590    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    /// Add a table-of-contents block.
601    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    /// Add a before/after comparison block.
611    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    /// Add a pipeline flow block.
627    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    /// Add a section container block.
636    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    /// Add a product card block.
655    #[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    /// Add an app manifest container block.
682    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    /// Add a deploy block.
698    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    /// Add an infrastructure database block.
714    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    /// Consume the builder and produce a `SurfDoc`.
726    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    // -- Internal helpers -----------------------------------------------
736
737    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
745// -----------------------------------------------------------------------
746// to_surf_source — serializer
747// -----------------------------------------------------------------------
748
749/// Serialize a `SurfDoc` to valid `.surf` format text.
750///
751/// The output can be parsed back with [`crate::parse`] to produce an
752/// equivalent document (round-trip).
753pub 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    // Front matter
761    if let Some(fm) = front_matter {
762        out.push_str(&serialize_front_matter(fm));
763    }
764
765    // Blocks — separate each pair with a blank line.
766    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
777// -----------------------------------------------------------------------
778// Front matter serialization
779// -----------------------------------------------------------------------
780
781fn 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    // Extra fields
845    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
933// -----------------------------------------------------------------------
934// Block serialization
935// -----------------------------------------------------------------------
936
937fn serialize_block(block: &Block) -> String {
938    match block {
939        Block::Markdown { content, .. } => {
940            // Trim leading/trailing blank lines to prevent blank-line
941            // accumulation on round-trips. The separator logic between blocks
942            // already inserts the blank line.
943            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!("![{alt}]({}){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 &section.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                    // The bg field is emitted only when set, so bg-less docs
1734                    // round-trip byte-identical to the 4-field form.
1735                    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        // ----- App description blocks -----
2025
2026        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                    // Keep badge in the 4th slot: pad blurb if absent.
2196                    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            // Inline data → emit a pipe table body so the chart round-trips.
2288            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        // ----- Infrastructure manifest blocks -----
2312
2313        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        // ── Interactive / application blocks ──────────────────────
2780
2781        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
3074// -----------------------------------------------------------------------
3075// Helpers
3076// -----------------------------------------------------------------------
3077
3078/// Serialize child blocks into a single string for container blocks.
3079fn 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
3135/// Serialize an author list back to the `Last, First; Last2, First2` form.
3136fn 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
3147/// Serialize a [`Reference`] back to a `::cite` block.
3148fn 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
3205/// Escape a string value for use inside `[key="value"]` attribute brackets.
3206fn escape_attr(s: &str) -> String {
3207    s.replace('\\', "\\\\").replace('"', "\\\"")
3208}
3209
3210/// Serialize one nav row back to `.surf` source, emitting the optional
3211/// `{icon= image= external}` brace when any are set (round-trips `parse_nav`).
3212fn 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
3265/// Serialize an `Attrs` map to a string suitable for inside `[...]`.
3266fn 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// -----------------------------------------------------------------------
3287// Tests
3288// -----------------------------------------------------------------------
3289
3290#[cfg(test)]
3291mod tests {
3292    use super::*;
3293    use crate::parse;
3294    use crate::types::*;
3295
3296    // === Builder tests ==================================================
3297
3298    #[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        // Both tasks should be in one Tasks block
3482        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    // === to_surf_source tests ==========================================
3602
3603    #[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        // No :: markers for markdown
3612        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        // And the emitted source parses back to the same flags.
3761        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("![Photo](photo.jpg) 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    // === Round-trip tests ===============================================
3969
3970    #[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        // First block: markdown heading
3989        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        // Second block: callout
3995        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        // Third block: markdown
4008        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        // Verify each block type survived the round-trip
4091        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        // Verify specific block types
4101        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        // Verify specific field values survived
4116        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        // Verify nav items survived
4449        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        // Verify footer details survived
4459        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        // Verify same number of blocks
4496        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        // Verify front matter survived
4503        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        // Verify block types match
4510        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        // Round-trip (c1 acceptance): parse -> to_surf_source -> parse and
4531        // assert the Diagram fields are equal across the trip, for both kinds,
4532        // an unknown type, a no-title block, and an empty body.
4533        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            // Unknown type must survive losslessly (renders as prose fallback).
4537            "::diagram[type=flowchart title=\"Later\"]\nstart => end\n::",
4538            // No title.
4539            "::diagram[type=architecture]\na -> b\n::",
4540            // Empty body.
4541            "::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        // type then title; both quoted-title escaping and bare-type form.
4572        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        // Empty type + no title -> bare ::diagram with no attr brackets.
4587        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        // Build -> serialize -> parse -> serialize -> parse -> compare
4605        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        // The serialized forms should be identical after the first round-trip
4625        assert_eq!(
4626            source2,
4627            to_surf_source(&parsed2.doc),
4628            "Third serialization differs from second"
4629        );
4630    }
4631}