Skip to main content

surf_parse/
blocks.rs

1//! Per-block-type content parsers (Pass 2 resolution).
2//!
3//! `resolve_block` converts a `Block::Unknown` into a typed variant based on
4//! the block name. Unknown block names pass through unchanged.
5
6use crate::types::{
7    AttrValue, Attrs, Block, CalloutType, ColumnContent, DataFormat, DecisionStatus, FaqItem,
8    Span, StyleProperty, TabPanel, TaskItem, Trend,
9};
10
11/// Resolve a `Block::Unknown` into a typed variant, if the name matches a known
12/// block type. Unrecognised names are returned unchanged.
13pub fn resolve_block(block: Block) -> Block {
14    let Block::Unknown {
15        name,
16        attrs,
17        content,
18        span,
19    } = &block
20    else {
21        return block;
22    };
23
24    match name.as_str() {
25        "callout" => parse_callout(attrs, content, *span),
26        "data" => parse_data(attrs, content, *span),
27        "code" => parse_code(attrs, content, *span),
28        "tasks" => parse_tasks(content, *span),
29        "decision" => parse_decision(attrs, content, *span),
30        "metric" => parse_metric(attrs, *span),
31        "summary" => parse_summary(content, *span),
32        "figure" => parse_figure(attrs, *span),
33        "tabs" => parse_tabs(content, *span),
34        "columns" => parse_columns(content, *span),
35        "quote" => parse_quote(attrs, content, *span),
36        "cta" => parse_cta(attrs, *span),
37        "hero-image" => parse_hero_image(attrs, *span),
38        "testimonial" => parse_testimonial(attrs, content, *span),
39        "style" => parse_style(content, *span),
40        "faq" => parse_faq(content, *span),
41        "pricing-table" => parse_pricing_table(content, *span),
42        "site" => parse_site(attrs, content, *span),
43        "page" => parse_page(attrs, content, *span),
44        _ => block,
45    }
46}
47
48// ------------------------------------------------------------------
49// Attribute extraction helpers
50// ------------------------------------------------------------------
51
52fn attr_string(attrs: &Attrs, key: &str) -> Option<String> {
53    attrs.get(key).and_then(|v| match v {
54        AttrValue::String(s) => Some(s.clone()),
55        AttrValue::Number(n) => Some(n.to_string()),
56        AttrValue::Bool(b) => Some(b.to_string()),
57        AttrValue::Null => None,
58    })
59}
60
61fn attr_bool(attrs: &Attrs, key: &str) -> bool {
62    attrs
63        .get(key)
64        .is_some_and(|v| matches!(v, AttrValue::Bool(true)))
65}
66
67// ------------------------------------------------------------------
68// Per-block parsers
69// ------------------------------------------------------------------
70
71fn parse_callout(attrs: &Attrs, content: &str, span: Span) -> Block {
72    let callout_type = attr_string(attrs, "type")
73        .and_then(|s| match s.as_str() {
74            "info" => Some(CalloutType::Info),
75            "warning" => Some(CalloutType::Warning),
76            "danger" => Some(CalloutType::Danger),
77            "tip" => Some(CalloutType::Tip),
78            "note" => Some(CalloutType::Note),
79            "success" => Some(CalloutType::Success),
80            _ => None,
81        })
82        .unwrap_or(CalloutType::Info);
83
84    let title = attr_string(attrs, "title");
85
86    Block::Callout {
87        callout_type,
88        title,
89        content: content.to_string(),
90        span,
91    }
92}
93
94fn parse_data(attrs: &Attrs, content: &str, span: Span) -> Block {
95    let id = attr_string(attrs, "id");
96    let sortable = attr_bool(attrs, "sortable");
97
98    let format = attr_string(attrs, "format")
99        .and_then(|s| match s.as_str() {
100            "table" => Some(DataFormat::Table),
101            "csv" => Some(DataFormat::Csv),
102            "json" => Some(DataFormat::Json),
103            _ => None,
104        })
105        .unwrap_or(DataFormat::Table);
106
107    let (headers, rows) = match format {
108        DataFormat::Table => parse_table_content(content),
109        DataFormat::Csv => parse_csv_content(content),
110        DataFormat::Json => (Vec::new(), Vec::new()),
111    };
112
113    Block::Data {
114        id,
115        format,
116        sortable,
117        headers,
118        rows,
119        raw_content: content.to_string(),
120        span,
121    }
122}
123
124/// Parse pipe-delimited table content.
125///
126/// First non-empty line is headers. Lines that look like `|---|---|` are
127/// separator rows and get skipped. Remaining lines are data rows.
128fn parse_table_content(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
129    let mut headers = Vec::new();
130    let mut rows = Vec::new();
131    let mut header_done = false;
132
133    for line in content.lines() {
134        let trimmed = line.trim();
135        if trimmed.is_empty() {
136            continue;
137        }
138
139        // Skip separator lines like |---|---| or | --- | --- |
140        if is_table_separator(trimmed) {
141            continue;
142        }
143
144        let cells: Vec<String> = split_pipe_row(trimmed);
145
146        if !header_done {
147            headers = cells;
148            header_done = true;
149        } else {
150            rows.push(cells);
151        }
152    }
153
154    (headers, rows)
155}
156
157/// Check whether a line is a markdown table separator (e.g. `|---|---|`).
158fn is_table_separator(line: &str) -> bool {
159    let stripped = line.trim().trim_matches('|').trim();
160    if stripped.is_empty() {
161        return false;
162    }
163    stripped
164        .split('|')
165        .all(|cell| cell.trim().chars().all(|c| c == '-' || c == ':'))
166}
167
168/// Split a pipe-delimited row into trimmed cell strings, stripping leading and
169/// trailing pipes.
170fn split_pipe_row(line: &str) -> Vec<String> {
171    let trimmed = line.trim();
172    // Remove leading/trailing pipes.
173    let inner = trimmed
174        .strip_prefix('|')
175        .unwrap_or(trimmed);
176    let inner = inner
177        .strip_suffix('|')
178        .unwrap_or(inner);
179    inner.split('|').map(|c| c.trim().to_string()).collect()
180}
181
182/// Parse CSV content: newline-delimited, comma-separated.
183fn parse_csv_content(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
184    let mut headers = Vec::new();
185    let mut rows = Vec::new();
186    let mut header_done = false;
187
188    for line in content.lines() {
189        let trimmed = line.trim();
190        if trimmed.is_empty() {
191            continue;
192        }
193
194        let cells: Vec<String> = trimmed.split(',').map(|c| c.trim().to_string()).collect();
195
196        if !header_done {
197            headers = cells;
198            header_done = true;
199        } else {
200            rows.push(cells);
201        }
202    }
203
204    (headers, rows)
205}
206
207fn parse_code(attrs: &Attrs, content: &str, span: Span) -> Block {
208    let lang = attr_string(attrs, "lang");
209    let file = attr_string(attrs, "file");
210    let highlight = attr_string(attrs, "highlight")
211        .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
212        .unwrap_or_default();
213
214    Block::Code {
215        lang,
216        file,
217        highlight,
218        content: content.to_string(),
219        span,
220    }
221}
222
223fn parse_tasks(content: &str, span: Span) -> Block {
224    let mut items = Vec::new();
225
226    for line in content.lines() {
227        let trimmed = line.trim();
228
229        let (done, rest) = if let Some(rest) = trimmed.strip_prefix("- [x] ") {
230            (true, rest)
231        } else if let Some(rest) = trimmed.strip_prefix("- [X] ") {
232            (true, rest)
233        } else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
234            (false, rest)
235        } else {
236            continue;
237        };
238
239        // Extract optional @assignee from end of text.
240        let (text, assignee) = extract_assignee(rest);
241
242        items.push(TaskItem {
243            done,
244            text,
245            assignee,
246        });
247    }
248
249    Block::Tasks { items, span }
250}
251
252/// Extract a trailing `@username` from the end of a task text.
253///
254/// Returns `(text_without_assignee, Option<assignee>)`.
255fn extract_assignee(text: &str) -> (String, Option<String>) {
256    let trimmed = text.trim_end();
257    if let Some(at_pos) = trimmed.rfind(" @") {
258        let candidate = &trimmed[at_pos + 2..];
259        // Assignee must be a single word (no spaces).
260        if !candidate.is_empty() && !candidate.contains(' ') {
261            let main_text = trimmed[..at_pos].trim_end().to_string();
262            return (main_text, Some(candidate.to_string()));
263        }
264    }
265    (text.to_string(), None)
266}
267
268fn parse_decision(attrs: &Attrs, content: &str, span: Span) -> Block {
269    let status = attr_string(attrs, "status")
270        .and_then(|s| match s.as_str() {
271            "proposed" => Some(DecisionStatus::Proposed),
272            "accepted" => Some(DecisionStatus::Accepted),
273            "rejected" => Some(DecisionStatus::Rejected),
274            "superseded" => Some(DecisionStatus::Superseded),
275            _ => None,
276        })
277        .unwrap_or(DecisionStatus::Proposed);
278
279    let date = attr_string(attrs, "date");
280
281    let deciders = attr_string(attrs, "deciders")
282        .map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
283        .unwrap_or_default();
284
285    Block::Decision {
286        status,
287        date,
288        deciders,
289        content: content.to_string(),
290        span,
291    }
292}
293
294fn parse_metric(attrs: &Attrs, span: Span) -> Block {
295    let label = attr_string(attrs, "label").unwrap_or_default();
296    let value = attr_string(attrs, "value").unwrap_or_default();
297
298    let trend = attr_string(attrs, "trend").and_then(|s| match s.as_str() {
299        "up" => Some(Trend::Up),
300        "down" => Some(Trend::Down),
301        "flat" => Some(Trend::Flat),
302        _ => None,
303    });
304
305    let unit = attr_string(attrs, "unit");
306
307    Block::Metric {
308        label,
309        value,
310        trend,
311        unit,
312        span,
313    }
314}
315
316fn parse_summary(content: &str, span: Span) -> Block {
317    Block::Summary {
318        content: content.to_string(),
319        span,
320    }
321}
322
323fn parse_tabs(content: &str, span: Span) -> Block {
324    let mut tabs = Vec::new();
325    let mut current_label: Option<String> = None;
326    let mut current_lines: Vec<&str> = Vec::new();
327
328    for line in content.lines() {
329        let trimmed = line.trim();
330        // Tab labels: `## Label` or `### Label` inside tabs block
331        if let Some(rest) = trimmed.strip_prefix("## ") {
332            // Flush previous tab
333            if let Some(label) = current_label.take() {
334                tabs.push(TabPanel {
335                    label,
336                    content: current_lines.join("\n").trim().to_string(),
337                });
338                current_lines.clear();
339            }
340            current_label = Some(rest.trim().to_string());
341        } else if let Some(rest) = trimmed.strip_prefix("### ") {
342            if let Some(label) = current_label.take() {
343                tabs.push(TabPanel {
344                    label,
345                    content: current_lines.join("\n").trim().to_string(),
346                });
347                current_lines.clear();
348            }
349            current_label = Some(rest.trim().to_string());
350        } else {
351            current_lines.push(line);
352        }
353    }
354
355    // Flush final tab
356    if let Some(label) = current_label {
357        tabs.push(TabPanel {
358            label,
359            content: current_lines.join("\n").trim().to_string(),
360        });
361    } else if !current_lines.is_empty() {
362        // No headers found — single unnamed tab
363        let text = current_lines.join("\n").trim().to_string();
364        if !text.is_empty() {
365            tabs.push(TabPanel {
366                label: "Tab 1".to_string(),
367                content: text,
368            });
369        }
370    }
371
372    Block::Tabs { tabs, span }
373}
374
375fn parse_columns(content: &str, span: Span) -> Block {
376    let mut columns = Vec::new();
377    let mut current_lines: Vec<&str> = Vec::new();
378    let mut found_separator = false;
379
380    for line in content.lines() {
381        let trimmed = line.trim();
382        // Nested :::column directives serve as separators
383        if trimmed.starts_with(":::column") {
384            if !current_lines.is_empty() {
385                columns.push(ColumnContent {
386                    content: current_lines.join("\n").trim().to_string(),
387                });
388                current_lines.clear();
389            }
390            found_separator = true;
391        } else if trimmed == ":::" {
392            // Close a :::column — flush content
393            if found_separator {
394                columns.push(ColumnContent {
395                    content: current_lines.join("\n").trim().to_string(),
396                });
397                current_lines.clear();
398            }
399        } else if trimmed == "---" && !found_separator {
400            // Horizontal rule as column separator (simpler syntax)
401            columns.push(ColumnContent {
402                content: current_lines.join("\n").trim().to_string(),
403            });
404            current_lines.clear();
405            found_separator = true;
406        } else {
407            current_lines.push(line);
408        }
409    }
410
411    // Flush remaining content
412    let remaining = current_lines.join("\n").trim().to_string();
413    if !remaining.is_empty() {
414        columns.push(ColumnContent {
415            content: remaining,
416        });
417    }
418
419    // If no separators were found, treat the whole thing as one column
420    if columns.is_empty() {
421        columns.push(ColumnContent {
422            content: content.trim().to_string(),
423        });
424    }
425
426    Block::Columns { columns, span }
427}
428
429fn parse_quote(attrs: &Attrs, content: &str, span: Span) -> Block {
430    let attribution = attr_string(attrs, "by")
431        .or_else(|| attr_string(attrs, "attribution"))
432        .or_else(|| attr_string(attrs, "author"));
433    let cite = attr_string(attrs, "cite")
434        .or_else(|| attr_string(attrs, "source"));
435
436    Block::Quote {
437        content: content.to_string(),
438        attribution,
439        cite,
440        span,
441    }
442}
443
444fn parse_figure(attrs: &Attrs, span: Span) -> Block {
445    let src = attr_string(attrs, "src").unwrap_or_default();
446    let caption = attr_string(attrs, "caption");
447    let alt = attr_string(attrs, "alt");
448    let width = attr_string(attrs, "width");
449
450    Block::Figure {
451        src,
452        caption,
453        alt,
454        width,
455        span,
456    }
457}
458
459fn parse_cta(attrs: &Attrs, span: Span) -> Block {
460    let label = attr_string(attrs, "label").unwrap_or_default();
461    let href = attr_string(attrs, "href").unwrap_or_default();
462    let primary = attr_bool(attrs, "primary");
463
464    Block::Cta {
465        label,
466        href,
467        primary,
468        span,
469    }
470}
471
472fn parse_hero_image(attrs: &Attrs, span: Span) -> Block {
473    let src = attr_string(attrs, "src").unwrap_or_default();
474    let alt = attr_string(attrs, "alt");
475
476    Block::HeroImage { src, alt, span }
477}
478
479fn parse_testimonial(attrs: &Attrs, content: &str, span: Span) -> Block {
480    let author = attr_string(attrs, "author")
481        .or_else(|| attr_string(attrs, "name"));
482    let role = attr_string(attrs, "role")
483        .or_else(|| attr_string(attrs, "title"));
484    let company = attr_string(attrs, "company")
485        .or_else(|| attr_string(attrs, "org"));
486
487    Block::Testimonial {
488        content: content.to_string(),
489        author,
490        role,
491        company,
492        span,
493    }
494}
495
496fn parse_style(content: &str, span: Span) -> Block {
497    let mut properties = Vec::new();
498
499    for line in content.lines() {
500        let trimmed = line.trim();
501        if trimmed.is_empty() {
502            continue;
503        }
504        // Parse "key: value" lines
505        if let Some((key, value)) = trimmed.split_once(':') {
506            let key = key.trim().to_string();
507            let value = value.trim().to_string();
508            if !key.is_empty() && !value.is_empty() {
509                properties.push(StyleProperty { key, value });
510            }
511        }
512    }
513
514    Block::Style { properties, span }
515}
516
517fn parse_faq(content: &str, span: Span) -> Block {
518    let mut items = Vec::new();
519    let mut current_question: Option<String> = None;
520    let mut current_lines: Vec<&str> = Vec::new();
521
522    for line in content.lines() {
523        let trimmed = line.trim();
524        // FAQ questions: `### Question` inside faq block
525        if let Some(rest) = trimmed.strip_prefix("### ") {
526            // Flush previous item
527            if let Some(question) = current_question.take() {
528                items.push(FaqItem {
529                    question,
530                    answer: current_lines.join("\n").trim().to_string(),
531                });
532                current_lines.clear();
533            }
534            current_question = Some(rest.trim().to_string());
535        } else if let Some(rest) = trimmed.strip_prefix("## ") {
536            // Also accept ## headers
537            if let Some(question) = current_question.take() {
538                items.push(FaqItem {
539                    question,
540                    answer: current_lines.join("\n").trim().to_string(),
541                });
542                current_lines.clear();
543            }
544            current_question = Some(rest.trim().to_string());
545        } else {
546            current_lines.push(line);
547        }
548    }
549
550    // Flush final item
551    if let Some(question) = current_question {
552        items.push(FaqItem {
553            question,
554            answer: current_lines.join("\n").trim().to_string(),
555        });
556    }
557
558    Block::Faq { items, span }
559}
560
561fn parse_pricing_table(content: &str, span: Span) -> Block {
562    let (headers, rows) = parse_table_content(content);
563
564    Block::PricingTable {
565        headers,
566        rows,
567        span,
568    }
569}
570
571fn parse_site(attrs: &Attrs, content: &str, span: Span) -> Block {
572    let domain = attr_string(attrs, "domain");
573
574    let mut properties = Vec::new();
575    for line in content.lines() {
576        let trimmed = line.trim();
577        if trimmed.is_empty() {
578            continue;
579        }
580        if let Some((key, value)) = trimmed.split_once(':') {
581            let key = key.trim().to_string();
582            let value = value.trim().to_string();
583            if !key.is_empty() && !value.is_empty() {
584                properties.push(StyleProperty { key, value });
585            }
586        }
587    }
588
589    Block::Site {
590        domain,
591        properties,
592        span,
593    }
594}
595
596fn parse_page(attrs: &Attrs, content: &str, span: Span) -> Block {
597    let route = attr_string(attrs, "route").unwrap_or_default();
598    let layout = attr_string(attrs, "layout");
599    let title = attr_string(attrs, "title");
600    let sidebar = attr_bool(attrs, "sidebar");
601
602    // Scan content for leaf directives, interleaving with markdown.
603    let children = parse_page_children(content);
604
605    Block::Page {
606        route,
607        layout,
608        title,
609        sidebar,
610        content: content.to_string(),
611        children,
612        span,
613    }
614}
615
616// ------------------------------------------------------------------
617// Page child block scanner
618// ------------------------------------------------------------------
619
620/// Scan page content for leaf directives (single-line `::name[attrs]`).
621///
622/// Lines matching a known block directive are resolved via `resolve_block()`.
623/// Consecutive non-directive lines are collected as `Block::Markdown`.
624fn parse_page_children(content: &str) -> Vec<Block> {
625    let mut children = Vec::new();
626    let mut md_lines: Vec<&str> = Vec::new();
627
628    for line in content.lines() {
629        if let Some(block) = try_parse_leaf_directive(line) {
630            // Flush accumulated markdown
631            flush_md_lines(&mut md_lines, &mut children);
632            children.push(block);
633        } else {
634            md_lines.push(line);
635        }
636    }
637
638    // Flush remaining markdown
639    flush_md_lines(&mut md_lines, &mut children);
640
641    children
642}
643
644/// Try to parse a single line as a leaf directive (`::name[attrs]`).
645///
646/// Returns `Some(resolved_block)` if the line matches, `None` otherwise.
647fn try_parse_leaf_directive(line: &str) -> Option<Block> {
648    let trimmed = line.trim();
649    if !trimmed.starts_with("::") {
650        return None;
651    }
652
653    // Count leading colons — must be exactly 2 for a top-level directive.
654    let depth = trimmed.chars().take_while(|&c| c == ':').count();
655    if depth != 2 {
656        return None;
657    }
658
659    let rest = &trimmed[2..];
660    if rest.is_empty() {
661        return None; // closing `::`, not an opening directive
662    }
663
664    // Must start with alphabetic character.
665    let first = rest.chars().next()?;
666    if !first.is_alphabetic() {
667        return None;
668    }
669
670    // Scan block name.
671    let name_end = rest
672        .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
673        .unwrap_or(rest.len());
674    let name = &rest[..name_end];
675    let remainder = &rest[name_end..];
676
677    // Extract attrs if present.
678    let attrs_str = if remainder.starts_with('[') {
679        if let Some(close) = remainder.find(']') {
680            &remainder[..=close]
681        } else {
682            remainder
683        }
684    } else {
685        ""
686    };
687
688    let attrs = crate::attrs::parse_attrs(attrs_str).unwrap_or_default();
689    let dummy_span = Span {
690        start_line: 0,
691        end_line: 0,
692        start_offset: 0,
693        end_offset: 0,
694    };
695
696    let block = Block::Unknown {
697        name: name.to_string(),
698        attrs,
699        content: String::new(),
700        span: dummy_span,
701    };
702
703    Some(resolve_block(block))
704}
705
706/// Flush accumulated markdown lines into a `Block::Markdown` if non-empty.
707fn flush_md_lines(lines: &mut Vec<&str>, children: &mut Vec<Block>) {
708    let text = lines.join("\n");
709    let trimmed = text.trim();
710    if !trimmed.is_empty() {
711        children.push(Block::Markdown {
712            content: text.trim().to_string(),
713            span: Span {
714                start_line: 0,
715                end_line: 0,
716                start_offset: 0,
717                end_offset: 0,
718            },
719        });
720    }
721    lines.clear();
722}
723
724// ------------------------------------------------------------------
725// Tests
726// ------------------------------------------------------------------
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use crate::types::AttrValue;
732    use pretty_assertions::assert_eq;
733    use std::collections::BTreeMap;
734
735    /// Helper: build a `Block::Unknown` for testing.
736    fn unknown(name: &str, attrs: Attrs, content: &str) -> Block {
737        Block::Unknown {
738            name: name.to_string(),
739            attrs,
740            content: content.to_string(),
741            span: Span {
742                start_line: 1,
743                end_line: 3,
744                start_offset: 0,
745                end_offset: 100,
746            },
747        }
748    }
749
750    /// Helper: quick attrs builder.
751    fn attrs(pairs: &[(&str, AttrValue)]) -> Attrs {
752        let mut map = BTreeMap::new();
753        for (k, v) in pairs {
754            map.insert(k.to_string(), v.clone());
755        }
756        map
757    }
758
759    // -- Callout ---------------------------------------------------
760
761    #[test]
762    fn resolve_callout_warning() {
763        let block = unknown(
764            "callout",
765            attrs(&[("type", AttrValue::String("warning".into()))]),
766            "Watch out!",
767        );
768        match resolve_block(block) {
769            Block::Callout {
770                callout_type,
771                content,
772                ..
773            } => {
774                assert_eq!(callout_type, CalloutType::Warning);
775                assert_eq!(content, "Watch out!");
776            }
777            other => panic!("Expected Callout, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn resolve_callout_with_title() {
783        let block = unknown(
784            "callout",
785            attrs(&[
786                ("type", AttrValue::String("tip".into())),
787                ("title", AttrValue::String("Pro Tip".into())),
788            ]),
789            "Use Rust.",
790        );
791        match resolve_block(block) {
792            Block::Callout {
793                callout_type,
794                title,
795                ..
796            } => {
797                assert_eq!(callout_type, CalloutType::Tip);
798                assert_eq!(title, Some("Pro Tip".to_string()));
799            }
800            other => panic!("Expected Callout, got {other:?}"),
801        }
802    }
803
804    #[test]
805    fn resolve_callout_default_type() {
806        let block = unknown("callout", Attrs::new(), "No type attr.");
807        match resolve_block(block) {
808            Block::Callout { callout_type, .. } => {
809                assert_eq!(callout_type, CalloutType::Info);
810            }
811            other => panic!("Expected Callout, got {other:?}"),
812        }
813    }
814
815    // -- Data ------------------------------------------------------
816
817    #[test]
818    fn resolve_data_table() {
819        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
820        let block = unknown("data", Attrs::new(), content);
821        match resolve_block(block) {
822            Block::Data {
823                headers,
824                rows,
825                format,
826                ..
827            } => {
828                assert_eq!(format, DataFormat::Table);
829                assert_eq!(headers, vec!["Name", "Age"]);
830                assert_eq!(rows.len(), 2);
831                assert_eq!(rows[0], vec!["Alice", "30"]);
832                assert_eq!(rows[1], vec!["Bob", "25"]);
833            }
834            other => panic!("Expected Data, got {other:?}"),
835        }
836    }
837
838    #[test]
839    fn resolve_data_with_separator() {
840        let content = "| H1 | H2 |\n| --- | --- |\n| v1 | v2 |";
841        let block = unknown("data", Attrs::new(), content);
842        match resolve_block(block) {
843            Block::Data { headers, rows, .. } => {
844                assert_eq!(headers, vec!["H1", "H2"]);
845                assert_eq!(rows.len(), 1);
846                assert_eq!(rows[0], vec!["v1", "v2"]);
847            }
848            other => panic!("Expected Data, got {other:?}"),
849        }
850    }
851
852    #[test]
853    fn resolve_data_sortable() {
854        let block = unknown(
855            "data",
856            attrs(&[("sortable", AttrValue::Bool(true))]),
857            "| A |\n| 1 |",
858        );
859        match resolve_block(block) {
860            Block::Data { sortable, .. } => {
861                assert!(sortable);
862            }
863            other => panic!("Expected Data, got {other:?}"),
864        }
865    }
866
867    #[test]
868    fn resolve_data_csv() {
869        let content = "Name, Age\nAlice, 30\nBob, 25";
870        let block = unknown(
871            "data",
872            attrs(&[("format", AttrValue::String("csv".into()))]),
873            content,
874        );
875        match resolve_block(block) {
876            Block::Data {
877                format,
878                headers,
879                rows,
880                ..
881            } => {
882                assert_eq!(format, DataFormat::Csv);
883                assert_eq!(headers, vec!["Name", "Age"]);
884                assert_eq!(rows.len(), 2);
885            }
886            other => panic!("Expected Data, got {other:?}"),
887        }
888    }
889
890    // -- Code ------------------------------------------------------
891
892    #[test]
893    fn resolve_code_with_lang() {
894        let block = unknown(
895            "code",
896            attrs(&[("lang", AttrValue::String("rust".into()))]),
897            "fn main() {}",
898        );
899        match resolve_block(block) {
900            Block::Code { lang, content, .. } => {
901                assert_eq!(lang, Some("rust".to_string()));
902                assert_eq!(content, "fn main() {}");
903            }
904            other => panic!("Expected Code, got {other:?}"),
905        }
906    }
907
908    #[test]
909    fn resolve_code_with_file() {
910        let block = unknown(
911            "code",
912            attrs(&[
913                ("lang", AttrValue::String("rust".into())),
914                ("file", AttrValue::String("main.rs".into())),
915            ]),
916            "fn main() {}",
917        );
918        match resolve_block(block) {
919            Block::Code { lang, file, .. } => {
920                assert_eq!(lang, Some("rust".to_string()));
921                assert_eq!(file, Some("main.rs".to_string()));
922            }
923            other => panic!("Expected Code, got {other:?}"),
924        }
925    }
926
927    // -- Tasks -----------------------------------------------------
928
929    #[test]
930    fn resolve_tasks_mixed() {
931        let content = "- [ ] Write tests\n- [x] Write parser";
932        let block = unknown("tasks", Attrs::new(), content);
933        match resolve_block(block) {
934            Block::Tasks { items, .. } => {
935                assert_eq!(items.len(), 2);
936                assert!(!items[0].done);
937                assert_eq!(items[0].text, "Write tests");
938                assert!(items[1].done);
939                assert_eq!(items[1].text, "Write parser");
940            }
941            other => panic!("Expected Tasks, got {other:?}"),
942        }
943    }
944
945    #[test]
946    fn resolve_tasks_with_assignee() {
947        let content = "- [ ] Fix bug @brady";
948        let block = unknown("tasks", Attrs::new(), content);
949        match resolve_block(block) {
950            Block::Tasks { items, .. } => {
951                assert_eq!(items.len(), 1);
952                assert_eq!(items[0].text, "Fix bug");
953                assert_eq!(items[0].assignee, Some("brady".to_string()));
954            }
955            other => panic!("Expected Tasks, got {other:?}"),
956        }
957    }
958
959    // -- Decision --------------------------------------------------
960
961    #[test]
962    fn resolve_decision_accepted() {
963        let block = unknown(
964            "decision",
965            attrs(&[
966                ("status", AttrValue::String("accepted".into())),
967                ("date", AttrValue::String("2026-02-10".into())),
968            ]),
969            "We chose Rust.",
970        );
971        match resolve_block(block) {
972            Block::Decision {
973                status,
974                date,
975                content,
976                ..
977            } => {
978                assert_eq!(status, DecisionStatus::Accepted);
979                assert_eq!(date, Some("2026-02-10".to_string()));
980                assert_eq!(content, "We chose Rust.");
981            }
982            other => panic!("Expected Decision, got {other:?}"),
983        }
984    }
985
986    #[test]
987    fn resolve_decision_with_deciders() {
988        let block = unknown(
989            "decision",
990            attrs(&[
991                ("status", AttrValue::String("proposed".into())),
992                ("deciders", AttrValue::String("Brady, Claude".into())),
993            ]),
994            "Consider options.",
995        );
996        match resolve_block(block) {
997            Block::Decision { deciders, .. } => {
998                assert_eq!(deciders, vec!["Brady", "Claude"]);
999            }
1000            other => panic!("Expected Decision, got {other:?}"),
1001        }
1002    }
1003
1004    // -- Metric ----------------------------------------------------
1005
1006    #[test]
1007    fn resolve_metric_basic() {
1008        let block = unknown(
1009            "metric",
1010            attrs(&[
1011                ("label", AttrValue::String("MRR".into())),
1012                ("value", AttrValue::String("$2K".into())),
1013            ]),
1014            "",
1015        );
1016        match resolve_block(block) {
1017            Block::Metric { label, value, .. } => {
1018                assert_eq!(label, "MRR");
1019                assert_eq!(value, "$2K");
1020            }
1021            other => panic!("Expected Metric, got {other:?}"),
1022        }
1023    }
1024
1025    #[test]
1026    fn resolve_metric_with_trend() {
1027        let block = unknown(
1028            "metric",
1029            attrs(&[
1030                ("label", AttrValue::String("Users".into())),
1031                ("value", AttrValue::String("500".into())),
1032                ("trend", AttrValue::String("up".into())),
1033            ]),
1034            "",
1035        );
1036        match resolve_block(block) {
1037            Block::Metric { trend, .. } => {
1038                assert_eq!(trend, Some(Trend::Up));
1039            }
1040            other => panic!("Expected Metric, got {other:?}"),
1041        }
1042    }
1043
1044    // -- Summary ---------------------------------------------------
1045
1046    #[test]
1047    fn resolve_summary() {
1048        let block = unknown("summary", Attrs::new(), "This is the executive summary.");
1049        match resolve_block(block) {
1050            Block::Summary { content, .. } => {
1051                assert_eq!(content, "This is the executive summary.");
1052            }
1053            other => panic!("Expected Summary, got {other:?}"),
1054        }
1055    }
1056
1057    // -- Figure ----------------------------------------------------
1058
1059    #[test]
1060    fn resolve_figure_basic() {
1061        let block = unknown(
1062            "figure",
1063            attrs(&[
1064                ("src", AttrValue::String("img.png".into())),
1065                ("caption", AttrValue::String("Photo".into())),
1066            ]),
1067            "",
1068        );
1069        match resolve_block(block) {
1070            Block::Figure {
1071                src,
1072                caption,
1073                alt,
1074                width,
1075                ..
1076            } => {
1077                assert_eq!(src, "img.png");
1078                assert_eq!(caption, Some("Photo".to_string()));
1079                assert!(alt.is_none());
1080                assert!(width.is_none());
1081            }
1082            other => panic!("Expected Figure, got {other:?}"),
1083        }
1084    }
1085
1086    // -- Tabs ------------------------------------------------------
1087
1088    #[test]
1089    fn resolve_tabs_with_headers() {
1090        let content = "## Overview\nIntro text.\n\n## Details\nTechnical info.\n\n## FAQ\nQ&A here.";
1091        let block = unknown("tabs", Attrs::new(), content);
1092        match resolve_block(block) {
1093            Block::Tabs { tabs, .. } => {
1094                assert_eq!(tabs.len(), 3);
1095                assert_eq!(tabs[0].label, "Overview");
1096                assert!(tabs[0].content.contains("Intro text."));
1097                assert_eq!(tabs[1].label, "Details");
1098                assert!(tabs[1].content.contains("Technical info."));
1099                assert_eq!(tabs[2].label, "FAQ");
1100                assert!(tabs[2].content.contains("Q&A here."));
1101            }
1102            other => panic!("Expected Tabs, got {other:?}"),
1103        }
1104    }
1105
1106    #[test]
1107    fn resolve_tabs_single_no_header() {
1108        let content = "Just some text without any tab headers.";
1109        let block = unknown("tabs", Attrs::new(), content);
1110        match resolve_block(block) {
1111            Block::Tabs { tabs, .. } => {
1112                assert_eq!(tabs.len(), 1);
1113                assert_eq!(tabs[0].label, "Tab 1");
1114                assert!(tabs[0].content.contains("Just some text"));
1115            }
1116            other => panic!("Expected Tabs, got {other:?}"),
1117        }
1118    }
1119
1120    // -- Columns ---------------------------------------------------
1121
1122    #[test]
1123    fn resolve_columns_with_nested_directives() {
1124        let content = ":::column\nLeft content.\n:::\n:::column\nRight content.\n:::";
1125        let block = unknown("columns", Attrs::new(), content);
1126        match resolve_block(block) {
1127            Block::Columns { columns, .. } => {
1128                assert_eq!(columns.len(), 2);
1129                assert_eq!(columns[0].content, "Left content.");
1130                assert_eq!(columns[1].content, "Right content.");
1131            }
1132            other => panic!("Expected Columns, got {other:?}"),
1133        }
1134    }
1135
1136    #[test]
1137    fn resolve_columns_with_hr_separator() {
1138        let content = "Left side.\n---\nRight side.";
1139        let block = unknown("columns", Attrs::new(), content);
1140        match resolve_block(block) {
1141            Block::Columns { columns, .. } => {
1142                assert_eq!(columns.len(), 2);
1143                assert_eq!(columns[0].content, "Left side.");
1144                assert_eq!(columns[1].content, "Right side.");
1145            }
1146            other => panic!("Expected Columns, got {other:?}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn resolve_columns_single() {
1152        let content = "All in one column.";
1153        let block = unknown("columns", Attrs::new(), content);
1154        match resolve_block(block) {
1155            Block::Columns { columns, .. } => {
1156                assert_eq!(columns.len(), 1);
1157                assert_eq!(columns[0].content, "All in one column.");
1158            }
1159            other => panic!("Expected Columns, got {other:?}"),
1160        }
1161    }
1162
1163    // -- Quote -----------------------------------------------------
1164
1165    #[test]
1166    fn resolve_quote_with_attribution() {
1167        let block = unknown(
1168            "quote",
1169            attrs(&[
1170                ("by", AttrValue::String("Alan Kay".into())),
1171                ("cite", AttrValue::String("ACM 1971".into())),
1172            ]),
1173            "The best way to predict the future is to invent it.",
1174        );
1175        match resolve_block(block) {
1176            Block::Quote {
1177                content,
1178                attribution,
1179                cite,
1180                ..
1181            } => {
1182                assert_eq!(content, "The best way to predict the future is to invent it.");
1183                assert_eq!(attribution, Some("Alan Kay".to_string()));
1184                assert_eq!(cite, Some("ACM 1971".to_string()));
1185            }
1186            other => panic!("Expected Quote, got {other:?}"),
1187        }
1188    }
1189
1190    #[test]
1191    fn resolve_quote_no_attribution() {
1192        let block = unknown("quote", Attrs::new(), "Anonymous wisdom.");
1193        match resolve_block(block) {
1194            Block::Quote {
1195                content,
1196                attribution,
1197                ..
1198            } => {
1199                assert_eq!(content, "Anonymous wisdom.");
1200                assert!(attribution.is_none());
1201            }
1202            other => panic!("Expected Quote, got {other:?}"),
1203        }
1204    }
1205
1206    #[test]
1207    fn resolve_quote_author_alias() {
1208        let block = unknown(
1209            "quote",
1210            attrs(&[("author", AttrValue::String("Knuth".into()))]),
1211            "Premature optimization.",
1212        );
1213        match resolve_block(block) {
1214            Block::Quote { attribution, .. } => {
1215                assert_eq!(attribution, Some("Knuth".to_string()));
1216            }
1217            other => panic!("Expected Quote, got {other:?}"),
1218        }
1219    }
1220
1221    // -- Cta -------------------------------------------------------
1222
1223    #[test]
1224    fn resolve_cta_primary() {
1225        let block = unknown(
1226            "cta",
1227            attrs(&[
1228                ("label", AttrValue::String("Get Started".into())),
1229                ("href", AttrValue::String("/signup".into())),
1230                ("primary", AttrValue::Bool(true)),
1231            ]),
1232            "",
1233        );
1234        match resolve_block(block) {
1235            Block::Cta {
1236                label,
1237                href,
1238                primary,
1239                ..
1240            } => {
1241                assert_eq!(label, "Get Started");
1242                assert_eq!(href, "/signup");
1243                assert!(primary);
1244            }
1245            other => panic!("Expected Cta, got {other:?}"),
1246        }
1247    }
1248
1249    #[test]
1250    fn resolve_cta_secondary() {
1251        let block = unknown(
1252            "cta",
1253            attrs(&[
1254                ("label", AttrValue::String("Learn More".into())),
1255                ("href", AttrValue::String("https://example.com".into())),
1256            ]),
1257            "",
1258        );
1259        match resolve_block(block) {
1260            Block::Cta {
1261                label,
1262                href,
1263                primary,
1264                ..
1265            } => {
1266                assert_eq!(label, "Learn More");
1267                assert_eq!(href, "https://example.com");
1268                assert!(!primary);
1269            }
1270            other => panic!("Expected Cta, got {other:?}"),
1271        }
1272    }
1273
1274    // -- HeroImage -------------------------------------------------
1275
1276    #[test]
1277    fn resolve_hero_image_with_alt() {
1278        let block = unknown(
1279            "hero-image",
1280            attrs(&[
1281                ("src", AttrValue::String("hero.png".into())),
1282                ("alt", AttrValue::String("Product screenshot".into())),
1283            ]),
1284            "",
1285        );
1286        match resolve_block(block) {
1287            Block::HeroImage { src, alt, .. } => {
1288                assert_eq!(src, "hero.png");
1289                assert_eq!(alt, Some("Product screenshot".to_string()));
1290            }
1291            other => panic!("Expected HeroImage, got {other:?}"),
1292        }
1293    }
1294
1295    #[test]
1296    fn resolve_hero_image_no_alt() {
1297        let block = unknown(
1298            "hero-image",
1299            attrs(&[("src", AttrValue::String("banner.jpg".into()))]),
1300            "",
1301        );
1302        match resolve_block(block) {
1303            Block::HeroImage { src, alt, .. } => {
1304                assert_eq!(src, "banner.jpg");
1305                assert!(alt.is_none());
1306            }
1307            other => panic!("Expected HeroImage, got {other:?}"),
1308        }
1309    }
1310
1311    // -- Testimonial -----------------------------------------------
1312
1313    #[test]
1314    fn resolve_testimonial_full() {
1315        let block = unknown(
1316            "testimonial",
1317            attrs(&[
1318                ("author", AttrValue::String("Jane Dev".into())),
1319                ("role", AttrValue::String("Engineer".into())),
1320                ("company", AttrValue::String("Acme".into())),
1321            ]),
1322            "This tool replaced 3 others for me.",
1323        );
1324        match resolve_block(block) {
1325            Block::Testimonial {
1326                content,
1327                author,
1328                role,
1329                company,
1330                ..
1331            } => {
1332                assert_eq!(content, "This tool replaced 3 others for me.");
1333                assert_eq!(author, Some("Jane Dev".to_string()));
1334                assert_eq!(role, Some("Engineer".to_string()));
1335                assert_eq!(company, Some("Acme".to_string()));
1336            }
1337            other => panic!("Expected Testimonial, got {other:?}"),
1338        }
1339    }
1340
1341    #[test]
1342    fn resolve_testimonial_name_alias() {
1343        let block = unknown(
1344            "testimonial",
1345            attrs(&[("name", AttrValue::String("Bob".into()))]),
1346            "Great product.",
1347        );
1348        match resolve_block(block) {
1349            Block::Testimonial { author, .. } => {
1350                assert_eq!(author, Some("Bob".to_string()));
1351            }
1352            other => panic!("Expected Testimonial, got {other:?}"),
1353        }
1354    }
1355
1356    #[test]
1357    fn resolve_testimonial_anonymous() {
1358        let block = unknown("testimonial", Attrs::new(), "Anonymous feedback.");
1359        match resolve_block(block) {
1360            Block::Testimonial {
1361                content,
1362                author,
1363                role,
1364                company,
1365                ..
1366            } => {
1367                assert_eq!(content, "Anonymous feedback.");
1368                assert!(author.is_none());
1369                assert!(role.is_none());
1370                assert!(company.is_none());
1371            }
1372            other => panic!("Expected Testimonial, got {other:?}"),
1373        }
1374    }
1375
1376    // -- Style -----------------------------------------------------
1377
1378    #[test]
1379    fn resolve_style_properties() {
1380        let content = "hero-bg: gradient indigo\ncard-radius: lg\nmax-width: 1200px";
1381        let block = unknown("style", Attrs::new(), content);
1382        match resolve_block(block) {
1383            Block::Style { properties, .. } => {
1384                assert_eq!(properties.len(), 3);
1385                assert_eq!(properties[0].key, "hero-bg");
1386                assert_eq!(properties[0].value, "gradient indigo");
1387                assert_eq!(properties[1].key, "card-radius");
1388                assert_eq!(properties[1].value, "lg");
1389                assert_eq!(properties[2].key, "max-width");
1390                assert_eq!(properties[2].value, "1200px");
1391            }
1392            other => panic!("Expected Style, got {other:?}"),
1393        }
1394    }
1395
1396    #[test]
1397    fn resolve_style_empty() {
1398        let block = unknown("style", Attrs::new(), "");
1399        match resolve_block(block) {
1400            Block::Style { properties, .. } => {
1401                assert!(properties.is_empty());
1402            }
1403            other => panic!("Expected Style, got {other:?}"),
1404        }
1405    }
1406
1407    #[test]
1408    fn resolve_style_skips_blank_lines() {
1409        let content = "  \nfont: inter\n\naccent: #6366f1\n  ";
1410        let block = unknown("style", Attrs::new(), content);
1411        match resolve_block(block) {
1412            Block::Style { properties, .. } => {
1413                assert_eq!(properties.len(), 2);
1414                assert_eq!(properties[0].key, "font");
1415                assert_eq!(properties[0].value, "inter");
1416                assert_eq!(properties[1].key, "accent");
1417                assert_eq!(properties[1].value, "#6366f1");
1418            }
1419            other => panic!("Expected Style, got {other:?}"),
1420        }
1421    }
1422
1423    // -- Faq -------------------------------------------------------
1424
1425    #[test]
1426    fn resolve_faq_multiple_items() {
1427        let content = "### Is my data encrypted?\nYes — AES-256 at rest, TLS in transit.\n\n### Can I self-host?\nYes. Docker image available.";
1428        let block = unknown("faq", Attrs::new(), content);
1429        match resolve_block(block) {
1430            Block::Faq { items, .. } => {
1431                assert_eq!(items.len(), 2);
1432                assert_eq!(items[0].question, "Is my data encrypted?");
1433                assert!(items[0].answer.contains("AES-256"));
1434                assert_eq!(items[1].question, "Can I self-host?");
1435                assert!(items[1].answer.contains("Docker"));
1436            }
1437            other => panic!("Expected Faq, got {other:?}"),
1438        }
1439    }
1440
1441    #[test]
1442    fn resolve_faq_h2_headers() {
1443        let content = "## Question one\nAnswer one.\n\n## Question two\nAnswer two.";
1444        let block = unknown("faq", Attrs::new(), content);
1445        match resolve_block(block) {
1446            Block::Faq { items, .. } => {
1447                assert_eq!(items.len(), 2);
1448                assert_eq!(items[0].question, "Question one");
1449                assert_eq!(items[1].question, "Question two");
1450            }
1451            other => panic!("Expected Faq, got {other:?}"),
1452        }
1453    }
1454
1455    #[test]
1456    fn resolve_faq_empty() {
1457        let block = unknown("faq", Attrs::new(), "");
1458        match resolve_block(block) {
1459            Block::Faq { items, .. } => {
1460                assert!(items.is_empty());
1461            }
1462            other => panic!("Expected Faq, got {other:?}"),
1463        }
1464    }
1465
1466    #[test]
1467    fn resolve_faq_single_item() {
1468        let content = "### How does pricing work?\nWe charge per seat per month.";
1469        let block = unknown("faq", Attrs::new(), content);
1470        match resolve_block(block) {
1471            Block::Faq { items, .. } => {
1472                assert_eq!(items.len(), 1);
1473                assert_eq!(items[0].question, "How does pricing work?");
1474                assert_eq!(items[0].answer, "We charge per seat per month.");
1475            }
1476            other => panic!("Expected Faq, got {other:?}"),
1477        }
1478    }
1479
1480    // -- PricingTable ----------------------------------------------
1481
1482    #[test]
1483    fn resolve_pricing_table() {
1484        let content = "| | Free | Pro | Team |\n|---|---|---|---|\n| Price | $0 | $4.99/mo | $8.99/seat/mo |\n| Notes | Unlimited | Unlimited | Unlimited |";
1485        let block = unknown("pricing-table", Attrs::new(), content);
1486        match resolve_block(block) {
1487            Block::PricingTable {
1488                headers, rows, ..
1489            } => {
1490                assert_eq!(headers, vec!["", "Free", "Pro", "Team"]);
1491                assert_eq!(rows.len(), 2);
1492                assert_eq!(rows[0][0], "Price");
1493                assert_eq!(rows[0][2], "$4.99/mo");
1494                assert_eq!(rows[1][3], "Unlimited");
1495            }
1496            other => panic!("Expected PricingTable, got {other:?}"),
1497        }
1498    }
1499
1500    #[test]
1501    fn resolve_pricing_table_empty() {
1502        let block = unknown("pricing-table", Attrs::new(), "");
1503        match resolve_block(block) {
1504            Block::PricingTable {
1505                headers, rows, ..
1506            } => {
1507                assert!(headers.is_empty());
1508                assert!(rows.is_empty());
1509            }
1510            other => panic!("Expected PricingTable, got {other:?}"),
1511        }
1512    }
1513
1514    // -- Site ------------------------------------------------------
1515
1516    #[test]
1517    fn resolve_site_with_domain() {
1518        let block = unknown(
1519            "site",
1520            attrs(&[("domain", AttrValue::String("notesurf.io".into()))]),
1521            "name: NoteSurf\ntagline: Notes that belong to you.\ntheme: dark\naccent: #6366f1",
1522        );
1523        match resolve_block(block) {
1524            Block::Site {
1525                domain,
1526                properties,
1527                ..
1528            } => {
1529                assert_eq!(domain, Some("notesurf.io".to_string()));
1530                assert_eq!(properties.len(), 4);
1531                assert_eq!(properties[0].key, "name");
1532                assert_eq!(properties[0].value, "NoteSurf");
1533                assert_eq!(properties[1].key, "tagline");
1534                assert_eq!(properties[1].value, "Notes that belong to you.");
1535                assert_eq!(properties[2].key, "theme");
1536                assert_eq!(properties[2].value, "dark");
1537            }
1538            other => panic!("Expected Site, got {other:?}"),
1539        }
1540    }
1541
1542    #[test]
1543    fn resolve_site_no_domain() {
1544        let block = unknown("site", Attrs::new(), "name: Test Site");
1545        match resolve_block(block) {
1546            Block::Site {
1547                domain,
1548                properties,
1549                ..
1550            } => {
1551                assert!(domain.is_none());
1552                assert_eq!(properties.len(), 1);
1553            }
1554            other => panic!("Expected Site, got {other:?}"),
1555        }
1556    }
1557
1558    // -- Page ------------------------------------------------------
1559
1560    #[test]
1561    fn resolve_page_basic() {
1562        let block = unknown(
1563            "page",
1564            attrs(&[
1565                ("route", AttrValue::String("/".into())),
1566                ("layout", AttrValue::String("hero".into())),
1567            ]),
1568            "# Welcome\n\nSome intro text.",
1569        );
1570        match resolve_block(block) {
1571            Block::Page {
1572                route,
1573                layout,
1574                children,
1575                ..
1576            } => {
1577                assert_eq!(route, "/");
1578                assert_eq!(layout, Some("hero".to_string()));
1579                // All content is markdown (no leaf directives)
1580                assert_eq!(children.len(), 1);
1581                assert!(matches!(&children[0], Block::Markdown { .. }));
1582            }
1583            other => panic!("Expected Page, got {other:?}"),
1584        }
1585    }
1586
1587    #[test]
1588    fn resolve_page_with_nested_cta() {
1589        let content = "# Take notes anywhere.\n\nIntro paragraph.\n\n::cta[label=\"Download\" href=\"/download\" primary]\n::cta[label=\"Try Web\" href=\"https://app.example.com\"]";
1590        let block = unknown(
1591            "page",
1592            attrs(&[("route", AttrValue::String("/".into()))]),
1593            content,
1594        );
1595        match resolve_block(block) {
1596            Block::Page { children, .. } => {
1597                // Should be: Markdown, Cta (primary), Cta (secondary)
1598                assert_eq!(children.len(), 3, "children: {children:#?}");
1599                assert!(matches!(&children[0], Block::Markdown { .. }));
1600                match &children[1] {
1601                    Block::Cta {
1602                        label, primary, ..
1603                    } => {
1604                        assert_eq!(label, "Download");
1605                        assert!(*primary);
1606                    }
1607                    other => panic!("Expected Cta, got {other:?}"),
1608                }
1609                match &children[2] {
1610                    Block::Cta {
1611                        label, primary, ..
1612                    } => {
1613                        assert_eq!(label, "Try Web");
1614                        assert!(!*primary);
1615                    }
1616                    other => panic!("Expected Cta, got {other:?}"),
1617                }
1618            }
1619            other => panic!("Expected Page, got {other:?}"),
1620        }
1621    }
1622
1623    #[test]
1624    fn resolve_page_with_mixed_children() {
1625        let content = "# Hero Title\n\n::hero-image[src=\"hero.png\" alt=\"Screenshot\"]\n\nMore text below.\n\n::cta[label=\"Sign Up\" href=\"/signup\" primary]";
1626        let block = unknown(
1627            "page",
1628            attrs(&[
1629                ("route", AttrValue::String("/".into())),
1630                ("layout", AttrValue::String("hero".into())),
1631            ]),
1632            content,
1633        );
1634        match resolve_block(block) {
1635            Block::Page { children, .. } => {
1636                // Markdown, HeroImage, Markdown, Cta
1637                assert_eq!(children.len(), 4, "children: {children:#?}");
1638                assert!(matches!(&children[0], Block::Markdown { .. }));
1639                assert!(matches!(&children[1], Block::HeroImage { .. }));
1640                assert!(matches!(&children[2], Block::Markdown { .. }));
1641                assert!(matches!(&children[3], Block::Cta { .. }));
1642            }
1643            other => panic!("Expected Page, got {other:?}"),
1644        }
1645    }
1646
1647    #[test]
1648    fn resolve_page_empty() {
1649        let block = unknown(
1650            "page",
1651            attrs(&[("route", AttrValue::String("/about".into()))]),
1652            "",
1653        );
1654        match resolve_block(block) {
1655            Block::Page {
1656                route, children, ..
1657            } => {
1658                assert_eq!(route, "/about");
1659                assert!(children.is_empty());
1660            }
1661            other => panic!("Expected Page, got {other:?}"),
1662        }
1663    }
1664
1665    // -- Passthrough -----------------------------------------------
1666
1667    #[test]
1668    fn resolve_unknown_passthrough() {
1669        let block = unknown("custom_block", Attrs::new(), "whatever");
1670        match resolve_block(block) {
1671            Block::Unknown { name, .. } => {
1672                assert_eq!(name, "custom_block");
1673            }
1674            other => panic!("Expected Unknown passthrough, got {other:?}"),
1675        }
1676    }
1677}