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::citation::{parse_authors, Reference, RefType};
7use crate::types::{
8    AttrValue, Attrs, AuthProvider, BeforeAfterItem, BindingEvent, Block, BookingDay,
9    BookingService, CalloutType, ChartData, ChartSeries, ChartType, Format, StoreItem,
10    ColumnContent, CommandItem, CrateDep, CrateEntry, DataFormat, DecisionStatus, DomainEntry,
11    EmbedType, EnvEntry, EnvVar, FaqItem, FeatureCard, FieldConstraint, FilterField, FooterSection,
12    FormField, FormFieldType, GalleryItem, HeroButton, HttpMethod, ListDisplay, ListFilter,
13    ModelField, ModelFieldType, NavGroup, NavItem, PipelineStep, PostItem, ProductGroup, ProductItem, ProgressStep,
14    RowState, SchemaField,
15    SlideLayout, SmokeCheck, SocialLink, SortSpec, Span, StatItem, StepItem,
16    StyleProperty, TabBarItem, TabPanel, TaskItem, ToolbarItem, Trend, VolumeEntry,
17};
18
19/// Resolve a `Block::Unknown` into a typed variant, if the name matches a known
20/// block type. Unrecognised names are returned unchanged.
21pub fn resolve_block(block: Block) -> Block {
22    let block = adopt_own_line_attrs(block);
23    let Block::Unknown {
24        name,
25        attrs,
26        content,
27        span,
28    } = &block
29    else {
30        return block;
31    };
32
33    match name.as_str() {
34        "callout" => parse_callout(attrs, content, *span),
35        "data" => parse_data(attrs, content, *span),
36        "code" => parse_code(attrs, content, *span),
37        "tasks" | "action-items" => parse_tasks(content, *span),
38        "decision" => parse_decision(attrs, content, *span),
39        "metric" => parse_metric(attrs, *span),
40        "summary" => parse_summary(content, *span),
41        "cite" | "reference-def" => parse_cite(attrs, content, *span),
42        "bibliography" | "references" => parse_bibliography(attrs, *span),
43        "figure" => parse_figure(attrs, *span),
44        "diagram" => parse_diagram(attrs, content, *span),
45        "tabs" => parse_tabs(content, *span),
46        "columns" => parse_columns(content, *span),
47        "quote" => parse_quote(attrs, content, *span),
48        "cta" => parse_cta(attrs, *span),
49        "hero-image" => parse_hero_image(attrs, *span),
50        "testimonial" => parse_testimonial(attrs, content, *span),
51        "style" => parse_style(content, *span),
52        "faq" => parse_faq(content, *span),
53        "pricing-table" => parse_pricing_table(content, *span),
54        "site" => parse_site(attrs, content, *span),
55        "page" => parse_page(attrs, content, *span),
56        "deck" => parse_deck(attrs, content, *span),
57        "slide" => parse_slide(attrs, content, *span),
58        "nav" => parse_nav(attrs, content, *span),
59        "embed" => parse_embed(attrs, *span),
60        "form" => parse_form(attrs, content, *span),
61        "banner" => parse_banner(attrs, content, *span),
62        "product-grid" => parse_product_grid(attrs, content, *span),
63        "post-grid" => parse_post_grid(attrs, content, *span),
64        "gate" => parse_gate(attrs, content, *span),
65        "gallery" => parse_gallery(content, *span),
66        "footer" => parse_footer(attrs, content, *span),
67        "details" => parse_details(attrs, content, *span),
68        "divider" => parse_divider(attrs, *span),
69        "hero" => parse_hero(attrs, content, *span),
70        "features" => parse_features(attrs, content, *span),
71        "steps" => parse_steps(content, *span),
72        "stats" => parse_stats(content, *span),
73        "comparison" => parse_comparison(attrs, content, *span),
74        "logo" => parse_logo(attrs, *span),
75        "toc" => parse_toc(attrs, *span),
76        "before-after" => parse_before_after(attrs, content, *span),
77        "pipeline" => parse_pipeline(content, *span),
78        "section" => parse_section(attrs, content, *span),
79        "product-card" => parse_product_card(attrs, content, *span),
80        // App description blocks
81        "list" => parse_list(attrs, content, *span),
82        "board" => parse_board(attrs, content, *span),
83        "action" => parse_action(attrs, content, *span),
84        "filter-bar" => parse_filter_bar(attrs, content, *span),
85        "search" => parse_search(attrs, *span),
86        "dashboard" => parse_dashboard(attrs, *span),
87        "chat-input" => parse_chat_input(attrs, content, *span),
88        "feed" => parse_feed(attrs, *span),
89        "booking" => parse_booking(attrs, content, *span),
90        "store" => parse_store(attrs, content, *span),
91        // Compound widget mount points
92        "editor" => parse_editor(attrs, *span),
93        "chart" => parse_chart(attrs, content, *span),
94        "split-pane" => parse_split_pane(attrs, *span),
95        // Infrastructure manifest blocks
96        "app" => parse_app(attrs, content, *span),
97        "build" => parse_build(attrs, content, *span),
98        "database" => parse_infra_database(attrs, content, *span),
99        "deploy" => parse_deploy(attrs, content, *span),
100        "env" => parse_infra_env(attrs, content, *span),
101        "health" => parse_health(attrs, *span),
102        "concurrency" => parse_concurrency(attrs, *span),
103        "cicd" => parse_cicd(attrs, content, *span),
104        "smoke" => parse_smoke(attrs, content, *span),
105        "domains" => parse_domains(content, *span),
106        "crates" => parse_crates(content, *span),
107        "deploy_urls" | "deploy-urls" => parse_deploy_urls(content, *span),
108        "volumes" => parse_volumes(content, *span),
109        // App spec blocks (data layer + API)
110        "model" => parse_model(attrs, content, *span),
111        "route" => parse_route(attrs, content, *span),
112        "auth" => parse_auth(attrs, content, *span),
113        "binding" => parse_binding(attrs, content, *span),
114        // App format blocks (schema, deps, config, deploy)
115        "schema" => parse_schema(attrs, content, *span),
116        "use" => parse_use(attrs, content, *span),
117        "app-env" => parse_app_env(attrs, content, *span),
118        "app-deploy" => parse_app_deploy(attrs, content, *span),
119        "row" => parse_row(attrs, content, *span),
120        "infocard" | "info-card" => parse_infocard(attrs, content, *span),
121        // Interactive / application blocks
122        "app-shell" => parse_app_shell(attrs, content, *span),
123        "sidebar" => parse_sidebar(attrs, content, *span),
124        "panel" => parse_panel(attrs, content, *span),
125        "tab-bar" => parse_tab_bar(attrs, content, *span),
126        "tab-content" => parse_tab_content(attrs, content, *span),
127        "toolbar" => parse_toolbar(content, *span),
128        "drawer" => parse_drawer(attrs, content, *span),
129        "modal" => parse_modal(attrs, content, *span),
130        "command-palette" => parse_command_palette(attrs, content, *span),
131        "code-editor" => parse_code_editor(attrs, content, *span),
132        "block-editor" => parse_block_editor(attrs, *span),
133        "terminal" => parse_terminal(attrs, *span),
134        "nav-tree" => parse_nav_tree(attrs, *span),
135        "badge" => parse_badge(attrs, *span),
136        "suggestion-chips" => parse_suggestion_chips(attrs, *span),
137        "chat-thread" => parse_chat_thread(attrs, *span),
138        "chat-input-simple" => parse_chat_input_simple(attrs, *span),
139        "progress" => parse_progress(attrs, content, *span),
140        "log-stream" => parse_log_stream(attrs, *span),
141        "problem-list" => parse_problem_list(attrs, *span),
142        _ => block,
143    }
144}
145
146// ------------------------------------------------------------------
147// Attribute extraction helpers
148// ------------------------------------------------------------------
149
150/// Adopt a whole-line `[attrs]` group displaced onto the block's first content
151/// line (`::features` newline `[cols=3 title="Why us"]`) as the block's
152/// attributes — a common generated-markup slip that otherwise renders the
153/// bracket text as body copy. Fires only when the block has no attrs of its
154/// own, the first non-blank content line is exactly a bracket group, and
155/// `parse_attrs` accepts it with at least one attribute — which keeps markdown
156/// link references (`[1]: url`), task boxes, and prose brackets out.
157fn adopt_own_line_attrs(block: Block) -> Block {
158    let Block::Unknown {
159        name,
160        attrs,
161        content,
162        span,
163    } = block
164    else {
165        return block;
166    };
167
168    if attrs.is_empty() {
169        if let Some((idx, line)) = content
170            .lines()
171            .enumerate()
172            .find(|(_, l)| !l.trim().is_empty())
173        {
174            let trimmed = line.trim();
175            if trimmed.starts_with('[') && trimmed.ends_with(']') {
176                if let Ok(adopted) = crate::attrs::parse_attrs(trimmed) {
177                    if !adopted.is_empty() {
178                        let remaining: String = content
179                            .lines()
180                            .enumerate()
181                            .filter(|(i, _)| *i != idx)
182                            .map(|(_, l)| l)
183                            .collect::<Vec<_>>()
184                            .join("\n");
185                        return Block::Unknown {
186                            name,
187                            attrs: adopted,
188                            content: remaining,
189                            span,
190                        };
191                    }
192                }
193            }
194        }
195    }
196
197    Block::Unknown {
198        name,
199        attrs,
200        content,
201        span,
202    }
203}
204
205fn attr_string(attrs: &Attrs, key: &str) -> Option<String> {
206    attrs.get(key).and_then(|v| match v {
207        AttrValue::String(s) => Some(s.clone()),
208        AttrValue::Number(n) => Some(n.to_string()),
209        AttrValue::Bool(b) => Some(b.to_string()),
210        AttrValue::Null => None,
211    })
212}
213
214fn attr_bool(attrs: &Attrs, key: &str) -> bool {
215    attrs
216        .get(key)
217        .is_some_and(|v| matches!(v, AttrValue::Bool(true)))
218}
219
220// ------------------------------------------------------------------
221// Per-block parsers
222// ------------------------------------------------------------------
223
224fn parse_callout(attrs: &Attrs, content: &str, span: Span) -> Block {
225    let callout_type = attr_string(attrs, "type")
226        .and_then(|s| match s.as_str() {
227            "info" => Some(CalloutType::Info),
228            "warning" => Some(CalloutType::Warning),
229            "danger" => Some(CalloutType::Danger),
230            "tip" => Some(CalloutType::Tip),
231            "note" => Some(CalloutType::Note),
232            "success" => Some(CalloutType::Success),
233            "context" => Some(CalloutType::Context),
234            _ => None,
235        })
236        .unwrap_or(CalloutType::Info);
237
238    let title = attr_string(attrs, "title");
239
240    Block::Callout {
241        callout_type,
242        title,
243        content: content.to_string(),
244        span,
245    }
246}
247
248fn parse_diagram(attrs: &Attrs, content: &str, span: Span) -> Block {
249    // Raw `type` value, lowercased — kept as a String (not an enum) so unknown
250    // types round-trip losslessly and hit the prose fallback at render time.
251    let diagram_type = attr_string(attrs, "type")
252        .map(|s| s.to_lowercase())
253        .unwrap_or_default();
254
255    let title = attr_string(attrs, "title");
256
257    Block::Diagram {
258        diagram_type,
259        title,
260        content: content.to_string(),
261        span,
262    }
263}
264
265fn parse_data(attrs: &Attrs, content: &str, span: Span) -> Block {
266    let id = attr_string(attrs, "id");
267    let sortable = attr_bool(attrs, "sortable");
268
269    let format = attr_string(attrs, "format")
270        .and_then(|s| match s.as_str() {
271            "table" => Some(DataFormat::Table),
272            "csv" => Some(DataFormat::Csv),
273            "json" => Some(DataFormat::Json),
274            _ => None,
275        })
276        .unwrap_or(DataFormat::Table);
277
278    let (headers, rows) = match format {
279        DataFormat::Table => parse_table_content(content),
280        DataFormat::Csv => parse_csv_content(content),
281        DataFormat::Json => (Vec::new(), Vec::new()),
282    };
283
284    Block::Data {
285        id,
286        format,
287        sortable,
288        headers,
289        rows,
290        raw_content: content.to_string(),
291        span,
292    }
293}
294
295/// Parse pipe-delimited table content.
296///
297/// First non-empty line is headers. Lines that look like `|---|---|` are
298/// separator rows and get skipped. Remaining lines are data rows.
299fn parse_table_content(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
300    let mut headers = Vec::new();
301    let mut rows = Vec::new();
302    let mut header_done = false;
303
304    for line in content.lines() {
305        let trimmed = line.trim();
306        if trimmed.is_empty() {
307            continue;
308        }
309
310        // Skip separator lines like |---|---| or | --- | --- |
311        if is_table_separator(trimmed) {
312            continue;
313        }
314
315        let cells: Vec<String> = split_pipe_row(trimmed);
316
317        if !header_done {
318            headers = cells;
319            header_done = true;
320        } else {
321            rows.push(cells);
322        }
323    }
324
325    (headers, rows)
326}
327
328/// Check whether a line is a markdown table separator (e.g. `|---|---|`).
329fn is_table_separator(line: &str) -> bool {
330    let stripped = line.trim().trim_matches('|').trim();
331    if stripped.is_empty() {
332        return false;
333    }
334    stripped
335        .split('|')
336        .all(|cell| cell.trim().chars().all(|c| c == '-' || c == ':'))
337}
338
339/// Split a pipe-delimited row into trimmed cell strings, stripping leading and
340/// trailing pipes.
341fn split_pipe_row(line: &str) -> Vec<String> {
342    let trimmed = line.trim();
343    // Remove leading/trailing pipes.
344    let inner = trimmed
345        .strip_prefix('|')
346        .unwrap_or(trimmed);
347    let inner = inner
348        .strip_suffix('|')
349        .unwrap_or(inner);
350    split_pipe_cells(inner)
351}
352
353/// Split `s` on `|` into trimmed cells, treating `«FILL: label | default»` /
354/// `«IMG: …»` generation slot markers as atomic so their own `|` separator
355/// (part of the FILL grammar, resolved later by
356/// `crate::slots::resolve_slot_markers` on the final page HTML) is never
357/// mistaken for a table column delimiter — that would split one marker into
358/// two mangled cells and silently drop the default text.
359fn split_pipe_cells(s: &str) -> Vec<String> {
360    let mut cells = Vec::new();
361    let mut cur = String::new();
362    let mut in_marker = false;
363    for ch in s.chars() {
364        match ch {
365            '\u{ab}' => {
366                in_marker = true;
367                cur.push(ch);
368            }
369            '\u{bb}' => {
370                in_marker = false;
371                cur.push(ch);
372            }
373            '|' if !in_marker => {
374                cells.push(cur.trim().to_string());
375                cur = String::new();
376            }
377            _ => cur.push(ch),
378        }
379    }
380    cells.push(cur.trim().to_string());
381    cells
382}
383
384/// Parse CSV content: newline-delimited, comma-separated.
385fn parse_csv_content(content: &str) -> (Vec<String>, Vec<Vec<String>>) {
386    let mut headers = Vec::new();
387    let mut rows = Vec::new();
388    let mut header_done = false;
389
390    for line in content.lines() {
391        let trimmed = line.trim();
392        if trimmed.is_empty() {
393            continue;
394        }
395
396        let cells: Vec<String> = trimmed.split(',').map(|c| c.trim().to_string()).collect();
397
398        if !header_done {
399            headers = cells;
400            header_done = true;
401        } else {
402            rows.push(cells);
403        }
404    }
405
406    (headers, rows)
407}
408
409fn parse_code(attrs: &Attrs, content: &str, span: Span) -> Block {
410    let lang = attr_string(attrs, "lang");
411    let file = attr_string(attrs, "file");
412    let highlight = attr_string(attrs, "highlight")
413        .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
414        .unwrap_or_default();
415
416    Block::Code {
417        lang,
418        file,
419        highlight,
420        content: content.to_string(),
421        span,
422    }
423}
424
425fn parse_tasks(content: &str, span: Span) -> Block {
426    let mut items = Vec::new();
427
428    for line in content.lines() {
429        let trimmed = line.trim();
430
431        let (done, rest) = if let Some(rest) = trimmed.strip_prefix("- [x] ") {
432            (true, rest)
433        } else if let Some(rest) = trimmed.strip_prefix("- [X] ") {
434            (true, rest)
435        } else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
436            (false, rest)
437        } else {
438            continue;
439        };
440
441        // Extract optional @assignee from end of text.
442        let (text, assignee) = extract_assignee(rest);
443
444        items.push(TaskItem {
445            done,
446            text,
447            assignee,
448        });
449    }
450
451    Block::Tasks { items, span }
452}
453
454/// Extract a trailing `@username` from the end of a task text.
455///
456/// Returns `(text_without_assignee, Option<assignee>)`.
457fn extract_assignee(text: &str) -> (String, Option<String>) {
458    let trimmed = text.trim_end();
459    if let Some(at_pos) = trimmed.rfind(" @") {
460        let candidate = &trimmed[at_pos + 2..];
461        // Assignee must be a single word (no spaces).
462        if !candidate.is_empty() && !candidate.contains(' ') {
463            let main_text = trimmed[..at_pos].trim_end().to_string();
464            return (main_text, Some(candidate.to_string()));
465        }
466    }
467    (text.to_string(), None)
468}
469
470fn parse_decision(attrs: &Attrs, content: &str, span: Span) -> Block {
471    let status = attr_string(attrs, "status")
472        .and_then(|s| match s.as_str() {
473            "proposed" => Some(DecisionStatus::Proposed),
474            "accepted" => Some(DecisionStatus::Accepted),
475            "rejected" => Some(DecisionStatus::Rejected),
476            "superseded" => Some(DecisionStatus::Superseded),
477            _ => None,
478        })
479        .unwrap_or(DecisionStatus::Proposed);
480
481    let date = attr_string(attrs, "date");
482
483    let deciders = attr_string(attrs, "deciders")
484        .map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
485        .unwrap_or_default();
486
487    Block::Decision {
488        status,
489        date,
490        deciders,
491        content: content.to_string(),
492        span,
493    }
494}
495
496fn parse_metric(attrs: &Attrs, span: Span) -> Block {
497    let label = attr_string(attrs, "label").unwrap_or_default();
498    let value = attr_string(attrs, "value").unwrap_or_default();
499
500    let trend = attr_string(attrs, "trend").and_then(|s| match s.as_str() {
501        "up" => Some(Trend::Up),
502        "down" => Some(Trend::Down),
503        "flat" => Some(Trend::Flat),
504        _ => None,
505    });
506
507    let unit = attr_string(attrs, "unit");
508
509    Block::Metric {
510        label,
511        value,
512        trend,
513        unit,
514        span,
515    }
516}
517
518fn parse_summary(content: &str, span: Span) -> Block {
519    Block::Summary {
520        content: content.to_string(),
521        span,
522    }
523}
524
525/// Parse a `::cite` block into a [`Block::Cite`] holding a [`Reference`].
526///
527/// The key comes from the `key=`/`id=` attr or a body `key:` line. The body is
528/// `field: value` (or `field = value`) lines; recognised fields map onto the
529/// reference (author/title/journal/year/volume/issue/pages/url/doi/…). Multiple
530/// authors use `author = "Last, First; Last2, First2"`.
531fn parse_cite(attrs: &Attrs, content: &str, span: Span) -> Block {
532    let mut reference = Reference::default();
533
534    // Key + type may be supplied as attributes.
535    if let Some(k) = attr_string(attrs, "key").or_else(|| attr_string(attrs, "id")) {
536        reference.key = k;
537    }
538    if let Some(t) = attr_string(attrs, "type") {
539        reference.ref_type = RefType::from_str_lossy(&t);
540    }
541
542    for raw in content.lines() {
543        let line = raw.trim();
544        if line.is_empty() {
545            continue;
546        }
547        // Split on the first ':' or '=' (whichever comes first).
548        let sep = match (line.find(':'), line.find('=')) {
549            (Some(a), Some(b)) => a.min(b),
550            (Some(a), None) => a,
551            (None, Some(b)) => b,
552            (None, None) => continue,
553        };
554        let field = line[..sep].trim().to_ascii_lowercase();
555        let value = strip_quotes(line[sep + 1..].trim());
556        if value.is_empty() {
557            continue;
558        }
559        let v = || Some(value.to_string());
560        match field.as_str() {
561            "key" | "id" => reference.key = value.to_string(),
562            "type" | "kind" => reference.ref_type = RefType::from_str_lossy(value),
563            "author" | "authors" => reference.authors = parse_authors(value),
564            "editor" | "editors" => reference.editors = parse_authors(value),
565            "title" => reference.title = v(),
566            "container" | "journal" | "booktitle" | "proceedings" | "site" => {
567                reference.container = v()
568            }
569            "publisher" => reference.publisher = v(),
570            "year" | "date" => reference.year = v(),
571            "volume" | "vol" => reference.volume = v(),
572            "issue" | "number" | "no" => reference.issue = v(),
573            "pages" | "page" | "pp" => reference.pages = v(),
574            "url" | "link" => reference.url = v(),
575            "doi" => reference.doi = v(),
576            "accessed" | "access" | "retrieved" => reference.accessed = v(),
577            "edition" | "ed" => reference.edition = v(),
578            _ => {}
579        }
580    }
581
582    Block::Cite { reference, span }
583}
584
585/// Strip a single pair of surrounding double or single quotes.
586fn strip_quotes(s: &str) -> &str {
587    let s = s.trim();
588    if s.len() >= 2 {
589        let b = s.as_bytes();
590        if (b[0] == b'"' && b[s.len() - 1] == b'"') || (b[0] == b'\'' && b[s.len() - 1] == b'\'') {
591            return &s[1..s.len() - 1];
592        }
593    }
594    s
595}
596
597/// Parse a `::bibliography` / `::references` block. Optional `style=` overrides
598/// the document `format:` for this list only.
599fn parse_bibliography(attrs: &Attrs, span: Span) -> Block {
600    let style = attr_string(attrs, "style").and_then(|s| match s.to_ascii_lowercase().as_str() {
601        "ieee" => Some(Format::Ieee),
602        "acm" => Some(Format::Acm),
603        "article" => Some(Format::Article),
604        "mla" => Some(Format::Mla),
605        "apa" => Some(Format::Apa),
606        "chicago" => Some(Format::Chicago),
607        _ => None,
608    });
609    Block::Bibliography { style, span }
610}
611
612fn parse_tabs(content: &str, span: Span) -> Block {
613    let mut tabs = Vec::new();
614    let mut current_label: Option<String> = None;
615    let mut current_lines: Vec<&str> = Vec::new();
616
617    for line in content.lines() {
618        let trimmed = line.trim();
619        // Tab labels: `## Label` or `### Label` inside tabs block
620        if let Some(rest) = trimmed.strip_prefix("## ") {
621            // Flush previous tab
622            if let Some(label) = current_label.take() {
623                tabs.push(TabPanel {
624                    label,
625                    content: current_lines.join("\n").trim().to_string(),
626                });
627                current_lines.clear();
628            }
629            current_label = Some(rest.trim().to_string());
630        } else if let Some(rest) = trimmed.strip_prefix("### ") {
631            if let Some(label) = current_label.take() {
632                tabs.push(TabPanel {
633                    label,
634                    content: current_lines.join("\n").trim().to_string(),
635                });
636                current_lines.clear();
637            }
638            current_label = Some(rest.trim().to_string());
639        } else {
640            current_lines.push(line);
641        }
642    }
643
644    // Flush final tab
645    if let Some(label) = current_label {
646        tabs.push(TabPanel {
647            label,
648            content: current_lines.join("\n").trim().to_string(),
649        });
650    } else if !current_lines.is_empty() {
651        // No headers found — single unnamed tab
652        let text = current_lines.join("\n").trim().to_string();
653        if !text.is_empty() {
654            tabs.push(TabPanel {
655                label: "Tab 1".to_string(),
656                content: text,
657            });
658        }
659    }
660
661    Block::Tabs { tabs, span }
662}
663
664fn parse_columns(content: &str, span: Span) -> Block {
665    let mut columns = Vec::new();
666    let mut current_lines: Vec<&str> = Vec::new();
667    let mut found_separator = false;
668
669    for line in content.lines() {
670        let trimmed = line.trim();
671        // Nested :::column directives serve as separators
672        if trimmed.starts_with(":::column") {
673            if !current_lines.is_empty() {
674                columns.push(ColumnContent {
675                    content: current_lines.join("\n").trim().to_string(),
676                });
677                current_lines.clear();
678            }
679            found_separator = true;
680        } else if trimmed == ":::" {
681            // Close a :::column — flush content
682            if found_separator {
683                columns.push(ColumnContent {
684                    content: current_lines.join("\n").trim().to_string(),
685                });
686                current_lines.clear();
687            }
688        } else if trimmed == "---" && !found_separator {
689            // Horizontal rule as column separator (simpler syntax)
690            columns.push(ColumnContent {
691                content: current_lines.join("\n").trim().to_string(),
692            });
693            current_lines.clear();
694            found_separator = true;
695        } else {
696            current_lines.push(line);
697        }
698    }
699
700    // Flush remaining content
701    let remaining = current_lines.join("\n").trim().to_string();
702    if !remaining.is_empty() {
703        columns.push(ColumnContent {
704            content: remaining,
705        });
706    }
707
708    // If no separators were found, treat the whole thing as one column
709    if columns.is_empty() {
710        columns.push(ColumnContent {
711            content: content.trim().to_string(),
712        });
713    }
714
715    Block::Columns { columns, span }
716}
717
718fn parse_quote(attrs: &Attrs, content: &str, span: Span) -> Block {
719    let attribution = attr_string(attrs, "by")
720        .or_else(|| attr_string(attrs, "attribution"))
721        .or_else(|| attr_string(attrs, "author"));
722    let cite = attr_string(attrs, "cite")
723        .or_else(|| attr_string(attrs, "source"));
724
725    Block::Quote {
726        content: content.to_string(),
727        attribution,
728        cite,
729        span,
730    }
731}
732
733fn parse_figure(attrs: &Attrs, span: Span) -> Block {
734    let src = attr_string(attrs, "src").unwrap_or_default();
735    let caption = attr_string(attrs, "caption");
736    let alt = attr_string(attrs, "alt");
737    let width = attr_string(attrs, "width");
738
739    Block::Figure {
740        src,
741        caption,
742        alt,
743        width,
744        span,
745    }
746}
747
748fn parse_cta(attrs: &Attrs, span: Span) -> Block {
749    let label = attr_string(attrs, "label").unwrap_or_default();
750    let href = attr_string(attrs, "href").unwrap_or_default();
751    let primary = attr_bool(attrs, "primary");
752    let icon = attr_string(attrs, "icon");
753
754    Block::Cta {
755        label,
756        href,
757        primary,
758        icon,
759        span,
760    }
761}
762
763fn parse_hero_image(attrs: &Attrs, span: Span) -> Block {
764    let src = attr_string(attrs, "src").unwrap_or_default();
765    let alt = attr_string(attrs, "alt");
766
767    Block::HeroImage { src, alt, span }
768}
769
770fn parse_testimonial(attrs: &Attrs, content: &str, span: Span) -> Block {
771    let author = attr_string(attrs, "author")
772        .or_else(|| attr_string(attrs, "name"));
773    let role = attr_string(attrs, "role")
774        .or_else(|| attr_string(attrs, "title"));
775    let company = attr_string(attrs, "company")
776        .or_else(|| attr_string(attrs, "org"));
777
778    Block::Testimonial {
779        content: content.to_string(),
780        author,
781        role,
782        company,
783        span,
784    }
785}
786
787fn parse_style(content: &str, span: Span) -> Block {
788    let mut properties = Vec::new();
789
790    for line in content.lines() {
791        let trimmed = line.trim();
792        if trimmed.is_empty() {
793            continue;
794        }
795        // Parse "key: value" lines
796        if let Some((key, value)) = trimmed.split_once(':') {
797            let key = key.trim().to_string();
798            let value = value.trim().to_string();
799            if !key.is_empty() && !value.is_empty() {
800                properties.push(StyleProperty { key, value });
801            }
802        }
803    }
804
805    Block::Style { properties, span }
806}
807
808fn parse_faq(content: &str, span: Span) -> Block {
809    let mut items = Vec::new();
810    let mut current_question: Option<String> = None;
811    let mut current_lines: Vec<&str> = Vec::new();
812
813    for line in content.lines() {
814        let trimmed = line.trim();
815        // FAQ questions: `### Question` inside faq block
816        if let Some(rest) = trimmed.strip_prefix("### ") {
817            // Flush previous item
818            if let Some(question) = current_question.take() {
819                items.push(FaqItem {
820                    question,
821                    answer: current_lines.join("\n").trim().to_string(),
822                });
823                current_lines.clear();
824            }
825            current_question = Some(rest.trim().to_string());
826        } else if let Some(rest) = trimmed.strip_prefix("## ") {
827            // Also accept ## headers
828            if let Some(question) = current_question.take() {
829                items.push(FaqItem {
830                    question,
831                    answer: current_lines.join("\n").trim().to_string(),
832                });
833                current_lines.clear();
834            }
835            current_question = Some(rest.trim().to_string());
836        } else {
837            current_lines.push(line);
838        }
839    }
840
841    // Flush final item
842    if let Some(question) = current_question {
843        items.push(FaqItem {
844            question,
845            answer: current_lines.join("\n").trim().to_string(),
846        });
847    }
848
849    Block::Faq { items, span }
850}
851
852fn parse_pricing_table(content: &str, span: Span) -> Block {
853    let (headers, rows) = parse_table_content(content);
854
855    Block::PricingTable {
856        headers,
857        rows,
858        span,
859    }
860}
861
862fn parse_site(attrs: &Attrs, content: &str, span: Span) -> Block {
863    let domain = attr_string(attrs, "domain");
864
865    let mut properties = Vec::new();
866    for line in content.lines() {
867        let trimmed = line.trim();
868        if trimmed.is_empty() {
869            continue;
870        }
871        if let Some((key, value)) = trimmed.split_once(':') {
872            let key = key.trim().to_string();
873            let value = value.trim().to_string();
874            if !key.is_empty() && !value.is_empty() {
875                properties.push(StyleProperty { key, value });
876            }
877        }
878    }
879
880    Block::Site {
881        domain,
882        properties,
883        span,
884    }
885}
886
887fn parse_page(attrs: &Attrs, content: &str, span: Span) -> Block {
888    let route = attr_string(attrs, "route").unwrap_or_default();
889    let layout = attr_string(attrs, "layout");
890    let title = attr_string(attrs, "title");
891    let sidebar = attr_bool(attrs, "sidebar");
892
893    // Scan content for leaf directives, interleaving with markdown.
894    let children = parse_page_children(content);
895
896    Block::Page {
897        route,
898        layout,
899        title,
900        sidebar,
901        content: content.to_string(),
902        children,
903        span,
904    }
905}
906
907/// Parse a `::deck` block — deck-level config (peer of `::site`).
908///
909/// Config may be supplied as attributes (`::deck { theme: surf-dark, aspect: 16:9 }`)
910/// and/or as `key: value` lines in the block body. Both are merged into
911/// `properties`; [`crate::render_slides::extract_deck`] reads the known keys.
912fn parse_deck(attrs: &Attrs, content: &str, span: Span) -> Block {
913    let mut properties = Vec::new();
914
915    // Attributes first.
916    for (key, value) in attrs.iter() {
917        if let Some(v) = attr_value_string(value) {
918            properties.push(StyleProperty {
919                key: key.clone(),
920                value: v,
921            });
922        }
923    }
924
925    // Then `key: value` body lines (mirrors parse_site).
926    for line in content.lines() {
927        let trimmed = line.trim();
928        if trimmed.is_empty() {
929            continue;
930        }
931        if let Some((key, value)) = trimmed.split_once(':') {
932            let key = key.trim().to_string();
933            let value = value.trim().to_string();
934            if !key.is_empty() && !value.is_empty() {
935                properties.push(StyleProperty { key, value });
936            }
937        }
938    }
939
940    Block::Deck { properties, span }
941}
942
943/// Parse a `::slide` block — a single slide container (peer of `::page`).
944///
945/// Children reuse the existing content block families via
946/// [`parse_page_children`], so a slide is an arrangement of existing blocks.
947fn parse_slide(attrs: &Attrs, content: &str, span: Span) -> Block {
948    let layout = attr_string(attrs, "layout")
949        .as_deref()
950        .and_then(SlideLayout::from_name);
951    let kicker = attr_string(attrs, "kicker");
952    let mut notes = attr_string(attrs, "notes");
953
954    let mut children = parse_page_children(content);
955
956    // Extract a `::notes` / `::speaker-notes` child block into the slide's
957    // `notes` field (presenter notes), removing it from the rendered children.
958    // The `notes=` attribute, if present, takes precedence.
959    let mut extracted_notes: Option<String> = None;
960    children.retain(|child| {
961        if let Block::Unknown { name, content, .. } = child
962            && (name == "notes" || name == "speaker-notes" || name == "presenter-notes")
963        {
964            if extracted_notes.is_none() {
965                let text = content.trim();
966                if !text.is_empty() {
967                    extracted_notes = Some(text.to_string());
968                }
969            }
970            return false;
971        }
972        true
973    });
974    if notes.is_none() {
975        notes = extracted_notes;
976    }
977
978    Block::Slide {
979        layout,
980        kicker,
981        notes,
982        content: content.to_string(),
983        children,
984        span,
985    }
986}
987
988/// Stringify an [`AttrValue`] for storage as a [`StyleProperty`] value.
989fn attr_value_string(value: &AttrValue) -> Option<String> {
990    match value {
991        AttrValue::String(s) => Some(s.clone()),
992        AttrValue::Number(n) => Some(n.to_string()),
993        AttrValue::Bool(b) => Some(b.to_string()),
994        AttrValue::Null => None,
995    }
996}
997
998// ------------------------------------------------------------------
999// Page child block scanner
1000// ------------------------------------------------------------------
1001
1002/// Scan page content for both leaf directives and container blocks.
1003///
1004/// Container blocks (`::name\ncontent\n::`) are collected and resolved via
1005/// `resolve_block()`. Leaf directives (`::name[attrs]` with no matching closer)
1006/// are handled as before. Consecutive non-directive lines are collected as
1007/// `Block::Markdown`.
1008fn parse_page_children(content: &str) -> Vec<Block> {
1009    let lines: Vec<&str> = content.lines().collect();
1010    let mut children = Vec::new();
1011    let mut md_lines: Vec<&str> = Vec::new();
1012    let mut i = 0;
1013
1014    // Fence guard mirroring parse.rs::scan_blocks: the tolerant space-after-colons
1015    // opener (and the bare-`::` orphan-closer skip) are suppressed inside fenced
1016    // code blocks so a fenced SurfDoc example nested in a page's content stays
1017    // literal instead of being spliced apart.
1018    let mut in_fence = false;
1019
1020    while i < lines.len() {
1021        let trimmed = lines[i].trim();
1022
1023        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
1024            in_fence = !in_fence;
1025        }
1026
1027        // Check for opening directive. The tolerant space-separated form
1028        // (`:: name`) is ignored inside fences so code samples keep their
1029        // literal lines; the strict form keeps its pre-existing
1030        // non-fence-aware behavior (matches parse.rs::scan_blocks).
1031        let opener = crate::parse::opening_directive(trimmed).filter(|(depth, _, _)| {
1032            !in_fence || crate::parse::directive_name_start(trimmed, *depth) == *depth
1033        });
1034        if let Some((depth, name, attrs_str)) = opener {
1035            // Scan ahead for matching closing directive
1036            if let Some((content_str, end_idx)) =
1037                scan_container_close(&lines, i + 1, depth)
1038            {
1039                // Container block — flush markdown, resolve, advance past closer
1040                flush_md_lines(&mut md_lines, &mut children);
1041
1042                let attrs = crate::attrs::parse_attrs(&attrs_str).unwrap_or_default();
1043                let dummy_span = Span {
1044                    start_line: 0,
1045                    end_line: 0,
1046                    start_offset: 0,
1047                    end_offset: 0,
1048                };
1049
1050                let block = Block::Unknown {
1051                    name,
1052                    attrs,
1053                    content: content_str,
1054                    span: dummy_span,
1055                };
1056                children.push(resolve_block(block));
1057
1058                i = end_idx + 1; // skip past closing ::
1059                continue;
1060            } else {
1061                // No matching closer — treat as leaf directive
1062                if let Some(block) = try_parse_leaf_directive(lines[i]) {
1063                    flush_md_lines(&mut md_lines, &mut children);
1064                    children.push(block);
1065                    i += 1;
1066                    continue;
1067                }
1068                // Not a valid leaf either — fall through to markdown
1069            }
1070        }
1071
1072        // Skip bare closing markers that aren't matched to anything. Only
1073        // outside a fence — inside one, a lone `::` is literal code-sample
1074        // content and must not be dropped (matches parse.rs::scan_blocks).
1075        if !in_fence && crate::parse::closing_directive_depth(trimmed).is_some() {
1076            // Orphan closer — don't leak into markdown
1077            i += 1;
1078            continue;
1079        }
1080
1081        md_lines.push(lines[i]);
1082        i += 1;
1083    }
1084
1085    // Flush remaining markdown
1086    flush_md_lines(&mut md_lines, &mut children);
1087
1088    children
1089}
1090
1091/// Parse a `- [Label](href){icon=… image=… external}` nav row into a `NavItem`.
1092/// The trailing `{…}` is optional and may carry `icon=`, `image=`, and a bare
1093/// `external` token in any order.
1094fn parse_nav_item_line(trimmed: &str) -> Option<NavItem> {
1095    let rest = trimmed.strip_prefix("- [")?;
1096    let label_end = rest.find("](")?;
1097    let label = rest[..label_end].to_string();
1098    let after_bracket = &rest[label_end + 2..]; // skip "]("
1099    let href_end = after_bracket.find(')')?;
1100    let href = after_bracket[..href_end].to_string();
1101
1102    // Optional trailing `{...}` attribute brace.
1103    let mut icon = None;
1104    let mut image = None;
1105    let mut external = false;
1106    let after_link = after_bracket[href_end + 1..].trim_start();
1107    if let Some(brace) = after_link.strip_prefix('{')
1108        && let Some(end) = brace.find('}')
1109    {
1110        for tok in brace[..end].split_whitespace() {
1111            if let Some(v) = tok.strip_prefix("icon=") {
1112                icon = Some(v.to_string());
1113            } else if let Some(v) = tok.strip_prefix("image=") {
1114                image = Some(v.to_string());
1115            } else if tok == "external" {
1116                external = true;
1117            }
1118        }
1119    }
1120    Some(NavItem { label, href, icon, image, external })
1121}
1122
1123fn parse_nav(attrs: &Attrs, content: &str, span: Span) -> Block {
1124    let logo = attr_string(attrs, "logo");
1125    let brand = attr_string(attrs, "brand");
1126    let brand_reg = attr_bool(attrs, "reg");
1127    let drawer = attr_bool(attrs, "drawer");
1128    let minimal = attr_bool(attrs, "minimal");
1129    // Trailing primary CTA link (per-page "Get in touch" etc.).
1130    let cta = match (attr_string(attrs, "cta-label"), attr_string(attrs, "cta-href")) {
1131        (Some(label), Some(href)) => Some(NavItem {
1132            label,
1133            href,
1134            icon: attr_string(attrs, "cta-icon"),
1135            image: None,
1136            external: false,
1137        }),
1138        _ => None,
1139    };
1140
1141    let mut items: Vec<NavItem> = Vec::new();
1142    let mut groups: Vec<NavGroup> = Vec::new();
1143    let mut current_label: Option<String> = None;
1144    let mut current_items: Vec<NavItem> = Vec::new();
1145
1146    // Parse link lines, grouping by `## Heading` / `### Heading` markers. Items
1147    // before any heading stay in the flat `items` list (legacy behaviour).
1148    for line in content.lines() {
1149        let trimmed = line.trim();
1150        if let Some(rest) = trimmed.strip_prefix("## ").or_else(|| trimmed.strip_prefix("### ")) {
1151            // Flush the previous group (or lead items) before starting a new one.
1152            if let Some(label) = current_label.take() {
1153                groups.push(NavGroup {
1154                    label: Some(label),
1155                    items: std::mem::take(&mut current_items),
1156                });
1157            }
1158            current_label = Some(rest.trim().to_string());
1159            continue;
1160        }
1161        if !trimmed.starts_with("- [") {
1162            continue;
1163        }
1164        if let Some(item) = parse_nav_item_line(trimmed) {
1165            if current_label.is_some() {
1166                current_items.push(item);
1167            } else {
1168                items.push(item);
1169            }
1170        }
1171    }
1172    if let Some(label) = current_label {
1173        groups.push(NavGroup {
1174            label: Some(label),
1175            items: current_items,
1176        });
1177    }
1178
1179    Block::Nav { items, logo, groups, brand, brand_reg, cta, drawer, minimal, span }
1180}
1181
1182fn parse_embed(attrs: &Attrs, span: Span) -> Block {
1183    let src = attr_string(attrs, "src").unwrap_or_default();
1184    let title = attr_string(attrs, "title");
1185    let width = attr_string(attrs, "width");
1186    let height = attr_string(attrs, "height");
1187
1188    let embed_type = attr_string(attrs, "type")
1189        .and_then(|s| match s.as_str() {
1190            "map" => Some(EmbedType::Map),
1191            "video" => Some(EmbedType::Video),
1192            "audio" => Some(EmbedType::Audio),
1193            "generic" => Some(EmbedType::Generic),
1194            _ => None,
1195        })
1196        .or_else(|| {
1197            // Auto-detect from URL
1198            let s = src.to_lowercase();
1199            if s.contains("google.com/maps") || s.contains("maps.google") {
1200                Some(EmbedType::Map)
1201            } else if s.contains("youtube.com") || s.contains("youtu.be") || s.contains("vimeo.com") {
1202                Some(EmbedType::Video)
1203            } else if s.contains("soundcloud.com") || s.contains("spotify.com") {
1204                Some(EmbedType::Audio)
1205            } else {
1206                None
1207            }
1208        });
1209
1210    Block::Embed {
1211        src,
1212        embed_type,
1213        width,
1214        height,
1215        title,
1216        span,
1217    }
1218}
1219
1220fn parse_form(attrs: &Attrs, content: &str, span: Span) -> Block {
1221    let submit_label = attr_string(attrs, "submit");
1222    let action = attr_string(attrs, "action");
1223    let method = attr_string(attrs, "method");
1224    let honeypot = attr_bool(attrs, "honeypot");
1225    let mut fields = Vec::new();
1226
1227    for line in content.lines() {
1228        let trimmed = line.trim();
1229        if trimmed.is_empty() {
1230            continue;
1231        }
1232
1233        // Parse field lines: `- Label (type) *` or `- Label (type, "placeholder")`
1234        // or `- Label (select: Option A | Option B | Option C) *`
1235        // or `- type: Label` (colon shorthand: field type before colon, label after)
1236        if let Some(rest) = trimmed.strip_prefix("- ") {
1237            let rest = rest.trim();
1238            // Colon shorthand: `- type: Label` (no parentheses, colon present)
1239            // Only treat as type:label if the left-hand side is a known type keyword.
1240            let (label_part, type_part) = if !rest.contains('(') {
1241                if let Some(colon_pos) = rest.find(':') {
1242                    let maybe_type = rest[..colon_pos].trim().to_lowercase();
1243                    let label_after = rest[colon_pos + 1..].trim();
1244                    let known = matches!(
1245                        maybe_type.as_str(),
1246                        "email" | "tel" | "phone" | "date" | "number" | "password"
1247                            | "select" | "textarea" | "multiline" | "text"
1248                    );
1249                    if known && !label_after.is_empty() {
1250                        (label_after, Some((maybe_type, String::new())))
1251                    } else {
1252                        (rest.trim_end_matches(" *").trim_end_matches('*'), None)
1253                    }
1254                } else {
1255                    (rest.trim_end_matches(" *").trim_end_matches('*'), None)
1256                }
1257            } else {
1258                let paren_start = rest.find('(').unwrap();
1259                let label = rest[..paren_start].trim();
1260                let after_paren = &rest[paren_start + 1..];
1261                let paren_end = after_paren.find(')').unwrap_or(after_paren.len());
1262                let type_str = &after_paren[..paren_end];
1263                let remainder = &after_paren[paren_end..].trim_start_matches(')');
1264                (label, Some((type_str.to_string(), remainder.to_string())))
1265            };
1266
1267            let required = rest.ends_with('*') || rest.ends_with("* ");
1268
1269            let (field_type, placeholder, options) = match &type_part {
1270                Some((type_str, _)) => {
1271                    let type_str = type_str.trim();
1272                    // Check for select with options: "select: A | B | C"
1273                    if let Some(opts_part) = type_str.strip_prefix("select:") {
1274                        let opts: Vec<String> = opts_part
1275                            .split('|')
1276                            .map(|o| o.trim().to_string())
1277                            .filter(|o| !o.is_empty())
1278                            .collect();
1279                        (FormFieldType::Select, None, opts)
1280                    } else {
1281                        // Check for placeholder after comma: "text, Enter your name"
1282                        let (type_name, placeholder) = if let Some((t, p)) = type_str.split_once(',') {
1283                            (t.trim(), Some(p.trim().trim_matches('"').to_string()))
1284                        } else {
1285                            (type_str, None)
1286                        };
1287                        let ft = match type_name {
1288                            "email" => FormFieldType::Email,
1289                            "tel" | "phone" => FormFieldType::Tel,
1290                            "date" => FormFieldType::Date,
1291                            "number" => FormFieldType::Number,
1292                            "password" => FormFieldType::Password,
1293                            "select" => FormFieldType::Select,
1294                            "textarea" | "multiline" => FormFieldType::Textarea,
1295                            _ => FormFieldType::Text,
1296                        };
1297                        (ft, placeholder, Vec::new())
1298                    }
1299                }
1300                None => (FormFieldType::Text, None, Vec::new()),
1301            };
1302
1303            // Generate a machine-safe name from the label
1304            let name = label_part
1305                .to_lowercase()
1306                .replace(|c: char| !c.is_alphanumeric(), "_")
1307                .trim_matches('_')
1308                .to_string();
1309
1310            fields.push(FormField {
1311                label: label_part.to_string(),
1312                name,
1313                field_type,
1314                required,
1315                placeholder,
1316                options,
1317            });
1318        }
1319    }
1320
1321    Block::Form {
1322        fields,
1323        submit_label,
1324        action,
1325        method,
1326        honeypot,
1327        span,
1328    }
1329}
1330
1331fn parse_gallery(content: &str, span: Span) -> Block {
1332    let mut items = Vec::new();
1333
1334    for line in content.lines() {
1335        let trimmed = line.trim();
1336        if trimmed.is_empty() {
1337            continue;
1338        }
1339
1340        // Parse gallery items: `![alt](src) Category: caption`
1341        // or simpler: `![alt](src) caption`
1342        // or minimal: `![](src)`
1343        if let Some(after_bang) = trimmed.strip_prefix("![") {
1344            if let Some(alt_end) = after_bang.find("](") {
1345                let alt_text = &after_bang[..alt_end];
1346                let after_bracket = &after_bang[alt_end + 2..];
1347                if let Some(src_end) = after_bracket.find(')') {
1348                    let src = after_bracket[..src_end].to_string();
1349                    let remainder = after_bracket[src_end + 1..].trim();
1350
1351                    let alt = if alt_text.is_empty() {
1352                        None
1353                    } else {
1354                        Some(alt_text.to_string())
1355                    };
1356
1357                    // Check for "Category: caption" pattern
1358                    let (category, caption) = if let Some(colon_pos) = remainder.find(':') {
1359                        let cat = remainder[..colon_pos].trim();
1360                        let cap = remainder[colon_pos + 1..].trim();
1361                        (
1362                            if cat.is_empty() { None } else { Some(cat.to_string()) },
1363                            if cap.is_empty() { None } else { Some(cap.to_string()) },
1364                        )
1365                    } else if remainder.is_empty() {
1366                        (None, None)
1367                    } else {
1368                        (None, Some(remainder.to_string()))
1369                    };
1370
1371                    items.push(GalleryItem {
1372                        src,
1373                        caption,
1374                        alt,
1375                        category,
1376                    });
1377                }
1378            }
1379        }
1380    }
1381
1382    // Determine column count from number of items
1383    let columns = if items.len() <= 2 {
1384        Some(2)
1385    } else if items.len() <= 6 {
1386        Some(3)
1387    } else {
1388        Some(4)
1389    };
1390
1391    Block::Gallery {
1392        items,
1393        columns,
1394        span,
1395    }
1396}
1397
1398fn parse_footer(attrs: &Attrs, content: &str, span: Span) -> Block {
1399    let brand = attr_string(attrs, "brand");
1400    let brand_reg = attr_bool(attrs, "reg");
1401    let brand_logo = attr_string(attrs, "logo");
1402    let tagline = attr_string(attrs, "tagline");
1403    let mut sections: Vec<FooterSection> = Vec::new();
1404    let mut copyright: Option<String> = None;
1405    let mut social: Vec<SocialLink> = Vec::new();
1406    let mut current_heading: Option<String> = None;
1407    let mut current_links: Vec<NavItem> = Vec::new();
1408
1409    for line in content.lines() {
1410        let trimmed = line.trim();
1411        if trimmed.is_empty() {
1412            continue;
1413        }
1414
1415        // Copyright line: starts with (c), ©, or "Copyright"
1416        if trimmed.starts_with("(c)") || trimmed.starts_with('\u{00a9}') || trimmed.to_lowercase().starts_with("copyright") {
1417            copyright = Some(trimmed.to_string());
1418            continue;
1419        }
1420
1421        // Social link: `@platform url`
1422        if trimmed.starts_with('@') {
1423            if let Some((platform, href)) = trimmed[1..].split_once(|c: char| c.is_whitespace()) {
1424                social.push(SocialLink {
1425                    platform: platform.trim().to_string(),
1426                    href: href.trim().to_string(),
1427                });
1428            }
1429            continue;
1430        }
1431
1432        // Section heading: `## Heading` or `### Heading`
1433        if let Some(rest) = trimmed.strip_prefix("## ") {
1434            // Flush previous section
1435            if let Some(heading) = current_heading.take() {
1436                sections.push(FooterSection {
1437                    heading,
1438                    links: std::mem::take(&mut current_links),
1439                });
1440            }
1441            current_heading = Some(rest.trim().to_string());
1442            continue;
1443        }
1444        if let Some(rest) = trimmed.strip_prefix("### ") {
1445            if let Some(heading) = current_heading.take() {
1446                sections.push(FooterSection {
1447                    heading,
1448                    links: std::mem::take(&mut current_links),
1449                });
1450            }
1451            current_heading = Some(rest.trim().to_string());
1452            continue;
1453        }
1454
1455        // Link: `- [Label](href)` — reuse nav link parsing
1456        if let Some(rest) = trimmed.strip_prefix("- [") {
1457            if let Some(label_end) = rest.find("](") {
1458                let label = rest[..label_end].to_string();
1459                let after_bracket = &rest[label_end + 2..];
1460                if let Some(href_end) = after_bracket.find(')') {
1461                    let href = after_bracket[..href_end].to_string();
1462                    current_links.push(NavItem {
1463                        label,
1464                        href,
1465                        icon: None,
1466                        image: None,
1467                        external: false,
1468                    });
1469                }
1470            }
1471            continue;
1472        }
1473
1474        // Plain text link: `- text` (no href, displayed as text)
1475        if let Some(rest) = trimmed.strip_prefix("- ") {
1476            current_links.push(NavItem {
1477                label: rest.trim().to_string(),
1478                href: String::new(),
1479                icon: None,
1480                image: None,
1481                external: false,
1482            });
1483        }
1484    }
1485
1486    // Flush final section
1487    if let Some(heading) = current_heading {
1488        sections.push(FooterSection {
1489            heading,
1490            links: current_links,
1491        });
1492    }
1493
1494    Block::Footer {
1495        sections,
1496        copyright,
1497        social,
1498        brand,
1499        brand_reg,
1500        brand_logo,
1501        tagline,
1502        span,
1503    }
1504}
1505
1506fn parse_details(attrs: &Attrs, content: &str, span: Span) -> Block {
1507    let title = attr_string(attrs, "title");
1508    let open = attr_bool(attrs, "open");
1509    Block::Details {
1510        title,
1511        open,
1512        content: content.to_string(),
1513        span,
1514    }
1515}
1516
1517fn parse_divider(attrs: &Attrs, span: Span) -> Block {
1518    let label = attr_string(attrs, "label");
1519    Block::Divider { label, span }
1520}
1521
1522/// Scan forward from `start` looking for a closing directive matching `open_depth`.
1523///
1524/// Tracks nesting so that `:::column` / `:::` inside a `::columns` block are
1525/// skipped correctly. If a sibling opening directive at the same depth is
1526/// encountered (e.g. `::callout` while scanning for `::hero-image`'s closer),
1527/// we bail out — the original block has no closer and should be treated as a leaf.
1528///
1529/// Returns `(collected_content, closing_line_index)`.
1530fn scan_container_close(lines: &[&str], start: usize, open_depth: usize) -> Option<(String, usize)> {
1531    let mut nesting = 0usize;
1532    let mut content_lines: Vec<&str> = Vec::new();
1533    // Fence guard mirroring parse_page_children/parse.rs::scan_blocks: suppress
1534    // the tolerant space-after-colons opener match inside fenced code blocks so
1535    // a nested SurfDoc example isn't parsed as a real sibling/nested directive.
1536    let mut in_fence = false;
1537
1538    for i in start..lines.len() {
1539        let trimmed = lines[i].trim();
1540
1541        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
1542            in_fence = !in_fence;
1543        }
1544
1545        // Check for a closing directive
1546        if let Some(close_depth) = crate::parse::closing_directive_depth(trimmed) {
1547            if close_depth == open_depth && nesting == 0 {
1548                // This closes our container
1549                return Some((content_lines.join("\n"), i));
1550            }
1551            // Might close a nested block
1552            if nesting > 0 {
1553                nesting -= 1;
1554                content_lines.push(lines[i]);
1555                continue;
1556            }
1557            // Unmatched closer at wrong depth — include as content
1558            content_lines.push(lines[i]);
1559            continue;
1560        }
1561
1562        // Check for opening directives. Same tolerant-opener fence gating as
1563        // the top-level scan (see parse.rs::scan_blocks).
1564        let opener = crate::parse::opening_directive(trimmed).filter(|(depth, _, _)| {
1565            !in_fence || crate::parse::directive_name_start(trimmed, *depth) == *depth
1566        });
1567        if let Some((nested_depth, _, _)) = opener {
1568            if nested_depth == open_depth && nesting == 0 {
1569                // Sibling block at same depth — our block has no closer
1570                return None;
1571            }
1572            if nested_depth > open_depth {
1573                nesting += 1;
1574            }
1575        }
1576
1577        content_lines.push(lines[i]);
1578    }
1579
1580    // No matching closer found
1581    None
1582}
1583
1584/// Try to parse a single line as a leaf directive (`::name[attrs]`).
1585///
1586/// Returns `Some(resolved_block)` if the line matches, `None` otherwise.
1587fn try_parse_leaf_directive(line: &str) -> Option<Block> {
1588    let trimmed = line.trim();
1589    if !trimmed.starts_with("::") {
1590        return None;
1591    }
1592
1593    // Count leading colons — must be exactly 2 for a top-level directive.
1594    let depth = trimmed.chars().take_while(|&c| c == ':').count();
1595    if depth != 2 {
1596        return None;
1597    }
1598
1599    // Tolerate spaces/tabs between the colons and the name, matching
1600    // `crate::parse::opening_directive`.
1601    let rest = trimmed[2..].trim_start_matches([' ', '\t']);
1602    if rest.is_empty() {
1603        return None; // closing `::`, not an opening directive
1604    }
1605
1606    // Must start with alphabetic character.
1607    let first = rest.chars().next()?;
1608    if !first.is_alphabetic() {
1609        return None;
1610    }
1611
1612    // Scan block name.
1613    let name_end = rest
1614        .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
1615        .unwrap_or(rest.len());
1616    let name = &rest[..name_end];
1617    let remainder = &rest[name_end..];
1618
1619    // Extract attrs if present.
1620    let attrs_str = if remainder.starts_with('[') {
1621        if let Some(close) = remainder.find(']') {
1622            &remainder[..=close]
1623        } else {
1624            remainder
1625        }
1626    } else {
1627        ""
1628    };
1629
1630    let attrs = crate::attrs::parse_attrs(attrs_str).unwrap_or_default();
1631    let dummy_span = Span {
1632        start_line: 0,
1633        end_line: 0,
1634        start_offset: 0,
1635        end_offset: 0,
1636    };
1637
1638    let block = Block::Unknown {
1639        name: name.to_string(),
1640        attrs,
1641        content: String::new(),
1642        span: dummy_span,
1643    };
1644
1645    Some(resolve_block(block))
1646}
1647
1648/// Flush accumulated markdown lines into a `Block::Markdown` if non-empty.
1649fn flush_md_lines(lines: &mut Vec<&str>, children: &mut Vec<Block>) {
1650    let text = lines.join("\n");
1651    let trimmed = text.trim();
1652    if !trimmed.is_empty() {
1653        children.push(Block::Markdown {
1654            content: text.trim().to_string(),
1655            span: Span {
1656                start_line: 0,
1657                end_line: 0,
1658                start_offset: 0,
1659                end_offset: 0,
1660            },
1661        });
1662    }
1663    lines.clear();
1664}
1665
1666// ------------------------------------------------------------------
1667// Landing page block parsers
1668// ------------------------------------------------------------------
1669
1670/// Parse the brace suffix on a hero/banner button link — `{primary}`,
1671/// `{external}`, `{primary external}` (order-free, `.`-prefixed markdown
1672/// class forms accepted) → (primary, external).
1673fn parse_button_flags(suffix: &str) -> (bool, bool) {
1674    let Some(inner) = suffix.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
1675        return (false, false);
1676    };
1677    let mut primary = false;
1678    let mut external = false;
1679    for flag in inner.split_whitespace() {
1680        match flag.trim_start_matches('.') {
1681            "primary" => primary = true,
1682            "external" => external = true,
1683            _ => {}
1684        }
1685    }
1686    (primary, external)
1687}
1688
1689fn parse_hero(attrs: &Attrs, content: &str, span: Span) -> Block {
1690    let badge = attr_string(attrs, "badge");
1691    let align = attr_string(attrs, "align").unwrap_or_else(|| "center".to_string());
1692    let image = attr_string(attrs, "image");
1693    let image_alt = attr_string(attrs, "image-alt");
1694    let layout = attr_string(attrs, "layout");
1695    let transparent = attr_bool(attrs, "transparent");
1696
1697    let mut headline: Option<String> = None;
1698    let mut subtitle_lines: Vec<&str> = Vec::new();
1699    let mut buttons: Vec<HeroButton> = Vec::new();
1700    let mut in_subtitle = false;
1701    // Lines NOT consumed as headline/subtitle/buttons. Only these survive as
1702    // `content` — native renderers draw `content` verbatim below the typed
1703    // fields, so keeping the full body there duplicates the hero on screen.
1704    let mut leftover: Vec<&str> = Vec::new();
1705
1706    for line in content.lines() {
1707        let trimmed = line.trim();
1708        if trimmed.is_empty() {
1709            if in_subtitle {
1710                in_subtitle = false;
1711            }
1712            leftover.push("");
1713            continue;
1714        }
1715
1716        // Heading -> headline
1717        if let Some(rest) = trimmed.strip_prefix("# ") {
1718            headline = Some(rest.trim().to_string());
1719            in_subtitle = false;
1720            continue;
1721        }
1722
1723        // Markdown link -> button: [Label](href){primary} or [Label](href)
1724        if trimmed.starts_with('[')
1725            && let Some(label_end) = trimmed.find("](") {
1726                let label = trimmed[1..label_end].to_string();
1727                let after = &trimmed[label_end + 2..];
1728                // Find closing paren, possibly followed by {primary} / {.primary}
1729                if let Some(href_end) = after.find(')') {
1730                    let href = after[..href_end].to_string();
1731                    let suffix = after[href_end + 1..].trim();
1732                    let (primary, external) = parse_button_flags(suffix);
1733                    buttons.push(HeroButton {
1734                        label,
1735                        href,
1736                        primary,
1737                        external,
1738                    });
1739                    continue;
1740                }
1741            }
1742
1743        // Otherwise it's subtitle text
1744        if headline.is_some() && buttons.is_empty() {
1745            subtitle_lines.push(trimmed);
1746            in_subtitle = true;
1747        } else {
1748            leftover.push(line);
1749        }
1750    }
1751
1752    let subtitle = if subtitle_lines.is_empty() {
1753        None
1754    } else {
1755        Some(subtitle_lines.join(" "))
1756    };
1757    let content = leftover.join("\n").trim().to_string();
1758
1759    Block::Hero {
1760        headline,
1761        subtitle,
1762        badge,
1763        align,
1764        image,
1765        image_alt,
1766        layout,
1767        transparent,
1768        buttons,
1769        content,
1770        span,
1771    }
1772}
1773
1774/// Parse a `::banner` block: a centered CTA band.
1775///
1776/// Reuses the hero authoring grammar — `# Heading`, free-text subtitle lines,
1777/// and `[Label](href){primary}` button links — but renders a compact mid-page
1778/// band rather than a page hero.
1779fn parse_banner(attrs: &Attrs, content: &str, span: Span) -> Block {
1780    let id = attr_string(attrs, "id");
1781    let mut headline: Option<String> = None;
1782    let mut subtitle_lines: Vec<&str> = Vec::new();
1783    let mut buttons: Vec<HeroButton> = Vec::new();
1784
1785    for line in content.lines() {
1786        let trimmed = line.trim();
1787        if trimmed.is_empty() {
1788            continue;
1789        }
1790        if let Some(rest) = trimmed.strip_prefix("# ") {
1791            headline = Some(rest.trim().to_string());
1792            continue;
1793        }
1794        if trimmed.starts_with('[')
1795            && let Some(label_end) = trimmed.find("](")
1796        {
1797            let label = trimmed[1..label_end].to_string();
1798            let after = &trimmed[label_end + 2..];
1799            if let Some(href_end) = after.find(')') {
1800                let href = after[..href_end].to_string();
1801                let suffix = after[href_end + 1..].trim();
1802                let (primary, external) = parse_button_flags(suffix);
1803                buttons.push(HeroButton {
1804                    label,
1805                    href,
1806                    primary,
1807                    external,
1808                });
1809                continue;
1810            }
1811        }
1812        subtitle_lines.push(trimmed);
1813    }
1814
1815    let subtitle = if subtitle_lines.is_empty() {
1816        None
1817    } else {
1818        Some(subtitle_lines.join(" "))
1819    };
1820
1821    Block::Banner {
1822        headline,
1823        subtitle,
1824        buttons,
1825        id,
1826        // Every non-blank line above is consumed into a typed field, so no
1827        // free-form content survives — mirroring `parse_hero`'s leftover rule
1828        // (native renderers draw `content` verbatim; a full-body copy here
1829        // renders the banner twice).
1830        content: String::new(),
1831        span,
1832    }
1833}
1834
1835/// Parse a `::product-grid` block.
1836///
1837/// `### Group` headings split the grid into labelled groups. Each card is a
1838/// pipe-delimited list item: `- Name | emblem-src | href | tagline | bg`. The
1839/// emblem, tagline, and bg fields are optional (leave empty or omit trailing
1840/// pipes). The emblem field also accepts `icon:<lucide-name>` — the built-in
1841/// icon renders as an accent chip instead of an image (unknown → omitted).
1842/// An href of `-` (or empty) marks a **linkless card** — a product
1843/// with no public destination yet: classic cards render as a static `<div>`
1844/// (no arrow, no hover), tiles drop the default "Learn more" CTA.
1845/// `[tiles]` renders apple.com-style promo tiles; the bg field then
1846/// picks the tile background: `image:<src>` / `color:<css>` / `gradient:<css>`
1847/// / `surface` (opaque theme surface, theme-following text) / `transparent`,
1848/// with a trailing ` dark` token for light-on-dark text.
1849fn parse_product_grid(attrs: &Attrs, content: &str, span: Span) -> Block {
1850    let tiles = attr_bool(attrs, "tiles");
1851    let mut groups: Vec<ProductGroup> = Vec::new();
1852    let mut current = ProductGroup {
1853        label: None,
1854        items: Vec::new(),
1855        cols: None,
1856    };
1857    let mut started = false;
1858
1859    for line in content.lines() {
1860        let trimmed = line.trim();
1861        if trimmed.is_empty() {
1862            continue;
1863        }
1864        if let Some(rest) = trimmed
1865            .strip_prefix("### ")
1866            .or_else(|| trimmed.strip_prefix("## "))
1867        {
1868            // New group heading — flush the previous group if it has content.
1869            if started && (!current.items.is_empty() || current.label.is_some()) {
1870                groups.push(current);
1871            }
1872            // `{cols=N}` brace on the heading line sets this row's max columns
1873            // (tiles mode); a heading that is ONLY the brace starts an unlabelled row.
1874            let mut heading = rest.trim().to_string();
1875            let mut cols = None;
1876            if let Some(i) = heading.find("{cols=") {
1877                let tail = &heading[i + 6..];
1878                if let Some(j) = tail.find('}') {
1879                    cols = tail[..j].trim().parse::<u8>().ok().map(|n| n.clamp(1, 3));
1880                    heading = format!("{}{}", &heading[..i], &heading[i + 6 + j + 1..]).trim().to_string();
1881                }
1882            }
1883            current = ProductGroup {
1884                label: if heading.is_empty() { None } else { Some(heading) },
1885                items: Vec::new(),
1886                cols,
1887            };
1888            started = true;
1889            continue;
1890        }
1891        if let Some(rest) = trimmed.strip_prefix("- ") {
1892            let parts: Vec<&str> = rest.split('|').map(|p| p.trim()).collect();
1893            let name = parts.first().copied().unwrap_or("").to_string();
1894            if name.is_empty() {
1895                continue;
1896            }
1897            let emblem = parts
1898                .get(1)
1899                .filter(|s| !s.is_empty())
1900                .map(|s| s.to_string());
1901            let href = parts.get(2).copied().unwrap_or("").to_string();
1902            let tagline = parts
1903                .get(3)
1904                .filter(|s| !s.is_empty())
1905                .map(|s| s.to_string());
1906            let bg = parts
1907                .get(4)
1908                .filter(|s| !s.is_empty())
1909                .map(|s| s.to_string());
1910            // Trailing `[Label](href)` fields (after bg): one link = the
1911            // secondary CTA; two links = primary override + secondary.
1912            let links: Vec<(String, String)> = parts
1913                .get(5..)
1914                .unwrap_or(&[])
1915                .iter()
1916                .filter_map(|s| {
1917                    let rest = s.trim().strip_prefix('[')?;
1918                    let (label, tail) = rest.split_once("](")?;
1919                    let href = tail.strip_suffix(')')?;
1920                    Some((label.to_string(), href.to_string()))
1921                })
1922                .collect();
1923            let (cta1, cta2) = match links.len() {
1924                0 => (None, None),
1925                1 => (None, Some(links[0].clone())),
1926                _ => (Some(links[0].clone()), Some(links[1].clone())),
1927            };
1928            current.items.push(ProductItem {
1929                name,
1930                href,
1931                emblem,
1932                tagline,
1933                cta1_label: cta1.as_ref().map(|l| l.0.clone()),
1934                cta1_href: cta1.as_ref().map(|l| l.1.clone()),
1935                cta2_label: cta2.as_ref().map(|l| l.0.clone()),
1936                cta2_href: cta2.as_ref().map(|l| l.1.clone()),
1937                bg,
1938            });
1939            started = true;
1940        }
1941    }
1942    if !current.items.is_empty() || current.label.is_some() {
1943        groups.push(current);
1944    }
1945
1946    Block::ProductGrid { groups, tiles, span }
1947}
1948
1949/// Parse a `::post-grid` block: a card grid for a blog/news/events index.
1950///
1951/// `title`/`subtitle` come from attrs. Each card is a markdown-link bullet with
1952/// optional `{external}` brace and pipe-delimited trailing fields:
1953///
1954/// ```text
1955/// - [Card Title](/href){external} | Category · Date | One-line excerpt | /assets/img.png
1956/// ```
1957///
1958/// Fields after the link are split on `|`, trimmed, and all optional:
1959/// `meta`, `excerpt`, `image` in that order.
1960fn parse_post_grid(attrs: &Attrs, content: &str, span: Span) -> Block {
1961    let title = attr_string(attrs, "title");
1962    let subtitle = attr_string(attrs, "subtitle");
1963    let mut items: Vec<PostItem> = Vec::new();
1964
1965    for line in content.lines() {
1966        let trimmed = line.trim();
1967        let Some(rest) = trimmed.strip_prefix("- ") else {
1968            continue;
1969        };
1970        let rest = rest.trim();
1971        if !rest.starts_with('[') {
1972            continue;
1973        }
1974        let Some(label_end) = rest.find("](") else {
1975            continue;
1976        };
1977        let post_title = rest[1..label_end].trim().to_string();
1978        let after = &rest[label_end + 2..];
1979        let Some(href_end) = after.find(')') else {
1980            continue;
1981        };
1982        let href = after[..href_end].trim().to_string();
1983        let mut tail = after[href_end + 1..].trim();
1984
1985        // Optional `{external}` brace immediately after the link.
1986        let mut external = false;
1987        if let Some(stripped) = tail.strip_prefix("{external}") {
1988            external = true;
1989            tail = stripped.trim();
1990        }
1991
1992        // Remaining pipe-delimited fields: meta | excerpt | image.
1993        let tail = tail.trim_start_matches('|').trim();
1994        let fields: Vec<&str> = if tail.is_empty() {
1995            Vec::new()
1996        } else {
1997            tail.split('|').map(|f| f.trim()).collect()
1998        };
1999        let field = |i: usize| {
2000            fields
2001                .get(i)
2002                .filter(|s| !s.is_empty())
2003                .map(|s| s.to_string())
2004        };
2005
2006        if post_title.is_empty() {
2007            continue;
2008        }
2009        items.push(PostItem {
2010            title: post_title,
2011            href,
2012            meta: field(0),
2013            excerpt: field(1),
2014            image: field(2),
2015            external,
2016        });
2017    }
2018
2019    Block::PostGrid {
2020        title,
2021        subtitle,
2022        items,
2023        span,
2024    }
2025}
2026
2027/// Parse a `::gate` block: an access-code card (password + submit).
2028///
2029/// All fields come from attrs. `action` defaults to "". When the `subtitle`
2030/// attr is absent, any free-text body becomes the subtitle.
2031fn parse_gate(attrs: &Attrs, content: &str, span: Span) -> Block {
2032    let title = attr_string(attrs, "title");
2033    let action = attr_string(attrs, "action").unwrap_or_default();
2034    let field_label = attr_string(attrs, "field");
2035    let submit_label = attr_string(attrs, "submit");
2036    let error = attr_string(attrs, "error");
2037
2038    let subtitle = attr_string(attrs, "subtitle").or_else(|| {
2039        let body: Vec<&str> = content
2040            .lines()
2041            .map(|l| l.trim())
2042            .filter(|l| !l.is_empty())
2043            .collect();
2044        if body.is_empty() {
2045            None
2046        } else {
2047            Some(body.join(" "))
2048        }
2049    });
2050
2051    Block::Gate {
2052        title,
2053        subtitle,
2054        action,
2055        field_label,
2056        submit_label,
2057        error,
2058        span,
2059    }
2060}
2061
2062fn parse_features(attrs: &Attrs, content: &str, span: Span) -> Block {
2063    let cols = attr_string(attrs, "cols").and_then(|s| s.parse::<u32>().ok());
2064    let mut cards: Vec<FeatureCard> = Vec::new();
2065    let mut current_title: Option<String> = None;
2066    let mut current_icon: Option<String> = None;
2067    let mut current_lines: Vec<&str> = Vec::new();
2068
2069    let flush = |title: String, icon: Option<String>, lines: &[&str]| -> FeatureCard {
2070        let mut body_lines: Vec<&str> = Vec::new();
2071        let mut link_label: Option<String> = None;
2072        let mut link_href: Option<String> = None;
2073
2074        for &line in lines {
2075            body_lines.push(line);
2076        }
2077
2078        // Check if the last non-empty line is a markdown link
2079        if let Some(last) = body_lines.iter().rev().find(|l| !l.trim().is_empty()) {
2080            let t = last.trim();
2081            if t.starts_with('[')
2082                && let Some(le) = t.find("](") {
2083                    let after = &t[le + 2..];
2084                    if let Some(he) = after.find(')') {
2085                        link_label = Some(t[1..le].to_string());
2086                        link_href = Some(after[..he].to_string());
2087                        // Remove last line from body
2088                        while body_lines.last().is_some_and(|l| l.trim().is_empty()) {
2089                            body_lines.pop();
2090                        }
2091                        body_lines.pop(); // remove the link line
2092                    }
2093                }
2094        }
2095
2096        let body = body_lines.join("\n").trim().to_string();
2097        FeatureCard {
2098            title,
2099            icon,
2100            body,
2101            link_label,
2102            link_href,
2103        }
2104    };
2105
2106    for line in content.lines() {
2107        let trimmed = line.trim();
2108        // Accept both `### ` and `## ` headings to start a card (mirrors parse_steps).
2109        if let Some(rest) = trimmed
2110            .strip_prefix("### ")
2111            .or_else(|| trimmed.strip_prefix("## "))
2112        {
2113            // Flush previous card
2114            if let Some(title) = current_title.take() {
2115                cards.push(flush(title, current_icon.take(), &current_lines));
2116                current_lines.clear();
2117            }
2118            // Extract {icon=name} from heading
2119            let heading = rest.trim();
2120            if let Some(brace_start) = heading.rfind("{icon=") {
2121                let before = heading[..brace_start].trim().to_string();
2122                let after = &heading[brace_start + 6..];
2123                let icon_name = after.trim_end_matches('}').to_string();
2124                current_title = Some(before);
2125                current_icon = Some(icon_name);
2126            } else {
2127                current_title = Some(heading.to_string());
2128                current_icon = None;
2129            }
2130        } else {
2131            current_lines.push(line);
2132        }
2133    }
2134
2135    // Flush final card
2136    if let Some(title) = current_title {
2137        cards.push(flush(title, current_icon, &current_lines));
2138    }
2139
2140    Block::Features { cards, cols, span }
2141}
2142
2143fn parse_steps(content: &str, span: Span) -> Block {
2144    let mut steps: Vec<StepItem> = Vec::new();
2145    let mut current_title: Option<String> = None;
2146    let mut current_time: Option<String> = None;
2147    let mut current_lines: Vec<&str> = Vec::new();
2148
2149    for line in content.lines() {
2150        let trimmed = line.trim();
2151        if let Some(rest) = trimmed
2152            .strip_prefix("### ")
2153            .or_else(|| trimmed.strip_prefix("## "))
2154        {
2155            // Flush previous step
2156            if let Some(title) = current_title.take() {
2157                steps.push(StepItem {
2158                    title,
2159                    time: current_time.take(),
2160                    body: current_lines.join("\n").trim().to_string(),
2161                });
2162                current_lines.clear();
2163            }
2164            // Extract {time="X"} from heading
2165            let heading = rest.trim();
2166            if let Some(brace_start) = heading.rfind("{time=") {
2167                let before = heading[..brace_start].trim().to_string();
2168                let after = &heading[brace_start + 6..];
2169                let time_val = after.trim_end_matches('}').trim_matches('"').to_string();
2170                current_title = Some(before);
2171                current_time = Some(time_val);
2172            } else {
2173                current_title = Some(heading.to_string());
2174                current_time = None;
2175            }
2176        } else {
2177            current_lines.push(line);
2178        }
2179    }
2180
2181    // Flush final step
2182    if let Some(title) = current_title {
2183        steps.push(StepItem {
2184            title,
2185            time: current_time,
2186            body: current_lines.join("\n").trim().to_string(),
2187        });
2188    }
2189
2190    Block::Steps { steps, span }
2191}
2192
2193/// Parse a `::booking` block.
2194///
2195/// Attrs: `title`, `service-label`. Content lines:
2196/// - `service: Name | Duration | Price`  (duration/price optional)
2197/// - `day: 2026-07-06 | 9:00 AM, 10:00 AM, 2:00 PM`  (slots comma-separated;
2198///   `full` or no slots → the day renders as unavailable)
2199fn parse_booking(attrs: &Attrs, content: &str, span: Span) -> Block {
2200    let mut services: Vec<BookingService> = Vec::new();
2201    let mut days: Vec<BookingDay> = Vec::new();
2202
2203    for line in content.lines() {
2204        let trimmed = line.trim();
2205        let trimmed = trimmed.strip_prefix("- ").unwrap_or(trimmed);
2206        if let Some(rest) = trimmed.strip_prefix("service:") {
2207            let mut parts = rest.split('|').map(|p| p.trim());
2208            let name = parts.next().unwrap_or("").to_string();
2209            if name.is_empty() {
2210                continue;
2211            }
2212            let duration = parts.next().filter(|s| !s.is_empty()).map(str::to_string);
2213            let price = parts.next().filter(|s| !s.is_empty()).map(str::to_string);
2214            services.push(BookingService {
2215                name,
2216                duration,
2217                price,
2218            });
2219        } else if let Some(rest) = trimmed.strip_prefix("day:") {
2220            let mut parts = rest.splitn(2, '|');
2221            let date = parts.next().unwrap_or("").trim().to_string();
2222            if date.is_empty() {
2223                continue;
2224            }
2225            let slots_raw = parts.next().unwrap_or("").trim();
2226            let slots: Vec<String> = if slots_raw.is_empty()
2227                || slots_raw.eq_ignore_ascii_case("full")
2228                || slots_raw.eq_ignore_ascii_case("none")
2229            {
2230                Vec::new()
2231            } else {
2232                slots_raw
2233                    .split(',')
2234                    .map(|s| s.trim().to_string())
2235                    .filter(|s| !s.is_empty())
2236                    .collect()
2237            };
2238            days.push(BookingDay { date, slots });
2239        }
2240    }
2241
2242    Block::Booking {
2243        title: attr_string(attrs, "title"),
2244        service_label: attr_string(attrs, "service-label"),
2245        services,
2246        days,
2247        span,
2248    }
2249}
2250
2251/// Parse a `::store` block.
2252///
2253/// Attrs: `title`, `currency`. Content lines:
2254/// - `category: Home`  (sets the category for subsequent items)
2255/// - `item: Name | Price | Blurb | Badge`  (blurb/badge optional)
2256fn parse_store(attrs: &Attrs, content: &str, span: Span) -> Block {
2257    let mut items: Vec<StoreItem> = Vec::new();
2258    let mut current_category: Option<String> = None;
2259
2260    for line in content.lines() {
2261        let trimmed = line.trim();
2262        let trimmed = trimmed.strip_prefix("- ").unwrap_or(trimmed);
2263        if let Some(rest) = trimmed.strip_prefix("category:") {
2264            let c = rest.trim();
2265            current_category = if c.is_empty() {
2266                None
2267            } else {
2268                Some(c.to_string())
2269            };
2270        } else if let Some(rest) = trimmed.strip_prefix("item:") {
2271            let mut parts = rest.split('|').map(|p| p.trim());
2272            let name = parts.next().unwrap_or("").to_string();
2273            if name.is_empty() {
2274                continue;
2275            }
2276            let price = parts.next().unwrap_or("0").to_string();
2277            let blurb = parts.next().filter(|s| !s.is_empty()).map(str::to_string);
2278            let badge = parts.next().filter(|s| !s.is_empty()).map(str::to_string);
2279            items.push(StoreItem {
2280                name,
2281                price,
2282                blurb,
2283                badge,
2284                category: current_category.clone(),
2285            });
2286        }
2287    }
2288
2289    Block::Store {
2290        title: attr_string(attrs, "title"),
2291        currency: attr_string(attrs, "currency"),
2292        items,
2293        span,
2294    }
2295}
2296
2297fn parse_stats(content: &str, span: Span) -> Block {
2298    let mut items: Vec<StatItem> = Vec::new();
2299
2300    for line in content.lines() {
2301        let trimmed = line.trim();
2302        let text = trimmed.strip_prefix("- ").unwrap_or(trimmed);
2303        if text.is_empty() {
2304            continue;
2305        }
2306
2307        // Pipe syntax: `value | label` (no inline color).
2308        if let Some(pipe) = text.find('|') {
2309            let value = text[..pipe].trim().to_string();
2310            let label = text[pipe + 1..].trim().to_string();
2311            if !value.is_empty() && !label.is_empty() {
2312                items.push(StatItem {
2313                    value,
2314                    label,
2315                    color: None,
2316                });
2317            }
2318            continue;
2319        }
2320
2321        // Extract {label="..." color="..."} suffix
2322        if let Some(brace_start) = text.rfind('{') {
2323            let value = text[..brace_start].trim().to_string();
2324            let attrs_str = &text[brace_start + 1..].trim_end_matches('}');
2325            let label = extract_quoted_attr(attrs_str, "label").unwrap_or_default();
2326            let color = extract_quoted_attr(attrs_str, "color");
2327            if !value.is_empty() && !label.is_empty() {
2328                items.push(StatItem {
2329                    value,
2330                    label,
2331                    color,
2332                });
2333            }
2334        }
2335    }
2336
2337    Block::Stats { items, span }
2338}
2339
2340/// Extract a quoted attribute value from an inline attr string like `label="Foo" color="#hex"`.
2341fn extract_quoted_attr(s: &str, key: &str) -> Option<String> {
2342    let pattern = format!("{}=", key);
2343    if let Some(pos) = s.find(&pattern) {
2344        let after = &s[pos + pattern.len()..];
2345        if let Some(inner) = after.strip_prefix('"') {
2346            if let Some(end) = inner.find('"') {
2347                return Some(inner[..end].to_string());
2348            }
2349        } else {
2350            // Unquoted value -- take until space or end
2351            let end = after.find(' ').unwrap_or(after.len());
2352            return Some(after[..end].to_string());
2353        }
2354    }
2355    None
2356}
2357
2358fn parse_comparison(attrs: &Attrs, content: &str, span: Span) -> Block {
2359    let highlight = attr_string(attrs, "highlight");
2360
2361    let mut headers: Vec<String> = Vec::new();
2362    let mut rows: Vec<Vec<String>> = Vec::new();
2363
2364    for line in content.lines() {
2365        let trimmed = line.trim();
2366        if trimmed.is_empty() {
2367            continue;
2368        }
2369        if !trimmed.starts_with('|') {
2370            continue;
2371        }
2372        // Skip separator rows
2373        let inner = trimmed.trim_matches('|');
2374        if inner.chars().all(|c| c == '-' || c == '|' || c == ' ' || c == ':') {
2375            continue;
2376        }
2377        let cells: Vec<String> = trimmed
2378            .split('|')
2379            .filter(|s| !s.is_empty())
2380            .map(|s| s.trim().to_string())
2381            .collect();
2382        if headers.is_empty() {
2383            headers = cells;
2384        } else {
2385            rows.push(cells);
2386        }
2387    }
2388
2389    Block::Comparison {
2390        headers,
2391        rows,
2392        highlight,
2393        span,
2394    }
2395}
2396
2397fn parse_logo(attrs: &Attrs, span: Span) -> Block {
2398    let src = attr_string(attrs, "src").unwrap_or_default();
2399    let alt = attr_string(attrs, "alt");
2400    let size = attr_string(attrs, "size").and_then(|s| s.parse::<u32>().ok());
2401    Block::Logo {
2402        src,
2403        alt,
2404        size,
2405        span,
2406    }
2407}
2408
2409fn parse_toc(attrs: &Attrs, span: Span) -> Block {
2410    let depth = attr_string(attrs, "depth")
2411        .and_then(|s| s.parse::<u32>().ok())
2412        .unwrap_or(3);
2413    Block::Toc {
2414        depth,
2415        entries: Vec::new(),
2416        span,
2417    }
2418}
2419
2420fn parse_before_after(attrs: &Attrs, content: &str, span: Span) -> Block {
2421    let transition = attr_string(attrs, "transition");
2422    let mut before_items: Vec<BeforeAfterItem> = Vec::new();
2423    let mut after_items: Vec<BeforeAfterItem> = Vec::new();
2424    let mut in_after = false;
2425
2426    for line in content.lines() {
2427        let trimmed = line.trim();
2428        let lower = trimmed.to_ascii_lowercase();
2429        // Accept both `## Before`/`## After` and `### Before`/`### After`.
2430        if lower == "## before" || lower == "### before" {
2431            in_after = false;
2432            continue;
2433        }
2434        if lower == "## after" || lower == "### after" {
2435            in_after = true;
2436            continue;
2437        }
2438        // Skip blanks and `---` section separators.
2439        if trimmed.is_empty() || trimmed.chars().all(|c| c == '-') {
2440            continue;
2441        }
2442        let text = trimmed.strip_prefix("- ").unwrap_or(trimmed);
2443        // `label | detail` is optional — a plain bullet is just a label.
2444        let item = if let Some(pipe_pos) = text.find(" | ") {
2445            BeforeAfterItem {
2446                label: text[..pipe_pos].trim().to_string(),
2447                detail: text[pipe_pos + 3..].trim().to_string(),
2448            }
2449        } else {
2450            BeforeAfterItem {
2451                label: text.to_string(),
2452                detail: String::new(),
2453            }
2454        };
2455        if in_after {
2456            after_items.push(item);
2457        } else {
2458            before_items.push(item);
2459        }
2460    }
2461
2462    Block::BeforeAfter {
2463        before_items,
2464        after_items,
2465        transition,
2466        span,
2467    }
2468}
2469
2470fn parse_pipeline(content: &str, span: Span) -> Block {
2471    let mut steps: Vec<PipelineStep> = Vec::new();
2472
2473    for line in content.lines() {
2474        let trimmed = line.trim();
2475        if trimmed.is_empty() {
2476            continue;
2477        }
2478        let text = trimmed.strip_prefix("- ").unwrap_or(trimmed);
2479        if let Some(pipe_pos) = text.find(" | ") {
2480            let label = text[..pipe_pos].trim().to_string();
2481            let description = Some(text[pipe_pos + 3..].trim().to_string());
2482            steps.push(PipelineStep { label, description });
2483        } else {
2484            steps.push(PipelineStep {
2485                label: text.trim().to_string(),
2486                description: None,
2487            });
2488        }
2489    }
2490
2491    Block::Pipeline { steps, span }
2492}
2493
2494fn parse_section(attrs: &Attrs, content: &str, span: Span) -> Block {
2495    let bg = attr_string(attrs, "bg");
2496    let mut headline: Option<String> = None;
2497    let mut subtitle: Option<String> = None;
2498    let mut body_start = 0;
2499    let mut found_headline = false;
2500
2501    for (i, line) in content.lines().enumerate() {
2502        let trimmed = line.trim();
2503        if trimmed.is_empty() && !found_headline {
2504            body_start = i + 1;
2505            continue;
2506        }
2507        if !found_headline {
2508            if let Some(rest) = trimmed.strip_prefix("## ") {
2509                headline = Some(rest.trim().to_string());
2510                found_headline = true;
2511                body_start = i + 1;
2512                continue;
2513            }
2514        }
2515        if found_headline && subtitle.is_none() && !trimmed.is_empty() {
2516            // Check if this line looks like a subtitle (plain text, not a directive or heading)
2517            if !trimmed.starts_with("::") && !trimmed.starts_with('#') && !trimmed.starts_with("- ") {
2518                subtitle = Some(trimmed.to_string());
2519                body_start = i + 1;
2520                continue;
2521            }
2522        }
2523        if found_headline {
2524            body_start = i;
2525            break;
2526        }
2527    }
2528
2529    let remaining: String = content
2530        .lines()
2531        .skip(body_start)
2532        .collect::<Vec<_>>()
2533        .join("\n");
2534    let remaining = remaining.trim().to_string();
2535
2536    let children = parse_page_children(&remaining);
2537
2538    Block::Section {
2539        bg,
2540        headline,
2541        subtitle,
2542        content: content.to_string(),
2543        children,
2544        span,
2545    }
2546}
2547
2548fn parse_product_card(attrs: &Attrs, content: &str, span: Span) -> Block {
2549    let badge = attr_string(attrs, "badge");
2550    let badge_color = attr_string(attrs, "badge-color");
2551    // Title / subtitle / CTA may be supplied as attrs (consistent with `hero`,
2552    // `cta`, etc.) or in the content body. Attrs take precedence.
2553    let attr_title = attr_string(attrs, "title");
2554    let attr_subtitle = attr_string(attrs, "subtitle");
2555    let attr_cta_label = attr_string(attrs, "cta-label");
2556    let attr_cta_href = attr_string(attrs, "cta-href");
2557
2558    let mut title = String::new();
2559    let mut subtitle: Option<String> = None;
2560    let mut body_lines: Vec<&str> = Vec::new();
2561    let mut features: Vec<String> = Vec::new();
2562    let mut cta_label: Option<String> = None;
2563    let mut cta_href: Option<String> = None;
2564
2565    // States: 0=before_title, 1=after_title(subtitle), 2=body, 3=features.
2566    // When the title is given as an attr, the content carries no `## ` heading,
2567    // so start in the body state to capture body/features/cta.
2568    let mut state = if attr_title.is_some() { 2 } else { 0 };
2569
2570    for line in content.lines() {
2571        let trimmed = line.trim();
2572
2573        match state {
2574            0 => {
2575                if let Some(rest) = trimmed.strip_prefix("## ") {
2576                    title = rest.trim().to_string();
2577                    state = 1;
2578                }
2579            }
2580            1 => {
2581                if trimmed.is_empty() {
2582                    if subtitle.is_some() {
2583                        state = 2;
2584                    }
2585                    continue;
2586                }
2587                if trimmed.starts_with("- ") {
2588                    features.push(trimmed[2..].to_string());
2589                    state = 3;
2590                } else if trimmed.starts_with('[') && trimmed.contains("](") {
2591                    parse_cta_link(trimmed, &mut cta_label, &mut cta_href);
2592                } else if subtitle.is_none() {
2593                    subtitle = Some(trimmed.to_string());
2594                } else {
2595                    body_lines.push(trimmed);
2596                    state = 2;
2597                }
2598            }
2599            2 => {
2600                if trimmed.starts_with("- ") {
2601                    features.push(trimmed[2..].to_string());
2602                    state = 3;
2603                } else if trimmed.starts_with('[') && trimmed.contains("](") {
2604                    parse_cta_link(trimmed, &mut cta_label, &mut cta_href);
2605                } else {
2606                    body_lines.push(trimmed);
2607                }
2608            }
2609            3 => {
2610                if trimmed.starts_with("- ") {
2611                    features.push(trimmed[2..].to_string());
2612                } else if trimmed.starts_with('[') && trimmed.contains("](") {
2613                    parse_cta_link(trimmed, &mut cta_label, &mut cta_href);
2614                }
2615            }
2616            _ => {}
2617        }
2618    }
2619
2620    let body = body_lines.join("\n").trim().to_string();
2621
2622    // Attrs override content-derived values.
2623    let title = attr_title.unwrap_or(title);
2624    let subtitle = attr_subtitle.or(subtitle);
2625    let cta_label = attr_cta_label.or(cta_label);
2626    let cta_href = attr_cta_href.or(cta_href);
2627
2628    Block::ProductCard {
2629        title,
2630        subtitle,
2631        badge,
2632        badge_color,
2633        body,
2634        features,
2635        cta_label,
2636        cta_href,
2637        span,
2638    }
2639}
2640
2641fn parse_cta_link(trimmed: &str, cta_label: &mut Option<String>, cta_href: &mut Option<String>) {
2642    if let Some(label_end) = trimmed.find("](") {
2643        let label = trimmed[1..label_end].to_string();
2644        let after = &trimmed[label_end + 2..];
2645        if let Some(href_end) = after.find(')') {
2646            *cta_label = Some(label);
2647            *cta_href = Some(after[..href_end].to_string());
2648        }
2649    }
2650}
2651
2652// ------------------------------------------------------------------
2653// App description block parsers
2654// ------------------------------------------------------------------
2655
2656fn attr_u32(attrs: &Attrs, key: &str) -> Option<u32> {
2657    attrs.get(key).and_then(|v| match v {
2658        AttrValue::Number(n) => Some(*n as u32),
2659        AttrValue::String(s) => s.parse().ok(),
2660        _ => None,
2661    })
2662}
2663
2664/// Validate a URL-like source/action path.
2665///
2666/// Only relative paths starting with `/` are allowed. Rejects `javascript:`,
2667/// `data:`, external URLs, and path traversal to prevent injection attacks.
2668fn validate_source_path(raw: &str) -> Option<String> {
2669    let trimmed = raw.trim();
2670    if trimmed.is_empty() {
2671        return None;
2672    }
2673    let lower = trimmed.to_lowercase();
2674    // Block dangerous schemes
2675    if lower.starts_with("javascript:")
2676        || lower.starts_with("data:")
2677        || lower.starts_with("vbscript:")
2678    {
2679        return None;
2680    }
2681    // Block external URLs — only relative paths allowed
2682    if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("//") {
2683        return None;
2684    }
2685    // Block path traversal
2686    if trimmed.contains("..") {
2687        return None;
2688    }
2689    // Must start with /
2690    if !trimmed.starts_with('/') {
2691        return None;
2692    }
2693    Some(trimmed.to_string())
2694}
2695
2696fn parse_list(attrs: &Attrs, content: &str, span: Span) -> Block {
2697    let source = attr_string(attrs, "source")
2698        .and_then(|s| validate_source_path(&s))
2699        .unwrap_or_default();
2700
2701    let display = attr_string(attrs, "display")
2702        .and_then(|s| match s.as_str() {
2703            "card" => Some(ListDisplay::Card),
2704            "table" => Some(ListDisplay::Table),
2705            "compact" => Some(ListDisplay::Compact),
2706            _ => None,
2707        })
2708        .unwrap_or(ListDisplay::Card);
2709
2710    let preload = attr_bool(attrs, "preload");
2711
2712    let mut item_template = String::new();
2713    let mut filters = Vec::new();
2714    let mut sort = None;
2715
2716    for line in content.lines() {
2717        let trimmed = line.trim();
2718        if trimmed.is_empty() {
2719            continue;
2720        }
2721        if let Some(field) = trimmed.strip_prefix("filter:") {
2722            filters.push(ListFilter {
2723                field: field.trim().to_string(),
2724            });
2725        } else if let Some(sort_spec) = trimmed.strip_prefix("sort:") {
2726            let parts: Vec<&str> = sort_spec.trim().split_whitespace().collect();
2727            if let Some(field) = parts.first() {
2728                let descending = parts.get(1).is_some_and(|d| d.eq_ignore_ascii_case("desc"));
2729                sort = Some(SortSpec {
2730                    field: field.to_string(),
2731                    descending,
2732                });
2733            }
2734        } else {
2735            if !item_template.is_empty() {
2736                item_template.push('\n');
2737            }
2738            item_template.push_str(trimmed);
2739        }
2740    }
2741
2742    Block::List {
2743        source,
2744        display,
2745        item_template,
2746        filters,
2747        sort,
2748        preload,
2749        span,
2750    }
2751}
2752
2753fn parse_board(attrs: &Attrs, content: &str, span: Span) -> Block {
2754    let source = attr_string(attrs, "source")
2755        .and_then(|s| validate_source_path(&s))
2756        .unwrap_or_default();
2757    let preload = attr_bool(attrs, "preload");
2758
2759    let mut columns = Vec::new();
2760    let mut card_template = None;
2761
2762    for line in content.lines() {
2763        let trimmed = line.trim();
2764        if trimmed.is_empty() {
2765            continue;
2766        }
2767        if let Some(cols) = trimmed.strip_prefix("columns:") {
2768            columns = cols.split('|').map(|c| c.trim().to_string()).filter(|c| !c.is_empty()).collect();
2769        } else if let Some(tmpl) = trimmed.strip_prefix("card-template:") {
2770            card_template = Some(tmpl.trim().to_string());
2771        }
2772    }
2773
2774    Block::Board {
2775        source,
2776        columns,
2777        card_template,
2778        preload,
2779        span,
2780    }
2781}
2782
2783fn parse_action(attrs: &Attrs, content: &str, span: Span) -> Block {
2784    let method = attr_string(attrs, "method")
2785        .and_then(|s| match s.to_lowercase().as_str() {
2786            "get" => Some(HttpMethod::Get),
2787            "post" => Some(HttpMethod::Post),
2788            "put" => Some(HttpMethod::Put),
2789            "patch" => Some(HttpMethod::Patch),
2790            "delete" => Some(HttpMethod::Delete),
2791            _ => None,
2792        })
2793        .unwrap_or(HttpMethod::Post);
2794
2795    let target = attr_string(attrs, "target")
2796        .and_then(|s| validate_source_path(&s))
2797        .unwrap_or_default();
2798
2799    let attr_label = attr_string(attrs, "label");
2800    let confirm = attr_string(attrs, "confirm");
2801
2802    // Reuse form field parsing from parse_form.
2803    // Track whether each item had parentheses so we can detect a bare
2804    // trailing item that should become the submit-button label.
2805    let mut fields = Vec::new();
2806    let mut has_parens = Vec::new();
2807    for line in content.lines() {
2808        let trimmed = line.trim();
2809        if trimmed.is_empty() {
2810            continue;
2811        }
2812        if let Some(rest) = trimmed.strip_prefix("- ") {
2813            let rest = rest.trim();
2814            let (label_part, type_part) = if let Some(paren_start) = rest.find('(') {
2815                let lbl = rest[..paren_start].trim();
2816                let after_paren = &rest[paren_start + 1..];
2817                let paren_end = after_paren.find(')').unwrap_or(after_paren.len());
2818                let type_str = &after_paren[..paren_end];
2819                let remainder = &after_paren[paren_end..].trim_start_matches(')');
2820                (lbl, Some((type_str.to_string(), remainder.to_string())))
2821            } else {
2822                (rest.trim_end_matches(" *").trim_end_matches('*'), None)
2823            };
2824
2825            let required = rest.ends_with('*') || rest.ends_with("* ");
2826            let item_has_parens = type_part.is_some();
2827
2828            let (field_type, placeholder, options) = match &type_part {
2829                Some((type_str, _)) => {
2830                    let type_str = type_str.trim();
2831                    if let Some(opts_part) = type_str.strip_prefix("select:") {
2832                        let opts: Vec<String> = opts_part
2833                            .split('|')
2834                            .map(|o| o.trim().to_string())
2835                            .filter(|o| !o.is_empty())
2836                            .collect();
2837                        (FormFieldType::Select, None, opts)
2838                    } else {
2839                        let (type_name, placeholder) = if let Some((t, p)) = type_str.split_once(',') {
2840                            (t.trim(), Some(p.trim().trim_matches('"').to_string()))
2841                        } else {
2842                            (type_str, None)
2843                        };
2844                        let ft = match type_name {
2845                            "email" => FormFieldType::Email,
2846                            "tel" | "phone" => FormFieldType::Tel,
2847                            "date" => FormFieldType::Date,
2848                            "number" => FormFieldType::Number,
2849                            "password" => FormFieldType::Password,
2850                            "select" => FormFieldType::Select,
2851                            "textarea" | "multiline" => FormFieldType::Textarea,
2852                            _ => FormFieldType::Text,
2853                        };
2854                        (ft, placeholder, Vec::new())
2855                    }
2856                }
2857                None => (FormFieldType::Text, None, Vec::new()),
2858            };
2859
2860            let name = label_part
2861                .to_lowercase()
2862                .replace(|c: char| !c.is_alphanumeric(), "_")
2863                .trim_matches('_')
2864                .to_string();
2865
2866            fields.push(FormField {
2867                label: label_part.to_string(),
2868                name,
2869                field_type,
2870                required,
2871                placeholder,
2872                options,
2873            });
2874            has_parens.push(item_has_parens);
2875        }
2876    }
2877
2878    // Always pop trailing bare items — they are button labels, not fields.
2879    let bare_label = if !fields.is_empty() && !has_parens[fields.len() - 1] {
2880        let btn = fields.pop().unwrap();
2881        has_parens.pop();
2882        Some(btn.label)
2883    } else {
2884        None
2885    };
2886
2887    // Explicit label attr wins, then bare item, then "Submit".
2888    let label = attr_label
2889        .or(bare_label)
2890        .unwrap_or_else(|| "Submit".to_string());
2891
2892    Block::Action {
2893        method,
2894        target,
2895        label,
2896        fields,
2897        confirm,
2898        span,
2899    }
2900}
2901
2902fn parse_filter_bar(attrs: &Attrs, content: &str, span: Span) -> Block {
2903    let target_selector = attr_string(attrs, "target").unwrap_or_default();
2904
2905    let mut fields = Vec::new();
2906    for line in content.lines() {
2907        let trimmed = line.trim();
2908        if trimmed.is_empty() {
2909            continue;
2910        }
2911        if let Some(rest) = trimmed.strip_prefix("- ") {
2912            let rest = rest.trim();
2913            if let Some(paren_start) = rest.find('(') {
2914                let label_part = rest[..paren_start].trim().to_string();
2915                let after_paren = &rest[paren_start + 1..];
2916                let paren_end = after_paren.find(')').unwrap_or(after_paren.len());
2917                let type_str = &after_paren[..paren_end];
2918
2919                if let Some(opts_part) = type_str.strip_prefix("select:") {
2920                    let options: Vec<String> = opts_part
2921                        .split('|')
2922                        .map(|o| o.trim().to_string())
2923                        .filter(|o| !o.is_empty())
2924                        .collect();
2925                    let name = label_part
2926                        .to_lowercase()
2927                        .replace(|c: char| !c.is_alphanumeric(), "_")
2928                        .trim_matches('_')
2929                        .to_string();
2930                    fields.push(FilterField {
2931                        label: label_part,
2932                        name,
2933                        options,
2934                    });
2935                }
2936            }
2937        }
2938    }
2939
2940    Block::FilterBar {
2941        target_selector,
2942        fields,
2943        span,
2944    }
2945}
2946
2947fn parse_search(attrs: &Attrs, span: Span) -> Block {
2948    let source = attr_string(attrs, "source")
2949        .and_then(|s| validate_source_path(&s))
2950        .unwrap_or_default();
2951    let placeholder = attr_string(attrs, "placeholder");
2952
2953    Block::Search {
2954        source,
2955        placeholder,
2956        span,
2957    }
2958}
2959
2960fn parse_dashboard(attrs: &Attrs, span: Span) -> Block {
2961    let source = attr_string(attrs, "source")
2962        .and_then(|s| validate_source_path(&s))
2963        .unwrap_or_default();
2964    let refresh = attr_u32(attrs, "refresh");
2965
2966    Block::Dashboard {
2967        source,
2968        refresh,
2969        span,
2970    }
2971}
2972
2973fn parse_chat_input(attrs: &Attrs, content: &str, span: Span) -> Block {
2974    let action = attr_string(attrs, "action")
2975        .and_then(|s| validate_source_path(&s))
2976        .unwrap_or_default();
2977    let placeholder = attr_string(attrs, "placeholder");
2978
2979    let mut modes = Vec::new();
2980    for line in content.lines() {
2981        let trimmed = line.trim();
2982        if let Some(mode_list) = trimmed.strip_prefix("modes:") {
2983            modes = mode_list
2984                .split('|')
2985                .map(|m| m.trim().to_string())
2986                .filter(|m| !m.is_empty())
2987                .collect();
2988        }
2989    }
2990
2991    Block::ChatInput {
2992        action,
2993        placeholder,
2994        modes,
2995        span,
2996    }
2997}
2998
2999fn parse_feed(attrs: &Attrs, span: Span) -> Block {
3000    let source = attr_string(attrs, "source")
3001        .and_then(|s| validate_source_path(&s))
3002        .unwrap_or_default();
3003    let stream = attr_bool(attrs, "stream");
3004
3005    Block::Feed {
3006        source,
3007        stream,
3008        span,
3009    }
3010}
3011
3012fn parse_editor(attrs: &Attrs, span: Span) -> Block {
3013    let source = attr_string(attrs, "source").and_then(|s| validate_source_path(&s));
3014    let lang = attr_string(attrs, "lang");
3015    let preview = attr_bool(attrs, "preview");
3016
3017    Block::Editor {
3018        source,
3019        lang,
3020        preview,
3021        span,
3022    }
3023}
3024
3025fn parse_chart(attrs: &Attrs, content: &str, span: Span) -> Block {
3026    let chart_type = attr_string(attrs, "type")
3027        .and_then(|s| match s.to_lowercase().as_str() {
3028            "line" => Some(ChartType::Line),
3029            "bar" => Some(ChartType::Bar),
3030            "pie" => Some(ChartType::Pie),
3031            "area" => Some(ChartType::Area),
3032            "scatter" => Some(ChartType::Scatter),
3033            "donut" | "doughnut" => Some(ChartType::Donut),
3034            "stacked-bar" | "stackedbar" | "stacked" => Some(ChartType::StackedBar),
3035            "radar" | "spider" => Some(ChartType::Radar),
3036            _ => None,
3037        })
3038        .unwrap_or(ChartType::Line);
3039
3040    let source = attr_string(attrs, "source")
3041        .and_then(|s| validate_source_path(&s))
3042        .unwrap_or_default();
3043    let period = attr_string(attrs, "period");
3044    let title = attr_string(attrs, "title");
3045    let data = parse_chart_data(content);
3046
3047    Block::Chart {
3048        chart_type,
3049        source,
3050        period,
3051        title,
3052        data,
3053        span,
3054    }
3055}
3056
3057/// Parse a `::chart` block body into an inline [`ChartData`] dataset.
3058///
3059/// The body is a pipe-delimited table consistent with `::data[format=table]`:
3060/// the first non-empty line is the header (`<x-label> | <series1> | <series2>
3061/// …`), and every following row is `<category> | <value1> | <value2> …`.
3062/// Markdown separator rows (`|---|---|`) are skipped. Non-numeric value cells
3063/// parse to `0.0`. Returns `None` when there is no usable data (no rows, or
3064/// fewer than two columns) so the chart falls back to the mount point.
3065fn parse_chart_data(content: &str) -> Option<ChartData> {
3066    let (headers, rows) = parse_table_content(content);
3067    if headers.len() < 2 || rows.is_empty() {
3068        return None;
3069    }
3070
3071    let series_count = headers.len() - 1;
3072    let mut series: Vec<ChartSeries> = headers[1..]
3073        .iter()
3074        .map(|name| ChartSeries {
3075            name: name.clone(),
3076            values: Vec::with_capacity(rows.len()),
3077        })
3078        .collect();
3079
3080    let mut categories = Vec::with_capacity(rows.len());
3081    for row in &rows {
3082        categories.push(row.first().cloned().unwrap_or_default());
3083        for (j, s) in series.iter_mut().enumerate() {
3084            let cell = row.get(j + 1).map(|c| c.as_str()).unwrap_or("");
3085            s.values.push(parse_chart_number(cell));
3086        }
3087    }
3088
3089    let _ = series_count;
3090    Some(ChartData { categories, series })
3091}
3092
3093/// Parse a numeric chart cell. Strips common formatting (`$`, `%`, `,`,
3094/// surrounding whitespace); non-numeric cells become `0.0`.
3095fn parse_chart_number(cell: &str) -> f64 {
3096    let cleaned: String = cell
3097        .chars()
3098        .filter(|c| !matches!(c, '$' | '%' | ',' | ' ' | '_'))
3099        .collect();
3100    cleaned.parse::<f64>().unwrap_or(0.0)
3101}
3102
3103fn parse_split_pane(attrs: &Attrs, span: Span) -> Block {
3104    let ratio = attr_string(attrs, "ratio").unwrap_or_else(|| "50:50".to_string());
3105
3106    Block::SplitPane { ratio, span }
3107}
3108
3109// ------------------------------------------------------------------
3110// Infrastructure manifest block parsers
3111// ------------------------------------------------------------------
3112
3113/// Container block — recursively parse children (like `parse_page`).
3114fn parse_app(attrs: &Attrs, content: &str, span: Span) -> Block {
3115    let name = attr_string(attrs, "name").unwrap_or_default();
3116    let binary = attr_string(attrs, "binary");
3117    let region = attr_string(attrs, "region");
3118    let port = attr_u32(attrs, "port");
3119    let platform = attr_string(attrs, "platform");
3120    let auth = attr_string(attrs, "auth");
3121
3122    let children = parse_page_children(content);
3123
3124    Block::App {
3125        name,
3126        binary,
3127        region,
3128        port,
3129        platform,
3130        auth,
3131        content: content.to_string(),
3132        children,
3133        span,
3134    }
3135}
3136
3137fn parse_build(attrs: &Attrs, content: &str, span: Span) -> Block {
3138    let base = attr_string(attrs, "base");
3139    let runtime = attr_string(attrs, "runtime");
3140    let edition = attr_string(attrs, "edition");
3141
3142    let mut properties = Vec::new();
3143    for line in content.lines() {
3144        let trimmed = line.trim();
3145        if trimmed.is_empty() {
3146            continue;
3147        }
3148        if let Some((key, value)) = trimmed.split_once(':') {
3149            let key = key.trim().to_string();
3150            let value = value.trim().to_string();
3151            if !key.is_empty() && !value.is_empty() {
3152                properties.push(StyleProperty { key, value });
3153            }
3154        }
3155    }
3156
3157    Block::Build {
3158        base,
3159        runtime,
3160        edition,
3161        properties,
3162        span,
3163    }
3164}
3165
3166fn parse_infra_database(attrs: &Attrs, content: &str, span: Span) -> Block {
3167    let name = attr_string(attrs, "name");
3168    let shared_auth = attr_bool(attrs, "shared_auth");
3169    let volume_gb = attr_u32(attrs, "volume_gb");
3170
3171    let mut properties = Vec::new();
3172    for line in content.lines() {
3173        let trimmed = line.trim();
3174        if trimmed.is_empty() {
3175            continue;
3176        }
3177        if let Some((key, value)) = trimmed.split_once(':') {
3178            let key = key.trim().to_string();
3179            let value = value.trim().to_string();
3180            if !key.is_empty() && !value.is_empty() {
3181                properties.push(StyleProperty { key, value });
3182            }
3183        }
3184    }
3185
3186    Block::InfraDatabase {
3187        name,
3188        shared_auth,
3189        volume_gb,
3190        properties,
3191        span,
3192    }
3193}
3194
3195fn parse_deploy(attrs: &Attrs, content: &str, span: Span) -> Block {
3196    let env = attr_string(attrs, "env");
3197    let app = attr_string(attrs, "app");
3198    let machines = attr_u32(attrs, "machines");
3199    let memory = attr_u32(attrs, "memory");
3200    let auto_stop = attr_string(attrs, "auto_stop");
3201    let min_machines = attr_u32(attrs, "min_machines");
3202    let strategy = attr_string(attrs, "strategy");
3203
3204    let mut properties = Vec::new();
3205    for line in content.lines() {
3206        let trimmed = line.trim();
3207        if trimmed.is_empty() {
3208            continue;
3209        }
3210        if let Some((key, value)) = trimmed.split_once(':') {
3211            let key = key.trim().to_string();
3212            let value = value.trim().to_string();
3213            if !key.is_empty() && !value.is_empty() {
3214                properties.push(StyleProperty { key, value });
3215            }
3216        }
3217    }
3218
3219    Block::Deploy {
3220        env,
3221        app,
3222        machines,
3223        memory,
3224        auto_stop,
3225        min_machines,
3226        strategy,
3227        properties,
3228        span,
3229    }
3230}
3231
3232/// Parse `NAME` or `NAME = default_value` lines.
3233fn parse_infra_env(attrs: &Attrs, content: &str, span: Span) -> Block {
3234    let tier = attr_string(attrs, "tier");
3235
3236    let mut entries = Vec::new();
3237    for line in content.lines() {
3238        let trimmed = line.trim();
3239        if trimmed.is_empty() {
3240            continue;
3241        }
3242        if let Some((name, value)) = trimmed.split_once('=') {
3243            let name = name.trim().to_string();
3244            let value = value.trim().to_string();
3245            entries.push(EnvEntry {
3246                name,
3247                default_value: if value.is_empty() { None } else { Some(value) },
3248            });
3249        } else {
3250            entries.push(EnvEntry {
3251                name: trimmed.to_string(),
3252                default_value: None,
3253            });
3254        }
3255    }
3256
3257    Block::InfraEnv {
3258        tier,
3259        entries,
3260        span,
3261    }
3262}
3263
3264fn parse_health(attrs: &Attrs, span: Span) -> Block {
3265    Block::Health {
3266        path: attr_string(attrs, "path"),
3267        method: attr_string(attrs, "method"),
3268        grace: attr_string(attrs, "grace"),
3269        interval: attr_string(attrs, "interval"),
3270        timeout: attr_string(attrs, "timeout"),
3271        span,
3272    }
3273}
3274
3275fn parse_concurrency(attrs: &Attrs, span: Span) -> Block {
3276    Block::Concurrency {
3277        concurrency_type: attr_string(attrs, "type"),
3278        hard_limit: attr_u32(attrs, "hard_limit"),
3279        soft_limit: attr_u32(attrs, "soft_limit"),
3280        force_https: attr_bool(attrs, "force_https"),
3281        span,
3282    }
3283}
3284
3285fn parse_cicd(attrs: &Attrs, content: &str, span: Span) -> Block {
3286    let provider = attr_string(attrs, "provider");
3287
3288    let mut properties = Vec::new();
3289    for line in content.lines() {
3290        let trimmed = line.trim();
3291        if trimmed.is_empty() {
3292            continue;
3293        }
3294        if let Some((key, value)) = trimmed.split_once(':') {
3295            let key = key.trim().to_string();
3296            let value = value.trim().to_string();
3297            if !key.is_empty() && !value.is_empty() {
3298                properties.push(StyleProperty { key, value });
3299            }
3300        }
3301    }
3302
3303    Block::Cicd {
3304        provider,
3305        properties,
3306        span,
3307    }
3308}
3309
3310/// Parse `METHOD /path -> STATUS` lines.
3311fn parse_smoke(attrs: &Attrs, content: &str, span: Span) -> Block {
3312    let script = attr_string(attrs, "script");
3313
3314    let mut checks = Vec::new();
3315    for line in content.lines() {
3316        let trimmed = line.trim();
3317        if trimmed.is_empty() {
3318            continue;
3319        }
3320        // Expected format: `GET /path -> 200`
3321        let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
3322        if parts.len() < 2 {
3323            continue;
3324        }
3325        let method = parts[0].to_string();
3326        let rest = parts[1];
3327        if let Some((path, status_str)) = rest.rsplit_once("->") {
3328            let path = path.trim().to_string();
3329            if let Ok(expected) = status_str.trim().parse::<u16>() {
3330                checks.push(SmokeCheck {
3331                    method,
3332                    path,
3333                    expected,
3334                });
3335            }
3336        }
3337    }
3338
3339    Block::Smoke {
3340        script,
3341        checks,
3342        span,
3343    }
3344}
3345
3346/// Parse `domain.com (description)` lines.
3347fn parse_domains(content: &str, span: Span) -> Block {
3348    let mut entries = Vec::new();
3349    for line in content.lines() {
3350        let trimmed = line.trim();
3351        if trimmed.is_empty() {
3352            continue;
3353        }
3354        if let Some((domain, rest)) = trimmed.split_once('(') {
3355            let domain = domain.trim().to_string();
3356            let description = rest.trim_end_matches(')').trim().to_string();
3357            entries.push(DomainEntry {
3358                domain,
3359                description: if description.is_empty() {
3360                    None
3361                } else {
3362                    Some(description)
3363                },
3364            });
3365        } else {
3366            entries.push(DomainEntry {
3367                domain: trimmed.to_string(),
3368                description: None,
3369            });
3370        }
3371    }
3372
3373    Block::Domains { entries, span }
3374}
3375
3376/// Parse `name (source: ..., features: ...)` or `name (key: value, ...)` lines.
3377fn parse_crates(content: &str, span: Span) -> Block {
3378    let mut entries = Vec::new();
3379    for line in content.lines() {
3380        let trimmed = line.trim();
3381        if trimmed.is_empty() {
3382            continue;
3383        }
3384        if let Some((name, rest)) = trimmed.split_once('(') {
3385            let name = name.trim().to_string();
3386            let meta = rest.trim_end_matches(')').trim();
3387            let mut source = None;
3388            let mut features = None;
3389            // Parse comma-separated key: value pairs inside parens
3390            for part in meta.split(',') {
3391                let part = part.trim();
3392                if let Some(val) = part.strip_prefix("github:").or_else(|| part.strip_prefix("source:")) {
3393                    // Combine with remaining comma-separated parts that are part of the source
3394                    source = Some(val.trim().to_string());
3395                } else if let Some(val) = part.strip_prefix("features:") {
3396                    features = Some(val.trim().to_string());
3397                } else if let Some(val) = part.strip_prefix("branch:") {
3398                    // Append branch info to source
3399                    if let Some(ref mut s) = source {
3400                        s.push_str(&format!(", branch: {}", val.trim()));
3401                    }
3402                } else if source.is_some() && features.is_none() && !part.contains(':') {
3403                    // Continue previous source value
3404                    if let Some(ref mut s) = source {
3405                        s.push_str(&format!(", {}", part));
3406                    }
3407                }
3408            }
3409            entries.push(CrateEntry {
3410                name,
3411                source,
3412                features,
3413            });
3414        } else {
3415            entries.push(CrateEntry {
3416                name: trimmed.to_string(),
3417                source: None,
3418                features: None,
3419            });
3420        }
3421    }
3422
3423    Block::Crates { entries, span }
3424}
3425
3426/// Parse `key: value` lines (same as style).
3427fn parse_deploy_urls(content: &str, span: Span) -> Block {
3428    let mut entries = Vec::new();
3429    for line in content.lines() {
3430        let trimmed = line.trim();
3431        if trimmed.is_empty() {
3432            continue;
3433        }
3434        if let Some((key, value)) = trimmed.split_once(':') {
3435            let key = key.trim().to_string();
3436            let value = value.trim().to_string();
3437            if !key.is_empty() && !value.is_empty() {
3438                entries.push(StyleProperty { key, value });
3439            }
3440        }
3441    }
3442
3443    Block::DeployUrls { entries, span }
3444}
3445
3446/// Parse `name -> /mount/path` lines.
3447fn parse_volumes(content: &str, span: Span) -> Block {
3448    let mut entries = Vec::new();
3449    for line in content.lines() {
3450        let trimmed = line.trim();
3451        if trimmed.is_empty() {
3452            continue;
3453        }
3454        if let Some((name, mount)) = trimmed.split_once("->") {
3455            entries.push(VolumeEntry {
3456                name: name.trim().to_string(),
3457                mount: mount.trim().to_string(),
3458            });
3459        }
3460    }
3461
3462    Block::Volumes { entries, span }
3463}
3464
3465// ------------------------------------------------------------------
3466// App spec block parsers
3467// ------------------------------------------------------------------
3468
3469/// Parse `::model[name=Task]` with field definitions like:
3470/// `- id: uuid [primary, auto]`
3471/// `- title: string [required, max=200]`
3472/// `- status: enum(todo, in_progress, done) [default=todo]`
3473/// `- assignee_id: ref(User) [optional]`
3474fn parse_model(attrs: &Attrs, content: &str, span: Span) -> Block {
3475    let name = attr_string(attrs, "name").unwrap_or_default();
3476    let mut fields = Vec::new();
3477
3478    for line in content.lines() {
3479        let trimmed = line.trim();
3480        if !trimmed.starts_with('-') {
3481            continue;
3482        }
3483        let rest = trimmed.trim_start_matches('-').trim();
3484        // Split on first `:` to get name and type+constraints
3485        let Some((field_name, remainder)) = rest.split_once(':') else {
3486            continue;
3487        };
3488        let field_name = field_name.trim().to_string();
3489        let remainder = remainder.trim();
3490
3491        // Extract constraints from [...]
3492        let (type_part, constraints) = if let Some(bracket_start) = remainder.find('[') {
3493            let type_str = remainder[..bracket_start].trim();
3494            let bracket_end = remainder.rfind(']').unwrap_or(remainder.len());
3495            let constraint_str = &remainder[bracket_start + 1..bracket_end];
3496            (type_str, parse_field_constraints(constraint_str))
3497        } else {
3498            (remainder, Vec::new())
3499        };
3500
3501        let field_type = parse_model_field_type(type_part);
3502
3503        fields.push(ModelField {
3504            name: field_name,
3505            field_type,
3506            constraints,
3507        });
3508    }
3509
3510    Block::Model { name, fields, span }
3511}
3512
3513fn parse_model_field_type(s: &str) -> ModelFieldType {
3514    let s = s.trim();
3515    let lower = s.to_lowercase();
3516
3517    // Check for enum(variant1, variant2, ...)
3518    if lower.starts_with("enum(") && s.ends_with(')') {
3519        let inner = &s[5..s.len() - 1];
3520        let variants: Vec<String> = inner.split(',').map(|v| v.trim().to_string()).collect();
3521        return ModelFieldType::Enum(variants);
3522    }
3523
3524    // Check for ref(ModelName)
3525    if lower.starts_with("ref(") && s.ends_with(')') {
3526        let inner = &s[4..s.len() - 1];
3527        return ModelFieldType::Ref(inner.trim().to_string());
3528    }
3529
3530    match lower.as_str() {
3531        "uuid" => ModelFieldType::Uuid,
3532        "string" | "str" | "varchar" => ModelFieldType::String,
3533        "int" | "integer" | "i32" | "i64" => ModelFieldType::Int,
3534        "float" | "f32" | "f64" | "decimal" | "numeric" => ModelFieldType::Float,
3535        "bool" | "boolean" => ModelFieldType::Bool,
3536        "datetime" | "timestamp" | "timestamptz" => ModelFieldType::Datetime,
3537        "text" => ModelFieldType::Text,
3538        "json" | "jsonb" => ModelFieldType::Json,
3539        "money" | "currency" | "price" => ModelFieldType::Money,
3540        "image" | "photo" | "picture" | "img" => ModelFieldType::Image,
3541        "email" | "email_address" => ModelFieldType::Email,
3542        "url" | "uri" | "link" | "href" => ModelFieldType::Url,
3543        _ => ModelFieldType::String, // default fallback
3544    }
3545}
3546
3547fn parse_field_constraints(s: &str) -> Vec<FieldConstraint> {
3548    let mut constraints = Vec::new();
3549    for part in s.split(',') {
3550        let part = part.trim();
3551        if part.is_empty() {
3552            continue;
3553        }
3554        let lower = part.to_lowercase();
3555        if lower == "primary" {
3556            constraints.push(FieldConstraint::Primary);
3557        } else if lower == "auto" {
3558            constraints.push(FieldConstraint::Auto);
3559        } else if lower == "required" {
3560            constraints.push(FieldConstraint::Required);
3561        } else if lower == "optional" {
3562            constraints.push(FieldConstraint::Optional);
3563        } else if lower == "unique" {
3564            constraints.push(FieldConstraint::Unique);
3565        } else if lower == "index" || lower == "indexed" {
3566            constraints.push(FieldConstraint::Index);
3567        } else if let Some(val) = lower.strip_prefix("max=") {
3568            if let Ok(n) = val.parse::<u32>() {
3569                constraints.push(FieldConstraint::Max(n));
3570            }
3571        } else if let Some(val) = lower.strip_prefix("min=") {
3572            if let Ok(n) = val.parse::<u32>() {
3573                constraints.push(FieldConstraint::Min(n));
3574            }
3575        } else if let Some(val) = lower.strip_prefix("default=") {
3576            constraints.push(FieldConstraint::Default(val.to_string()));
3577        } else if part.starts_with("default=") {
3578            // Preserve original case for default values
3579            constraints.push(FieldConstraint::Default(part[8..].to_string()));
3580        }
3581    }
3582    constraints
3583}
3584
3585/// Parse `::route[method=GET path="/api/tasks"]`
3586/// Content lines: `auth: required`, `returns: list(Task)`, `body: Task`
3587///
3588/// If a fenced code block (` ``` `rust ... ``` `) appears in the content, the
3589/// code inside is extracted as the `handler` field.
3590fn parse_route(attrs: &Attrs, content: &str, span: Span) -> Block {
3591    let method_str = attr_string(attrs, "method").unwrap_or_else(|| "get".to_string());
3592    let method = match method_str.to_lowercase().as_str() {
3593        "get" => HttpMethod::Get,
3594        "post" => HttpMethod::Post,
3595        "put" => HttpMethod::Put,
3596        "patch" => HttpMethod::Patch,
3597        "delete" => HttpMethod::Delete,
3598        _ => HttpMethod::Get,
3599    };
3600    let path = attr_string(attrs, "path").unwrap_or_default();
3601
3602    let mut auth = None;
3603    let mut returns = None;
3604    let mut body = None;
3605    let mut handler = None;
3606    let mut extra_lines = Vec::new();
3607
3608    // Detect fenced code block for handler extraction.
3609    let mut in_fence = false;
3610    let mut fence_lines: Vec<&str> = Vec::new();
3611
3612    for line in content.lines() {
3613        let trimmed = line.trim();
3614
3615        if !in_fence && trimmed.starts_with("```") {
3616            in_fence = true;
3617            continue;
3618        }
3619        if in_fence {
3620            if trimmed.starts_with("```") {
3621                in_fence = false;
3622                handler = Some(fence_lines.join("\n"));
3623                fence_lines.clear();
3624            } else {
3625                fence_lines.push(line);
3626            }
3627            continue;
3628        }
3629
3630        if trimmed.is_empty() {
3631            continue;
3632        }
3633        if let Some((key, value)) = trimmed.split_once(':') {
3634            let key = key.trim().to_lowercase();
3635            let value = value.trim().to_string();
3636            match key.as_str() {
3637                "auth" => auth = Some(value),
3638                "returns" => returns = Some(value),
3639                "body" => body = Some(value),
3640                _ => extra_lines.push(trimmed.to_string()),
3641            }
3642        } else {
3643            extra_lines.push(trimmed.to_string());
3644        }
3645    }
3646
3647    Block::Route {
3648        method,
3649        path,
3650        auth,
3651        returns,
3652        body,
3653        handler,
3654        content: extra_lines.join("\n"),
3655        span,
3656    }
3657}
3658
3659/// Parse `::auth[provider=email]`
3660/// Content lines: `session: cookie`, `roles: admin, member`, `default_role: member`
3661fn parse_auth(attrs: &Attrs, content: &str, span: Span) -> Block {
3662    let provider_str = attr_string(attrs, "provider").unwrap_or_else(|| "email".to_string());
3663    let provider = match provider_str.to_lowercase().as_str() {
3664        "email" => AuthProvider::Email,
3665        "oauth" => AuthProvider::OAuth,
3666        "api-key" | "api_key" | "apikey" => AuthProvider::ApiKey,
3667        "token" => AuthProvider::Token,
3668        _ => AuthProvider::Email,
3669    };
3670
3671    let mut session = None;
3672    let mut roles = Vec::new();
3673    let mut default_role = None;
3674
3675    for line in content.lines() {
3676        let trimmed = line.trim();
3677        if trimmed.is_empty() {
3678            continue;
3679        }
3680        if let Some((key, value)) = trimmed.split_once(':') {
3681            let key = key.trim().to_lowercase();
3682            let value = value.trim();
3683            match key.as_str() {
3684                "session" => session = Some(value.to_string()),
3685                "roles" => {
3686                    roles = value.split(',').map(|r| r.trim().to_string()).filter(|r| !r.is_empty()).collect();
3687                }
3688                "default_role" | "default-role" => default_role = Some(value.to_string()),
3689                _ => {}
3690            }
3691        }
3692    }
3693
3694    Block::Auth {
3695        provider,
3696        session,
3697        roles,
3698        default_role,
3699        span,
3700    }
3701}
3702
3703/// Parse `::binding[source="/api/tasks" target="#task-list"]`
3704/// Content lines: `on_create: refresh`, `on_update: patch`
3705fn parse_binding(attrs: &Attrs, content: &str, span: Span) -> Block {
3706    let source = attr_string(attrs, "source").unwrap_or_default();
3707    let target = attr_string(attrs, "target").unwrap_or_default();
3708
3709    let mut events = Vec::new();
3710    for line in content.lines() {
3711        let trimmed = line.trim();
3712        if trimmed.is_empty() {
3713            continue;
3714        }
3715        if let Some((key, value)) = trimmed.split_once(':') {
3716            let key = key.trim().to_string();
3717            let value = value.trim().to_string();
3718            if !key.is_empty() && !value.is_empty() {
3719                events.push(BindingEvent {
3720                    event: key,
3721                    action: value,
3722                });
3723            }
3724        }
3725    }
3726
3727    Block::Binding {
3728        source,
3729        target,
3730        events,
3731        span,
3732    }
3733}
3734
3735// ------------------------------------------------------------------
3736// Row block — compact list item with icon, title, description
3737// ------------------------------------------------------------------
3738
3739fn parse_row(attrs: &Attrs, content: &str, span: Span) -> Block {
3740    let icon = attr_string(attrs, "icon").unwrap_or_else(|| "doc".to_string());
3741    let href = attr_string(attrs, "href");
3742    let state = match attr_string(attrs, "state").as_deref() {
3743        Some("loading") => RowState::Loading,
3744        Some("empty") => RowState::Empty,
3745        _ => RowState::Default,
3746    };
3747
3748    // Inline attrs take priority; content-based parsing fills in the rest.
3749    let attr_title = attr_string(attrs, "title");
3750    let attr_description = attr_string(attrs, "description");
3751
3752    let mut lines = content.lines();
3753    let title = attr_title.unwrap_or_else(|| lines.next().unwrap_or("").trim().to_string());
3754    let description = attr_description.unwrap_or_else(|| lines.next().unwrap_or("").trim().to_string());
3755
3756    Block::Row {
3757        icon,
3758        title,
3759        description,
3760        href,
3761        state,
3762        span,
3763    }
3764}
3765
3766// ------------------------------------------------------------------
3767// InfoCard block — knowledge card with facts/steps
3768// ------------------------------------------------------------------
3769
3770fn parse_infocard(attrs: &Attrs, content: &str, span: Span) -> Block {
3771    let intent = attr_string(attrs, "intent").unwrap_or_else(|| "general".to_string());
3772    let image = attr_string(attrs, "image");
3773    let state = match attr_string(attrs, "state").as_deref() {
3774        Some("loading") => RowState::Loading,
3775        Some("empty") => RowState::Empty,
3776        _ => RowState::Default,
3777    };
3778
3779    // Inline attrs take priority; content-based parsing fills in the rest.
3780    let attr_title = attr_string(attrs, "title");
3781    let attr_subtitle = attr_string(attrs, "subtitle");
3782    let attr_summary = attr_string(attrs, "summary");
3783
3784    let mut title = attr_title.unwrap_or_default();
3785    let mut subtitle = attr_subtitle.unwrap_or_default();
3786    let mut summary = attr_summary.unwrap_or_default();
3787    let mut facts: Vec<[String; 2]> = Vec::new();
3788    let mut steps: Vec<String> = Vec::new();
3789    let mut in_summary = false;
3790
3791    for line in content.lines() {
3792        let trimmed = line.trim();
3793        if trimmed.is_empty() {
3794            if !title.is_empty() && subtitle.is_empty() {
3795                // blank line after subtitle area, switch to summary
3796                in_summary = true;
3797            }
3798            continue;
3799        }
3800
3801        // Title: first line starting with # or first non-empty line (only if not set by attr)
3802        if title.is_empty() {
3803            title = trimmed.trim_start_matches('#').trim().to_string();
3804            continue;
3805        }
3806
3807        // Subtitle: second non-empty line (before blank line, only if not set by attr)
3808        if subtitle.is_empty() && !in_summary {
3809            subtitle = trimmed.to_string();
3810            in_summary = true;
3811            continue;
3812        }
3813
3814        // Steps: lines starting with number + dot
3815        if trimmed.len() > 2 && trimmed.as_bytes()[0].is_ascii_digit() && trimmed.contains(". ") {
3816            if let Some((_num, text)) = trimmed.split_once(". ") {
3817                steps.push(text.trim().to_string());
3818                continue;
3819            }
3820        }
3821
3822        // Facts: lines with "Key: Value"
3823        if let Some((key, value)) = trimmed.split_once(':') {
3824            let key = key.trim();
3825            let value = value.trim();
3826            if !key.is_empty() && !value.is_empty() && key.len() < 30 {
3827                facts.push([key.to_string(), value.to_string()]);
3828                continue;
3829            }
3830        }
3831
3832        // Otherwise it's summary text (only if not set by attr)
3833        if summary.is_empty() {
3834            summary.push_str(trimmed);
3835        } else {
3836            summary.push(' ');
3837            summary.push_str(trimmed);
3838        }
3839    }
3840
3841    Block::InfoCard {
3842        intent,
3843        title,
3844        subtitle,
3845        summary,
3846        image,
3847        facts,
3848        steps,
3849        state,
3850        span,
3851    }
3852}
3853
3854// ------------------------------------------------------------------
3855// App format blocks: schema, use, app-env, app-deploy
3856// ------------------------------------------------------------------
3857
3858/// Parse `::schema[name="bookings"]`
3859/// Content lines: `- field_name (type) constraint1 constraint2 ...`
3860fn parse_schema(attrs: &Attrs, content: &str, span: Span) -> Block {
3861    let name = attr_string(attrs, "name").unwrap_or_default();
3862    let mut fields = Vec::new();
3863
3864    for line in content.lines() {
3865        let trimmed = line.trim();
3866        if !trimmed.starts_with('-') {
3867            continue;
3868        }
3869        let rest = trimmed.trim_start_matches('-').trim();
3870        if rest.is_empty() {
3871            continue;
3872        }
3873
3874        // Parse: field_name (type) constraint1 constraint2 ...
3875        if let Some(paren_start) = rest.find('(') {
3876            let field_name = rest[..paren_start].trim().to_string();
3877            let after_paren = &rest[paren_start + 1..];
3878            let paren_end = after_paren.find(')').unwrap_or(after_paren.len());
3879            let raw_type = after_paren[..paren_end].trim();
3880            let field_type = parse_schema_field_type(raw_type)
3881                .unwrap_or(ModelFieldType::String);
3882            let remainder = if paren_end < after_paren.len() {
3883                after_paren[paren_end + 1..].trim()
3884            } else {
3885                ""
3886            };
3887            let constraints: Vec<FieldConstraint> = remainder
3888                .split_whitespace()
3889                .filter_map(|s| parse_schema_constraint(s).ok())
3890                .collect();
3891            fields.push(SchemaField {
3892                name: field_name,
3893                field_type,
3894                constraints,
3895            });
3896        } else {
3897            // No type in parens — treat the whole thing as a name with no type
3898            let parts: Vec<&str> = rest.split_whitespace().collect();
3899            let field_name = parts.first().unwrap_or(&"").to_string();
3900            let constraints: Vec<FieldConstraint> = parts.iter().skip(1)
3901                .filter_map(|s| parse_schema_constraint(s).ok())
3902                .collect();
3903            fields.push(SchemaField {
3904                name: field_name,
3905                field_type: ModelFieldType::String,
3906                constraints,
3907            });
3908        }
3909    }
3910
3911    Block::Schema { name, fields, span }
3912}
3913
3914pub fn parse_schema_field_type(raw: &str) -> Result<ModelFieldType, crate::error::ParseError> {
3915    let s = raw.trim();
3916    let lower = s.to_lowercase();
3917
3918    // Check for enum:variant1,variant2,...
3919    if let Some(inner) = lower.strip_prefix("enum:") {
3920        let variants: Vec<String> = inner.split(',').map(|v| v.trim().to_string()).collect();
3921        return Ok(ModelFieldType::Enum(variants));
3922    }
3923
3924    // Check for ref:ModelName
3925    if lower.starts_with("ref:") {
3926        let inner = &s[4..];
3927        return Ok(ModelFieldType::Ref(inner.trim().to_string()));
3928    }
3929
3930    match lower.as_str() {
3931        "uuid" => Ok(ModelFieldType::Uuid),
3932        "string" | "str" | "varchar" => Ok(ModelFieldType::String),
3933        "text" => Ok(ModelFieldType::Text),
3934        "int" | "integer" | "i64" => Ok(ModelFieldType::Int),
3935        "float" | "f64" | "double" => Ok(ModelFieldType::Float),
3936        "bool" | "boolean" => Ok(ModelFieldType::Bool),
3937        "datetime" | "timestamp" => Ok(ModelFieldType::Datetime),
3938        "json" | "jsonb" => Ok(ModelFieldType::Json),
3939        "money" | "cents" | "price" => Ok(ModelFieldType::Money),
3940        "image" | "img" | "photo" => Ok(ModelFieldType::Image),
3941        "email" => Ok(ModelFieldType::Email),
3942        "url" | "uri" | "link" => Ok(ModelFieldType::Url),
3943        _ => Err(crate::error::ParseError::InvalidAttrs {
3944            message: format!("unknown schema field type: '{s}'"),
3945            span: Span::SYNTHETIC,
3946        }),
3947    }
3948}
3949
3950pub fn parse_schema_constraint(raw: &str) -> Result<FieldConstraint, crate::error::ParseError> {
3951    let s = raw.trim();
3952    let lower = s.to_lowercase();
3953
3954    if let Some(val) = lower.strip_prefix("max:") {
3955        return val.parse::<u32>()
3956            .map(FieldConstraint::Max)
3957            .map_err(|_| crate::error::ParseError::InvalidAttrs {
3958                message: format!("invalid max value: '{val}'"),
3959                span: Span::SYNTHETIC,
3960            });
3961    }
3962
3963    if let Some(val) = lower.strip_prefix("min:") {
3964        return val.parse::<u32>()
3965            .map(FieldConstraint::Min)
3966            .map_err(|_| crate::error::ParseError::InvalidAttrs {
3967                message: format!("invalid min value: '{val}'"),
3968                span: Span::SYNTHETIC,
3969            });
3970    }
3971
3972    if lower.starts_with("default:") {
3973        let val = &s[8..];
3974        return Ok(FieldConstraint::Default(val.to_string()));
3975    }
3976
3977    match lower.as_str() {
3978        "primary" => Ok(FieldConstraint::Primary),
3979        "auto" => Ok(FieldConstraint::Auto),
3980        "required" => Ok(FieldConstraint::Required),
3981        "optional" => Ok(FieldConstraint::Optional),
3982        "unique" => Ok(FieldConstraint::Unique),
3983        "index" => Ok(FieldConstraint::Index),
3984        _ => Err(crate::error::ParseError::InvalidAttrs {
3985            message: format!("unknown schema constraint: '{s}'"),
3986            span: Span::SYNTHETIC,
3987        }),
3988    }
3989}
3990
3991/// Parse `::use` with content lines or inline attrs.
3992///
3993/// Content lines: `- reqwest 0.12 [json, rustls-tls]`
3994/// Inline: `::use[reqwest, lettre, stripe]` — attrs parsed as simple crate names.
3995fn parse_use(attrs: &Attrs, content: &str, span: Span) -> Block {
3996    let mut crates = Vec::new();
3997
3998    // Check for inline attrs: keys are crate names, values are typically Null or String.
3999    if !attrs.is_empty() && content.trim().is_empty() {
4000        for (key, _value) in attrs {
4001            crates.push(CrateDep {
4002                name: key.clone(),
4003                version: None,
4004                features: Vec::new(),
4005            });
4006        }
4007        return Block::Use { crates, span };
4008    }
4009
4010    for line in content.lines() {
4011        let trimmed = line.trim();
4012        if !trimmed.starts_with('-') {
4013            continue;
4014        }
4015        let rest = trimmed.trim_start_matches('-').trim();
4016        if rest.is_empty() {
4017            continue;
4018        }
4019
4020        // Parse: name version [feature1, feature2]
4021        let (main_part, features) = if let Some(bracket_start) = rest.find('[') {
4022            let bracket_end = rest.rfind(']').unwrap_or(rest.len());
4023            let features_str = &rest[bracket_start + 1..bracket_end];
4024            let feats: Vec<String> = features_str
4025                .split(',')
4026                .map(|f| f.trim().to_string())
4027                .filter(|f| !f.is_empty())
4028                .collect();
4029            (rest[..bracket_start].trim(), feats)
4030        } else {
4031            (rest, Vec::new())
4032        };
4033
4034        let parts: Vec<&str> = main_part.split_whitespace().collect();
4035        let name = parts.first().unwrap_or(&"").to_string();
4036        let version = parts.get(1).map(|v| v.to_string());
4037
4038        if !name.is_empty() {
4039            crates.push(CrateDep {
4040                name,
4041                version,
4042                features,
4043            });
4044        }
4045    }
4046
4047    Block::Use { crates, span }
4048}
4049
4050/// Parse `::app-env` with content lines or inline attrs.
4051///
4052/// Content lines: `- STRIPE_KEY * "Stripe secret API key"`
4053/// Star means required. Quoted string is description.
4054/// Inline: `::app-env[STRIPE_KEY, SMTP_HOST]`
4055fn parse_app_env(attrs: &Attrs, content: &str, span: Span) -> Block {
4056    let mut vars = Vec::new();
4057
4058    // Inline mode: attrs are variable names.
4059    if !attrs.is_empty() && content.trim().is_empty() {
4060        for (key, _value) in attrs {
4061            vars.push(EnvVar {
4062                name: key.clone(),
4063                description: None,
4064                required: false,
4065            });
4066        }
4067        return Block::AppEnv { vars, span };
4068    }
4069
4070    for line in content.lines() {
4071        let trimmed = line.trim();
4072        if !trimmed.starts_with('-') {
4073            continue;
4074        }
4075        let rest = trimmed.trim_start_matches('-').trim();
4076        if rest.is_empty() {
4077            continue;
4078        }
4079
4080        // Parse: NAME [*] ["description"]
4081        let mut parts_iter = rest.splitn(2, char::is_whitespace);
4082        let name = parts_iter.next().unwrap_or("").to_string();
4083        let remainder = parts_iter.next().unwrap_or("").trim();
4084
4085        let required = remainder.contains('*');
4086        let remainder = remainder.replace('*', "").trim().to_string();
4087
4088        let description = if let Some(start) = remainder.find('"') {
4089            let after_quote = &remainder[start + 1..];
4090            let end = after_quote.find('"').unwrap_or(after_quote.len());
4091            let desc = after_quote[..end].to_string();
4092            if desc.is_empty() { None } else { Some(desc) }
4093        } else {
4094            None
4095        };
4096
4097        if !name.is_empty() {
4098            vars.push(EnvVar {
4099                name,
4100                description,
4101                required,
4102            });
4103        }
4104    }
4105
4106    Block::AppEnv { vars, span }
4107}
4108
4109/// Parse `::app-deploy[region="sjc" scale="2" domain="myapp.surf.new" memory="512mb"]`
4110/// Also parse content lines as key-value properties.
4111fn parse_app_deploy(attrs: &Attrs, content: &str, span: Span) -> Block {
4112    let region = attr_string(attrs, "region");
4113    let scale = attr_u32(attrs, "scale");
4114    let domain = attr_string(attrs, "domain");
4115    let memory = attr_string(attrs, "memory");
4116
4117    let mut properties = Vec::new();
4118    for line in content.lines() {
4119        let trimmed = line.trim();
4120        if trimmed.is_empty() {
4121            continue;
4122        }
4123        if let Some((key, value)) = trimmed.split_once(':') {
4124            let key = key.trim().to_string();
4125            let value = value.trim().to_string();
4126            if !key.is_empty() && !value.is_empty() {
4127                properties.push((key, value));
4128            }
4129        }
4130    }
4131
4132    Block::AppDeploy {
4133        region,
4134        scale,
4135        domain,
4136        memory,
4137        properties,
4138        span,
4139    }
4140}
4141
4142// ------------------------------------------------------------------
4143// Interactive / application block parsers
4144// ------------------------------------------------------------------
4145
4146fn parse_app_shell(attrs: &Attrs, content: &str, span: Span) -> Block {
4147    let layout = attr_string(attrs, "layout").unwrap_or_else(|| "sidebar-main-panel".to_string());
4148    let children = parse_page_children(content);
4149    Block::AppShell {
4150        layout,
4151        children,
4152        span,
4153    }
4154}
4155
4156fn parse_sidebar(attrs: &Attrs, content: &str, span: Span) -> Block {
4157    let position = attr_string(attrs, "position").unwrap_or_else(|| "left".to_string());
4158    let collapsible = attr_bool(attrs, "collapsible");
4159    let width = attr_u32(attrs, "width");
4160    let children = parse_page_children(content);
4161    Block::Sidebar {
4162        position,
4163        collapsible,
4164        width,
4165        children,
4166        span,
4167    }
4168}
4169
4170fn parse_panel(attrs: &Attrs, content: &str, span: Span) -> Block {
4171    let position = attr_string(attrs, "position").unwrap_or_else(|| "bottom".to_string());
4172    let resizable = attr_bool(attrs, "resizable");
4173    let height = attr_u32(attrs, "height");
4174    let desktop_only = attr_bool(attrs, "desktop-only");
4175    let children = parse_page_children(content);
4176    Block::Panel {
4177        position,
4178        resizable,
4179        height,
4180        desktop_only,
4181        children,
4182        span,
4183    }
4184}
4185
4186fn parse_tab_bar(attrs: &Attrs, content: &str, span: Span) -> Block {
4187    let active = attr_string(attrs, "active");
4188    let mut items = Vec::new();
4189    for line in content.lines() {
4190        let trimmed = line.trim();
4191        if let Some(rest) = trimmed.strip_prefix("- ") {
4192            // Expected format: id "Label"
4193            let rest = rest.trim();
4194            if let Some((id, label_part)) = rest.split_once(' ') {
4195                let label = label_part.trim().trim_matches('"').to_string();
4196                items.push(TabBarItem {
4197                    id: id.to_string(),
4198                    label,
4199                });
4200            } else {
4201                items.push(TabBarItem {
4202                    id: rest.to_string(),
4203                    label: rest.to_string(),
4204                });
4205            }
4206        }
4207    }
4208    Block::TabBar {
4209        active,
4210        items,
4211        span,
4212    }
4213}
4214
4215fn parse_tab_content(attrs: &Attrs, content: &str, span: Span) -> Block {
4216    let tab = attr_string(attrs, "tab").unwrap_or_default();
4217    let children = parse_page_children(content);
4218    Block::TabContent {
4219        tab,
4220        children,
4221        span,
4222    }
4223}
4224
4225fn parse_toolbar(content: &str, span: Span) -> Block {
4226    let mut items = Vec::new();
4227    for line in content.lines() {
4228        let trimmed = line.trim();
4229        if let Some(rest) = trimmed.strip_prefix("- ") {
4230            let rest = rest.trim();
4231            if rest == "separator" {
4232                items.push(ToolbarItem::Separator);
4233            } else if rest == "spacer" {
4234                items.push(ToolbarItem::Spacer);
4235            } else if let Some(inner) = rest.strip_prefix("button[") {
4236                let inner = inner.trim_end_matches(']');
4237                let attrs = crate::attrs::parse_attrs(inner).unwrap_or_default();
4238                items.push(ToolbarItem::Button {
4239                    label: attr_string(&attrs, "label"),
4240                    action: attr_string(&attrs, "action"),
4241                    icon: attr_string(&attrs, "icon"),
4242                    style: attr_string(&attrs, "style"),
4243                    disabled: attr_bool(&attrs, "disabled"),
4244                });
4245            } else if let Some(inner) = rest.strip_prefix("badge[") {
4246                let inner = inner.trim_end_matches(']');
4247                let attrs = crate::attrs::parse_attrs(inner).unwrap_or_default();
4248                items.push(ToolbarItem::Badge {
4249                    value: attr_string(&attrs, "value").unwrap_or_default(),
4250                    color: attr_string(&attrs, "color"),
4251                });
4252            } else if let Some(inner) = rest.strip_prefix("dropdown[") {
4253                let inner = inner.trim_end_matches(']');
4254                let attrs = crate::attrs::parse_attrs(inner).unwrap_or_default();
4255                items.push(ToolbarItem::Dropdown {
4256                    label: attr_string(&attrs, "label").unwrap_or_default(),
4257                    options: attr_string(&attrs, "options"),
4258                    action: attr_string(&attrs, "action"),
4259                });
4260            } else if let Some(inner) = rest.strip_prefix("text[") {
4261                let inner = inner.trim_end_matches(']');
4262                let attrs = crate::attrs::parse_attrs(inner).unwrap_or_default();
4263                items.push(ToolbarItem::Text {
4264                    value: attr_string(&attrs, "value").unwrap_or_default(),
4265                    editable: attr_bool(&attrs, "editable"),
4266                    action: attr_string(&attrs, "action"),
4267                });
4268            } else if !rest.is_empty() {
4269                // Plain text item — treat as a button with no action
4270                items.push(ToolbarItem::Button {
4271                    label: Some(rest.to_string()),
4272                    action: None,
4273                    icon: None,
4274                    style: None,
4275                    disabled: false,
4276                });
4277            }
4278        }
4279    }
4280    Block::Toolbar { items, span }
4281}
4282
4283fn parse_drawer(attrs: &Attrs, content: &str, span: Span) -> Block {
4284    let name = attr_string(attrs, "name").unwrap_or_default();
4285    let position = attr_string(attrs, "position").unwrap_or_else(|| "right".to_string());
4286    let width = attr_u32(attrs, "width");
4287    let trigger = attr_string(attrs, "trigger");
4288    let children = parse_page_children(content);
4289    Block::Drawer {
4290        name,
4291        position,
4292        width,
4293        trigger,
4294        children,
4295        span,
4296    }
4297}
4298
4299fn parse_modal(attrs: &Attrs, content: &str, span: Span) -> Block {
4300    let name = attr_string(attrs, "name").unwrap_or_default();
4301    let title = attr_string(attrs, "title");
4302    let children = parse_page_children(content);
4303    Block::Modal {
4304        name,
4305        title,
4306        children,
4307        span,
4308    }
4309}
4310
4311fn parse_command_palette(attrs: &Attrs, content: &str, span: Span) -> Block {
4312    let trigger = attr_string(attrs, "trigger");
4313    let mut items = Vec::new();
4314    for line in content.lines() {
4315        let trimmed = line.trim();
4316        if let Some(rest) = trimmed.strip_prefix("- ") {
4317            let rest = rest.trim();
4318            // Expected: "Label" description="..." action=... icon=... group=...
4319            if let Some(label_end) = rest.find('"').and_then(|start| {
4320                rest[start + 1..].find('"').map(|end| start + 1 + end)
4321            }) {
4322                let label = rest[1..label_end].to_string();
4323                let remainder = rest[label_end + 1..].trim();
4324                let attrs = crate::attrs::parse_attrs(remainder).unwrap_or_default();
4325                items.push(CommandItem {
4326                    label,
4327                    description: attr_string(&attrs, "description"),
4328                    action: attr_string(&attrs, "action"),
4329                    icon: attr_string(&attrs, "icon"),
4330                    group: attr_string(&attrs, "group"),
4331                });
4332            }
4333        }
4334    }
4335    Block::CommandPalette {
4336        trigger,
4337        items,
4338        span,
4339    }
4340}
4341
4342fn parse_code_editor(attrs: &Attrs, content: &str, span: Span) -> Block {
4343    let lang = attr_string(attrs, "lang");
4344    let source = attr_string(attrs, "source");
4345    let line_numbers = attr_bool(attrs, "line-numbers");
4346    Block::CodeEditor {
4347        lang,
4348        source,
4349        line_numbers,
4350        content: content.to_string(),
4351        span,
4352    }
4353}
4354
4355fn parse_block_editor(attrs: &Attrs, span: Span) -> Block {
4356    let source = attr_string(attrs, "source");
4357    Block::BlockEditor { source, span }
4358}
4359
4360fn parse_terminal(attrs: &Attrs, span: Span) -> Block {
4361    let shell = attr_string(attrs, "shell");
4362    let cwd = attr_string(attrs, "cwd");
4363    Block::Terminal { shell, cwd, span }
4364}
4365
4366fn parse_nav_tree(attrs: &Attrs, span: Span) -> Block {
4367    let source = attr_string(attrs, "source");
4368    let on_select = attr_string(attrs, "on-select");
4369    let on_rename = attr_string(attrs, "on-rename");
4370    let on_delete = attr_string(attrs, "on-delete");
4371    Block::NavTree {
4372        source,
4373        on_select,
4374        on_rename,
4375        on_delete,
4376        span,
4377    }
4378}
4379
4380fn parse_badge(attrs: &Attrs, span: Span) -> Block {
4381    let value = attr_string(attrs, "value").unwrap_or_default();
4382    let color = attr_string(attrs, "color");
4383    Block::Badge { value, color, span }
4384}
4385
4386fn parse_suggestion_chips(attrs: &Attrs, span: Span) -> Block {
4387    let source = attr_string(attrs, "source");
4388    let max = attr_u32(attrs, "max");
4389    let dismissible = attr_bool(attrs, "dismissible");
4390    Block::SuggestionChips {
4391        source,
4392        max,
4393        dismissible,
4394        span,
4395    }
4396}
4397
4398fn parse_chat_thread(attrs: &Attrs, span: Span) -> Block {
4399    let source = attr_string(attrs, "source");
4400    let on_action = attr_string(attrs, "on-action");
4401    Block::ChatThread {
4402        source,
4403        on_action,
4404        span,
4405    }
4406}
4407
4408fn parse_chat_input_simple(attrs: &Attrs, span: Span) -> Block {
4409    let placeholder = attr_string(attrs, "placeholder");
4410    let action = attr_string(attrs, "action");
4411    Block::ChatInputSimple {
4412        placeholder,
4413        action,
4414        span,
4415    }
4416}
4417
4418fn parse_progress(attrs: &Attrs, content: &str, span: Span) -> Block {
4419    let source = attr_string(attrs, "source");
4420    let mut steps = Vec::new();
4421    for line in content.lines() {
4422        let trimmed = line.trim();
4423        if let Some(rest) = trimmed.strip_prefix("- ") {
4424            let rest = rest.trim();
4425            let (label, status) = if let Some(label) = rest.strip_suffix('\u{2713}') {
4426                // ✓ = done
4427                (label.trim().to_string(), "done".to_string())
4428            } else if let Some(label) = rest.strip_suffix('\u{25CF}') {
4429                // ● = active
4430                (label.trim().to_string(), "active".to_string())
4431            } else if let Some(label) = rest.strip_suffix('\u{25CB}') {
4432                // ○ = pending
4433                (label.trim().to_string(), "pending".to_string())
4434            } else {
4435                (rest.to_string(), "pending".to_string())
4436            };
4437            steps.push(ProgressStep { label, status });
4438        }
4439    }
4440    Block::Progress {
4441        source,
4442        steps,
4443        span,
4444    }
4445}
4446
4447fn parse_log_stream(attrs: &Attrs, span: Span) -> Block {
4448    let source = attr_string(attrs, "source");
4449    let tail = attr_u32(attrs, "tail");
4450    Block::LogStream { source, tail, span }
4451}
4452
4453fn parse_problem_list(attrs: &Attrs, span: Span) -> Block {
4454    let source = attr_string(attrs, "source");
4455    Block::ProblemList { source, span }
4456}
4457
4458// ------------------------------------------------------------------
4459// Tests
4460// ------------------------------------------------------------------
4461
4462#[cfg(test)]
4463mod tests {
4464    use super::*;
4465    use crate::types::AttrValue;
4466    use pretty_assertions::assert_eq;
4467    use std::collections::BTreeMap;
4468
4469    /// Helper: build a `Block::Unknown` for testing.
4470    fn unknown(name: &str, attrs: Attrs, content: &str) -> Block {
4471        Block::Unknown {
4472            name: name.to_string(),
4473            attrs,
4474            content: content.to_string(),
4475            span: Span {
4476                start_line: 1,
4477                end_line: 3,
4478                start_offset: 0,
4479                end_offset: 100,
4480            },
4481        }
4482    }
4483
4484    /// Helper: quick attrs builder.
4485    fn attrs(pairs: &[(&str, AttrValue)]) -> Attrs {
4486        let mut map = BTreeMap::new();
4487        for (k, v) in pairs {
4488            map.insert(k.to_string(), v.clone());
4489        }
4490        map
4491    }
4492
4493    // -- Callout ---------------------------------------------------
4494
4495    #[test]
4496    fn resolve_callout_warning() {
4497        let block = unknown(
4498            "callout",
4499            attrs(&[("type", AttrValue::String("warning".into()))]),
4500            "Watch out!",
4501        );
4502        match resolve_block(block) {
4503            Block::Callout {
4504                callout_type,
4505                content,
4506                ..
4507            } => {
4508                assert_eq!(callout_type, CalloutType::Warning);
4509                assert_eq!(content, "Watch out!");
4510            }
4511            other => panic!("Expected Callout, got {other:?}"),
4512        }
4513    }
4514
4515    #[test]
4516    fn resolve_callout_with_title() {
4517        let block = unknown(
4518            "callout",
4519            attrs(&[
4520                ("type", AttrValue::String("tip".into())),
4521                ("title", AttrValue::String("Pro Tip".into())),
4522            ]),
4523            "Use Rust.",
4524        );
4525        match resolve_block(block) {
4526            Block::Callout {
4527                callout_type,
4528                title,
4529                ..
4530            } => {
4531                assert_eq!(callout_type, CalloutType::Tip);
4532                assert_eq!(title, Some("Pro Tip".to_string()));
4533            }
4534            other => panic!("Expected Callout, got {other:?}"),
4535        }
4536    }
4537
4538    #[test]
4539    fn resolve_callout_default_type() {
4540        let block = unknown("callout", Attrs::new(), "No type attr.");
4541        match resolve_block(block) {
4542            Block::Callout { callout_type, .. } => {
4543                assert_eq!(callout_type, CalloutType::Info);
4544            }
4545            other => panic!("Expected Callout, got {other:?}"),
4546        }
4547    }
4548
4549    // -- Data ------------------------------------------------------
4550
4551    #[test]
4552    fn resolve_data_table() {
4553        let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
4554        let block = unknown("data", Attrs::new(), content);
4555        match resolve_block(block) {
4556            Block::Data {
4557                headers,
4558                rows,
4559                format,
4560                ..
4561            } => {
4562                assert_eq!(format, DataFormat::Table);
4563                assert_eq!(headers, vec!["Name", "Age"]);
4564                assert_eq!(rows.len(), 2);
4565                assert_eq!(rows[0], vec!["Alice", "30"]);
4566                assert_eq!(rows[1], vec!["Bob", "25"]);
4567            }
4568            other => panic!("Expected Data, got {other:?}"),
4569        }
4570    }
4571
4572    #[test]
4573    fn resolve_data_with_separator() {
4574        let content = "| H1 | H2 |\n| --- | --- |\n| v1 | v2 |";
4575        let block = unknown("data", Attrs::new(), content);
4576        match resolve_block(block) {
4577            Block::Data { headers, rows, .. } => {
4578                assert_eq!(headers, vec!["H1", "H2"]);
4579                assert_eq!(rows.len(), 1);
4580                assert_eq!(rows[0], vec!["v1", "v2"]);
4581            }
4582            other => panic!("Expected Data, got {other:?}"),
4583        }
4584    }
4585
4586    // p1-fill-render: a `«FILL: label | default»` marker's own `|` separator
4587    // must not be mistaken for the table's column delimiter, or the marker
4588    // splits into two mangled cells and the default is silently dropped.
4589    #[test]
4590    fn resolve_data_table_fill_marker_pipe_stays_atomic() {
4591        let content = "| Item | Price |\n|---|---|\n| Deck | \u{ab}FILL: deck price | $120\u{bb} |";
4592        let block = unknown("data", Attrs::new(), content);
4593        match resolve_block(block) {
4594            Block::Data { headers, rows, .. } => {
4595                assert_eq!(headers, vec!["Item", "Price"]);
4596                assert_eq!(rows.len(), 1);
4597                assert_eq!(
4598                    rows[0],
4599                    vec!["Deck", "\u{ab}FILL: deck price | $120\u{bb}"],
4600                    "the marker's internal `|` must not split the cell"
4601                );
4602            }
4603            other => panic!("Expected Data, got {other:?}"),
4604        }
4605    }
4606
4607    #[test]
4608    fn resolve_data_sortable() {
4609        let block = unknown(
4610            "data",
4611            attrs(&[("sortable", AttrValue::Bool(true))]),
4612            "| A |\n| 1 |",
4613        );
4614        match resolve_block(block) {
4615            Block::Data { sortable, .. } => {
4616                assert!(sortable);
4617            }
4618            other => panic!("Expected Data, got {other:?}"),
4619        }
4620    }
4621
4622    #[test]
4623    fn resolve_data_csv() {
4624        let content = "Name, Age\nAlice, 30\nBob, 25";
4625        let block = unknown(
4626            "data",
4627            attrs(&[("format", AttrValue::String("csv".into()))]),
4628            content,
4629        );
4630        match resolve_block(block) {
4631            Block::Data {
4632                format,
4633                headers,
4634                rows,
4635                ..
4636            } => {
4637                assert_eq!(format, DataFormat::Csv);
4638                assert_eq!(headers, vec!["Name", "Age"]);
4639                assert_eq!(rows.len(), 2);
4640            }
4641            other => panic!("Expected Data, got {other:?}"),
4642        }
4643    }
4644
4645    // -- Code ------------------------------------------------------
4646
4647    #[test]
4648    fn resolve_code_with_lang() {
4649        let block = unknown(
4650            "code",
4651            attrs(&[("lang", AttrValue::String("rust".into()))]),
4652            "fn main() {}",
4653        );
4654        match resolve_block(block) {
4655            Block::Code { lang, content, .. } => {
4656                assert_eq!(lang, Some("rust".to_string()));
4657                assert_eq!(content, "fn main() {}");
4658            }
4659            other => panic!("Expected Code, got {other:?}"),
4660        }
4661    }
4662
4663    #[test]
4664    fn resolve_code_with_file() {
4665        let block = unknown(
4666            "code",
4667            attrs(&[
4668                ("lang", AttrValue::String("rust".into())),
4669                ("file", AttrValue::String("main.rs".into())),
4670            ]),
4671            "fn main() {}",
4672        );
4673        match resolve_block(block) {
4674            Block::Code { lang, file, .. } => {
4675                assert_eq!(lang, Some("rust".to_string()));
4676                assert_eq!(file, Some("main.rs".to_string()));
4677            }
4678            other => panic!("Expected Code, got {other:?}"),
4679        }
4680    }
4681
4682    // -- Tasks -----------------------------------------------------
4683
4684    #[test]
4685    fn resolve_tasks_mixed() {
4686        let content = "- [ ] Write tests\n- [x] Write parser";
4687        let block = unknown("tasks", Attrs::new(), content);
4688        match resolve_block(block) {
4689            Block::Tasks { items, .. } => {
4690                assert_eq!(items.len(), 2);
4691                assert!(!items[0].done);
4692                assert_eq!(items[0].text, "Write tests");
4693                assert!(items[1].done);
4694                assert_eq!(items[1].text, "Write parser");
4695            }
4696            other => panic!("Expected Tasks, got {other:?}"),
4697        }
4698    }
4699
4700    #[test]
4701    fn resolve_tasks_with_assignee() {
4702        let content = "- [ ] Fix bug @brady";
4703        let block = unknown("tasks", Attrs::new(), content);
4704        match resolve_block(block) {
4705            Block::Tasks { items, .. } => {
4706                assert_eq!(items.len(), 1);
4707                assert_eq!(items[0].text, "Fix bug");
4708                assert_eq!(items[0].assignee, Some("brady".to_string()));
4709            }
4710            other => panic!("Expected Tasks, got {other:?}"),
4711        }
4712    }
4713
4714    // -- Decision --------------------------------------------------
4715
4716    #[test]
4717    fn resolve_decision_accepted() {
4718        let block = unknown(
4719            "decision",
4720            attrs(&[
4721                ("status", AttrValue::String("accepted".into())),
4722                ("date", AttrValue::String("2026-02-10".into())),
4723            ]),
4724            "We chose Rust.",
4725        );
4726        match resolve_block(block) {
4727            Block::Decision {
4728                status,
4729                date,
4730                content,
4731                ..
4732            } => {
4733                assert_eq!(status, DecisionStatus::Accepted);
4734                assert_eq!(date, Some("2026-02-10".to_string()));
4735                assert_eq!(content, "We chose Rust.");
4736            }
4737            other => panic!("Expected Decision, got {other:?}"),
4738        }
4739    }
4740
4741    #[test]
4742    fn resolve_decision_with_deciders() {
4743        let block = unknown(
4744            "decision",
4745            attrs(&[
4746                ("status", AttrValue::String("proposed".into())),
4747                ("deciders", AttrValue::String("Brady, Ryan".into())),
4748            ]),
4749            "Consider options.",
4750        );
4751        match resolve_block(block) {
4752            Block::Decision { deciders, .. } => {
4753                assert_eq!(deciders, vec!["Brady", "Ryan"]);
4754            }
4755            other => panic!("Expected Decision, got {other:?}"),
4756        }
4757    }
4758
4759    // -- Metric ----------------------------------------------------
4760
4761    #[test]
4762    fn resolve_metric_basic() {
4763        let block = unknown(
4764            "metric",
4765            attrs(&[
4766                ("label", AttrValue::String("MRR".into())),
4767                ("value", AttrValue::String("$2K".into())),
4768            ]),
4769            "",
4770        );
4771        match resolve_block(block) {
4772            Block::Metric { label, value, .. } => {
4773                assert_eq!(label, "MRR");
4774                assert_eq!(value, "$2K");
4775            }
4776            other => panic!("Expected Metric, got {other:?}"),
4777        }
4778    }
4779
4780    #[test]
4781    fn resolve_metric_with_trend() {
4782        let block = unknown(
4783            "metric",
4784            attrs(&[
4785                ("label", AttrValue::String("Users".into())),
4786                ("value", AttrValue::String("500".into())),
4787                ("trend", AttrValue::String("up".into())),
4788            ]),
4789            "",
4790        );
4791        match resolve_block(block) {
4792            Block::Metric { trend, .. } => {
4793                assert_eq!(trend, Some(Trend::Up));
4794            }
4795            other => panic!("Expected Metric, got {other:?}"),
4796        }
4797    }
4798
4799    // -- Summary ---------------------------------------------------
4800
4801    #[test]
4802    fn resolve_summary() {
4803        let block = unknown("summary", Attrs::new(), "This is the executive summary.");
4804        match resolve_block(block) {
4805            Block::Summary { content, .. } => {
4806                assert_eq!(content, "This is the executive summary.");
4807            }
4808            other => panic!("Expected Summary, got {other:?}"),
4809        }
4810    }
4811
4812    // -- Figure ----------------------------------------------------
4813
4814    #[test]
4815    fn resolve_figure_basic() {
4816        let block = unknown(
4817            "figure",
4818            attrs(&[
4819                ("src", AttrValue::String("img.png".into())),
4820                ("caption", AttrValue::String("Photo".into())),
4821            ]),
4822            "",
4823        );
4824        match resolve_block(block) {
4825            Block::Figure {
4826                src,
4827                caption,
4828                alt,
4829                width,
4830                ..
4831            } => {
4832                assert_eq!(src, "img.png");
4833                assert_eq!(caption, Some("Photo".to_string()));
4834                assert!(alt.is_none());
4835                assert!(width.is_none());
4836            }
4837            other => panic!("Expected Figure, got {other:?}"),
4838        }
4839    }
4840
4841    // -- Diagram ---------------------------------------------------
4842
4843    #[test]
4844    fn resolve_diagram_lowercases_type_and_keeps_content() {
4845        let block = unknown(
4846            "diagram",
4847            attrs(&[
4848                ("type", AttrValue::String("Architecture".into())),
4849                ("title", AttrValue::String("System Map".into())),
4850            ]),
4851            "web: Web\nweb -> api",
4852        );
4853        match resolve_block(block) {
4854            Block::Diagram {
4855                diagram_type,
4856                title,
4857                content,
4858                ..
4859            } => {
4860                assert_eq!(diagram_type, "architecture");
4861                assert_eq!(title, Some("System Map".to_string()));
4862                assert_eq!(content, "web: Web\nweb -> api");
4863            }
4864            other => panic!("Expected Diagram, got {other:?}"),
4865        }
4866    }
4867
4868    #[test]
4869    fn resolve_diagram_missing_type_is_empty_string() {
4870        let block = unknown("diagram", attrs(&[]), "a -> b");
4871        match resolve_block(block) {
4872            Block::Diagram {
4873                diagram_type,
4874                title,
4875                ..
4876            } => {
4877                assert_eq!(diagram_type, "");
4878                assert!(title.is_none());
4879            }
4880            other => panic!("Expected Diagram, got {other:?}"),
4881        }
4882    }
4883
4884    #[test]
4885    fn resolve_diagram_unknown_type_round_trips_raw() {
4886        // Unknown types stay a String so they survive losslessly.
4887        let block = unknown(
4888            "diagram",
4889            attrs(&[("type", AttrValue::String("flowchart".into()))]),
4890            "whatever -> body",
4891        );
4892        match resolve_block(block) {
4893            Block::Diagram { diagram_type, content, .. } => {
4894                assert_eq!(diagram_type, "flowchart");
4895                assert_eq!(content, "whatever -> body");
4896            }
4897            other => panic!("Expected Diagram, got {other:?}"),
4898        }
4899    }
4900
4901    // -- Tabs ------------------------------------------------------
4902
4903    #[test]
4904    fn resolve_tabs_with_headers() {
4905        let content = "## Overview\nIntro text.\n\n## Details\nTechnical info.\n\n## FAQ\nQ&A here.";
4906        let block = unknown("tabs", Attrs::new(), content);
4907        match resolve_block(block) {
4908            Block::Tabs { tabs, .. } => {
4909                assert_eq!(tabs.len(), 3);
4910                assert_eq!(tabs[0].label, "Overview");
4911                assert!(tabs[0].content.contains("Intro text."));
4912                assert_eq!(tabs[1].label, "Details");
4913                assert!(tabs[1].content.contains("Technical info."));
4914                assert_eq!(tabs[2].label, "FAQ");
4915                assert!(tabs[2].content.contains("Q&A here."));
4916            }
4917            other => panic!("Expected Tabs, got {other:?}"),
4918        }
4919    }
4920
4921    #[test]
4922    fn resolve_tabs_single_no_header() {
4923        let content = "Just some text without any tab headers.";
4924        let block = unknown("tabs", Attrs::new(), content);
4925        match resolve_block(block) {
4926            Block::Tabs { tabs, .. } => {
4927                assert_eq!(tabs.len(), 1);
4928                assert_eq!(tabs[0].label, "Tab 1");
4929                assert!(tabs[0].content.contains("Just some text"));
4930            }
4931            other => panic!("Expected Tabs, got {other:?}"),
4932        }
4933    }
4934
4935    // -- Columns ---------------------------------------------------
4936
4937    #[test]
4938    fn resolve_columns_with_nested_directives() {
4939        let content = ":::column\nLeft content.\n:::\n:::column\nRight content.\n:::";
4940        let block = unknown("columns", Attrs::new(), content);
4941        match resolve_block(block) {
4942            Block::Columns { columns, .. } => {
4943                assert_eq!(columns.len(), 2);
4944                assert_eq!(columns[0].content, "Left content.");
4945                assert_eq!(columns[1].content, "Right content.");
4946            }
4947            other => panic!("Expected Columns, got {other:?}"),
4948        }
4949    }
4950
4951    #[test]
4952    fn resolve_columns_with_hr_separator() {
4953        let content = "Left side.\n---\nRight side.";
4954        let block = unknown("columns", Attrs::new(), content);
4955        match resolve_block(block) {
4956            Block::Columns { columns, .. } => {
4957                assert_eq!(columns.len(), 2);
4958                assert_eq!(columns[0].content, "Left side.");
4959                assert_eq!(columns[1].content, "Right side.");
4960            }
4961            other => panic!("Expected Columns, got {other:?}"),
4962        }
4963    }
4964
4965    #[test]
4966    fn resolve_columns_single() {
4967        let content = "All in one column.";
4968        let block = unknown("columns", Attrs::new(), content);
4969        match resolve_block(block) {
4970            Block::Columns { columns, .. } => {
4971                assert_eq!(columns.len(), 1);
4972                assert_eq!(columns[0].content, "All in one column.");
4973            }
4974            other => panic!("Expected Columns, got {other:?}"),
4975        }
4976    }
4977
4978    // -- Quote -----------------------------------------------------
4979
4980    #[test]
4981    fn resolve_quote_with_attribution() {
4982        let block = unknown(
4983            "quote",
4984            attrs(&[
4985                ("by", AttrValue::String("Alan Kay".into())),
4986                ("cite", AttrValue::String("ACM 1971".into())),
4987            ]),
4988            "The best way to predict the future is to invent it.",
4989        );
4990        match resolve_block(block) {
4991            Block::Quote {
4992                content,
4993                attribution,
4994                cite,
4995                ..
4996            } => {
4997                assert_eq!(content, "The best way to predict the future is to invent it.");
4998                assert_eq!(attribution, Some("Alan Kay".to_string()));
4999                assert_eq!(cite, Some("ACM 1971".to_string()));
5000            }
5001            other => panic!("Expected Quote, got {other:?}"),
5002        }
5003    }
5004
5005    #[test]
5006    fn resolve_quote_no_attribution() {
5007        let block = unknown("quote", Attrs::new(), "Anonymous wisdom.");
5008        match resolve_block(block) {
5009            Block::Quote {
5010                content,
5011                attribution,
5012                ..
5013            } => {
5014                assert_eq!(content, "Anonymous wisdom.");
5015                assert!(attribution.is_none());
5016            }
5017            other => panic!("Expected Quote, got {other:?}"),
5018        }
5019    }
5020
5021    #[test]
5022    fn resolve_quote_author_alias() {
5023        let block = unknown(
5024            "quote",
5025            attrs(&[("author", AttrValue::String("Knuth".into()))]),
5026            "Premature optimization.",
5027        );
5028        match resolve_block(block) {
5029            Block::Quote { attribution, .. } => {
5030                assert_eq!(attribution, Some("Knuth".to_string()));
5031            }
5032            other => panic!("Expected Quote, got {other:?}"),
5033        }
5034    }
5035
5036    // -- Cta -------------------------------------------------------
5037
5038    #[test]
5039    fn resolve_cta_primary() {
5040        let block = unknown(
5041            "cta",
5042            attrs(&[
5043                ("label", AttrValue::String("Get Started".into())),
5044                ("href", AttrValue::String("/signup".into())),
5045                ("primary", AttrValue::Bool(true)),
5046            ]),
5047            "",
5048        );
5049        match resolve_block(block) {
5050            Block::Cta {
5051                label,
5052                href,
5053                primary,
5054                ..
5055            } => {
5056                assert_eq!(label, "Get Started");
5057                assert_eq!(href, "/signup");
5058                assert!(primary);
5059            }
5060            other => panic!("Expected Cta, got {other:?}"),
5061        }
5062    }
5063
5064    #[test]
5065    fn resolve_cta_secondary() {
5066        let block = unknown(
5067            "cta",
5068            attrs(&[
5069                ("label", AttrValue::String("Learn More".into())),
5070                ("href", AttrValue::String("https://example.com".into())),
5071            ]),
5072            "",
5073        );
5074        match resolve_block(block) {
5075            Block::Cta {
5076                label,
5077                href,
5078                primary,
5079                ..
5080            } => {
5081                assert_eq!(label, "Learn More");
5082                assert_eq!(href, "https://example.com");
5083                assert!(!primary);
5084            }
5085            other => panic!("Expected Cta, got {other:?}"),
5086        }
5087    }
5088
5089    // -- HeroImage -------------------------------------------------
5090
5091    #[test]
5092    fn resolve_hero_image_with_alt() {
5093        let block = unknown(
5094            "hero-image",
5095            attrs(&[
5096                ("src", AttrValue::String("hero.png".into())),
5097                ("alt", AttrValue::String("Product screenshot".into())),
5098            ]),
5099            "",
5100        );
5101        match resolve_block(block) {
5102            Block::HeroImage { src, alt, .. } => {
5103                assert_eq!(src, "hero.png");
5104                assert_eq!(alt, Some("Product screenshot".to_string()));
5105            }
5106            other => panic!("Expected HeroImage, got {other:?}"),
5107        }
5108    }
5109
5110    #[test]
5111    fn resolve_hero_image_no_alt() {
5112        let block = unknown(
5113            "hero-image",
5114            attrs(&[("src", AttrValue::String("banner.jpg".into()))]),
5115            "",
5116        );
5117        match resolve_block(block) {
5118            Block::HeroImage { src, alt, .. } => {
5119                assert_eq!(src, "banner.jpg");
5120                assert!(alt.is_none());
5121            }
5122            other => panic!("Expected HeroImage, got {other:?}"),
5123        }
5124    }
5125
5126    // -- Testimonial -----------------------------------------------
5127
5128    #[test]
5129    fn resolve_testimonial_full() {
5130        let block = unknown(
5131            "testimonial",
5132            attrs(&[
5133                ("author", AttrValue::String("Jane Dev".into())),
5134                ("role", AttrValue::String("Engineer".into())),
5135                ("company", AttrValue::String("Acme".into())),
5136            ]),
5137            "This tool replaced 3 others for me.",
5138        );
5139        match resolve_block(block) {
5140            Block::Testimonial {
5141                content,
5142                author,
5143                role,
5144                company,
5145                ..
5146            } => {
5147                assert_eq!(content, "This tool replaced 3 others for me.");
5148                assert_eq!(author, Some("Jane Dev".to_string()));
5149                assert_eq!(role, Some("Engineer".to_string()));
5150                assert_eq!(company, Some("Acme".to_string()));
5151            }
5152            other => panic!("Expected Testimonial, got {other:?}"),
5153        }
5154    }
5155
5156    #[test]
5157    fn resolve_testimonial_name_alias() {
5158        let block = unknown(
5159            "testimonial",
5160            attrs(&[("name", AttrValue::String("Bob".into()))]),
5161            "Great product.",
5162        );
5163        match resolve_block(block) {
5164            Block::Testimonial { author, .. } => {
5165                assert_eq!(author, Some("Bob".to_string()));
5166            }
5167            other => panic!("Expected Testimonial, got {other:?}"),
5168        }
5169    }
5170
5171    #[test]
5172    fn resolve_testimonial_anonymous() {
5173        let block = unknown("testimonial", Attrs::new(), "Anonymous feedback.");
5174        match resolve_block(block) {
5175            Block::Testimonial {
5176                content,
5177                author,
5178                role,
5179                company,
5180                ..
5181            } => {
5182                assert_eq!(content, "Anonymous feedback.");
5183                assert!(author.is_none());
5184                assert!(role.is_none());
5185                assert!(company.is_none());
5186            }
5187            other => panic!("Expected Testimonial, got {other:?}"),
5188        }
5189    }
5190
5191    // -- Style -----------------------------------------------------
5192
5193    #[test]
5194    fn resolve_style_properties() {
5195        let content = "hero-bg: gradient indigo\ncard-radius: lg\nmax-width: 1200px";
5196        let block = unknown("style", Attrs::new(), content);
5197        match resolve_block(block) {
5198            Block::Style { properties, .. } => {
5199                assert_eq!(properties.len(), 3);
5200                assert_eq!(properties[0].key, "hero-bg");
5201                assert_eq!(properties[0].value, "gradient indigo");
5202                assert_eq!(properties[1].key, "card-radius");
5203                assert_eq!(properties[1].value, "lg");
5204                assert_eq!(properties[2].key, "max-width");
5205                assert_eq!(properties[2].value, "1200px");
5206            }
5207            other => panic!("Expected Style, got {other:?}"),
5208        }
5209    }
5210
5211    #[test]
5212    fn resolve_style_empty() {
5213        let block = unknown("style", Attrs::new(), "");
5214        match resolve_block(block) {
5215            Block::Style { properties, .. } => {
5216                assert!(properties.is_empty());
5217            }
5218            other => panic!("Expected Style, got {other:?}"),
5219        }
5220    }
5221
5222    #[test]
5223    fn resolve_style_skips_blank_lines() {
5224        let content = "  \nfont: inter\n\naccent: #6366f1\n  ";
5225        let block = unknown("style", Attrs::new(), content);
5226        match resolve_block(block) {
5227            Block::Style { properties, .. } => {
5228                assert_eq!(properties.len(), 2);
5229                assert_eq!(properties[0].key, "font");
5230                assert_eq!(properties[0].value, "inter");
5231                assert_eq!(properties[1].key, "accent");
5232                assert_eq!(properties[1].value, "#6366f1");
5233            }
5234            other => panic!("Expected Style, got {other:?}"),
5235        }
5236    }
5237
5238    // -- Faq -------------------------------------------------------
5239
5240    #[test]
5241    fn resolve_faq_multiple_items() {
5242        let content = "### Is my data encrypted?\nYes — AES-256 at rest, TLS in transit.\n\n### Can I self-host?\nYes. Docker image available.";
5243        let block = unknown("faq", Attrs::new(), content);
5244        match resolve_block(block) {
5245            Block::Faq { items, .. } => {
5246                assert_eq!(items.len(), 2);
5247                assert_eq!(items[0].question, "Is my data encrypted?");
5248                assert!(items[0].answer.contains("AES-256"));
5249                assert_eq!(items[1].question, "Can I self-host?");
5250                assert!(items[1].answer.contains("Docker"));
5251            }
5252            other => panic!("Expected Faq, got {other:?}"),
5253        }
5254    }
5255
5256    #[test]
5257    fn resolve_faq_h2_headers() {
5258        let content = "## Question one\nAnswer one.\n\n## Question two\nAnswer two.";
5259        let block = unknown("faq", Attrs::new(), content);
5260        match resolve_block(block) {
5261            Block::Faq { items, .. } => {
5262                assert_eq!(items.len(), 2);
5263                assert_eq!(items[0].question, "Question one");
5264                assert_eq!(items[1].question, "Question two");
5265            }
5266            other => panic!("Expected Faq, got {other:?}"),
5267        }
5268    }
5269
5270    #[test]
5271    fn resolve_faq_empty() {
5272        let block = unknown("faq", Attrs::new(), "");
5273        match resolve_block(block) {
5274            Block::Faq { items, .. } => {
5275                assert!(items.is_empty());
5276            }
5277            other => panic!("Expected Faq, got {other:?}"),
5278        }
5279    }
5280
5281    #[test]
5282    fn resolve_faq_single_item() {
5283        let content = "### How does pricing work?\nWe charge per seat per month.";
5284        let block = unknown("faq", Attrs::new(), content);
5285        match resolve_block(block) {
5286            Block::Faq { items, .. } => {
5287                assert_eq!(items.len(), 1);
5288                assert_eq!(items[0].question, "How does pricing work?");
5289                assert_eq!(items[0].answer, "We charge per seat per month.");
5290            }
5291            other => panic!("Expected Faq, got {other:?}"),
5292        }
5293    }
5294
5295    // -- PricingTable ----------------------------------------------
5296
5297    #[test]
5298    fn resolve_pricing_table() {
5299        let content = "| | Free | Pro | Team |\n|---|---|---|---|\n| Price | $0 | $4.99/mo | $8.99/seat/mo |\n| Notes | Unlimited | Unlimited | Unlimited |";
5300        let block = unknown("pricing-table", Attrs::new(), content);
5301        match resolve_block(block) {
5302            Block::PricingTable {
5303                headers, rows, ..
5304            } => {
5305                assert_eq!(headers, vec!["", "Free", "Pro", "Team"]);
5306                assert_eq!(rows.len(), 2);
5307                assert_eq!(rows[0][0], "Price");
5308                assert_eq!(rows[0][2], "$4.99/mo");
5309                assert_eq!(rows[1][3], "Unlimited");
5310            }
5311            other => panic!("Expected PricingTable, got {other:?}"),
5312        }
5313    }
5314
5315    #[test]
5316    fn resolve_pricing_table_empty() {
5317        let block = unknown("pricing-table", Attrs::new(), "");
5318        match resolve_block(block) {
5319            Block::PricingTable {
5320                headers, rows, ..
5321            } => {
5322                assert!(headers.is_empty());
5323                assert!(rows.is_empty());
5324            }
5325            other => panic!("Expected PricingTable, got {other:?}"),
5326        }
5327    }
5328
5329    // -- Site ------------------------------------------------------
5330
5331    #[test]
5332    fn resolve_site_with_domain() {
5333        let block = unknown(
5334            "site",
5335            attrs(&[("domain", AttrValue::String("notesurf.io".into()))]),
5336            "name: NoteSurf\ntagline: Notes that belong to you.\ntheme: dark\naccent: #6366f1",
5337        );
5338        match resolve_block(block) {
5339            Block::Site {
5340                domain,
5341                properties,
5342                ..
5343            } => {
5344                assert_eq!(domain, Some("notesurf.io".to_string()));
5345                assert_eq!(properties.len(), 4);
5346                assert_eq!(properties[0].key, "name");
5347                assert_eq!(properties[0].value, "NoteSurf");
5348                assert_eq!(properties[1].key, "tagline");
5349                assert_eq!(properties[1].value, "Notes that belong to you.");
5350                assert_eq!(properties[2].key, "theme");
5351                assert_eq!(properties[2].value, "dark");
5352            }
5353            other => panic!("Expected Site, got {other:?}"),
5354        }
5355    }
5356
5357    #[test]
5358    fn resolve_site_no_domain() {
5359        let block = unknown("site", Attrs::new(), "name: Test Site");
5360        match resolve_block(block) {
5361            Block::Site {
5362                domain,
5363                properties,
5364                ..
5365            } => {
5366                assert!(domain.is_none());
5367                assert_eq!(properties.len(), 1);
5368            }
5369            other => panic!("Expected Site, got {other:?}"),
5370        }
5371    }
5372
5373    // -- Page ------------------------------------------------------
5374
5375    #[test]
5376    fn resolve_page_basic() {
5377        let block = unknown(
5378            "page",
5379            attrs(&[
5380                ("route", AttrValue::String("/".into())),
5381                ("layout", AttrValue::String("hero".into())),
5382            ]),
5383            "# Welcome\n\nSome intro text.",
5384        );
5385        match resolve_block(block) {
5386            Block::Page {
5387                route,
5388                layout,
5389                children,
5390                ..
5391            } => {
5392                assert_eq!(route, "/");
5393                assert_eq!(layout, Some("hero".to_string()));
5394                // All content is markdown (no leaf directives)
5395                assert_eq!(children.len(), 1);
5396                assert!(matches!(&children[0], Block::Markdown { .. }));
5397            }
5398            other => panic!("Expected Page, got {other:?}"),
5399        }
5400    }
5401
5402    #[test]
5403    fn resolve_page_with_nested_cta() {
5404        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\"]";
5405        let block = unknown(
5406            "page",
5407            attrs(&[("route", AttrValue::String("/".into()))]),
5408            content,
5409        );
5410        match resolve_block(block) {
5411            Block::Page { children, .. } => {
5412                // Should be: Markdown, Cta (primary), Cta (secondary)
5413                assert_eq!(children.len(), 3, "children: {children:#?}");
5414                assert!(matches!(&children[0], Block::Markdown { .. }));
5415                match &children[1] {
5416                    Block::Cta {
5417                        label, primary, ..
5418                    } => {
5419                        assert_eq!(label, "Download");
5420                        assert!(*primary);
5421                    }
5422                    other => panic!("Expected Cta, got {other:?}"),
5423                }
5424                match &children[2] {
5425                    Block::Cta {
5426                        label, primary, ..
5427                    } => {
5428                        assert_eq!(label, "Try Web");
5429                        assert!(!*primary);
5430                    }
5431                    other => panic!("Expected Cta, got {other:?}"),
5432                }
5433            }
5434            other => panic!("Expected Page, got {other:?}"),
5435        }
5436    }
5437
5438    #[test]
5439    fn resolve_page_with_mixed_children() {
5440        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]";
5441        let block = unknown(
5442            "page",
5443            attrs(&[
5444                ("route", AttrValue::String("/".into())),
5445                ("layout", AttrValue::String("hero".into())),
5446            ]),
5447            content,
5448        );
5449        match resolve_block(block) {
5450            Block::Page { children, .. } => {
5451                // Markdown, HeroImage, Markdown, Cta
5452                assert_eq!(children.len(), 4, "children: {children:#?}");
5453                assert!(matches!(&children[0], Block::Markdown { .. }));
5454                assert!(matches!(&children[1], Block::HeroImage { .. }));
5455                assert!(matches!(&children[2], Block::Markdown { .. }));
5456                assert!(matches!(&children[3], Block::Cta { .. }));
5457            }
5458            other => panic!("Expected Page, got {other:?}"),
5459        }
5460    }
5461
5462    #[test]
5463    fn resolve_page_empty() {
5464        let block = unknown(
5465            "page",
5466            attrs(&[("route", AttrValue::String("/about".into()))]),
5467            "",
5468        );
5469        match resolve_block(block) {
5470            Block::Page {
5471                route, children, ..
5472            } => {
5473                assert_eq!(route, "/about");
5474                assert!(children.is_empty());
5475            }
5476            other => panic!("Expected Page, got {other:?}"),
5477        }
5478    }
5479
5480    // -- Passthrough -----------------------------------------------
5481
5482    #[test]
5483    fn resolve_unknown_passthrough() {
5484        let block = unknown("custom_block", Attrs::new(), "whatever");
5485        match resolve_block(block) {
5486            Block::Unknown { name, .. } => {
5487                assert_eq!(name, "custom_block");
5488            }
5489            other => panic!("Expected Unknown passthrough, got {other:?}"),
5490        }
5491    }
5492
5493    // -- Container blocks inside Page ---------------------------------
5494
5495    #[test]
5496    fn page_container_pricing_table() {
5497        let content = "# Menu\n\n::pricing-table\n| Item | Price |\n|------|-------|\n| Coffee | $4 |\n| Muffin | $3 |\n::\n\nVisit us today!";
5498        let block = unknown(
5499            "page",
5500            attrs(&[("route", AttrValue::String("/".into()))]),
5501            content,
5502        );
5503        match resolve_block(block) {
5504            Block::Page { children, .. } => {
5505                assert_eq!(children.len(), 3, "children: {children:#?}");
5506                assert!(matches!(&children[0], Block::Markdown { .. }));
5507                match &children[1] {
5508                    Block::PricingTable { headers, rows, .. } => {
5509                        assert_eq!(headers, &["Item", "Price"]);
5510                        assert_eq!(rows.len(), 2);
5511                        assert_eq!(rows[0], vec!["Coffee", "$4"]);
5512                        assert_eq!(rows[1], vec!["Muffin", "$3"]);
5513                    }
5514                    other => panic!("Expected PricingTable, got {other:?}"),
5515                }
5516                assert!(matches!(&children[2], Block::Markdown { .. }));
5517            }
5518            other => panic!("Expected Page, got {other:?}"),
5519        }
5520    }
5521
5522    #[test]
5523    fn page_children_fenced_directive_sample_stays_literal() {
5524        // Regression: a fenced SurfDoc code sample inside a page's content
5525        // (the exact input parse_page_children resolves) must not have its
5526        // tolerant `:: name` opener or bare `::` closer parsed as a real
5527        // directive — the whole fence should survive as one Markdown block.
5528        let content = "Example usage:\n\n```\n:: hero[title=\"X\"]\nSome text\n::\n```\n\nAfter the sample.";
5529        let block = unknown(
5530            "page",
5531            attrs(&[("route", AttrValue::String("/".into()))]),
5532            content,
5533        );
5534        match resolve_block(block) {
5535            Block::Page { children, .. } => {
5536                assert_eq!(children.len(), 1, "children: {children:#?}");
5537                match &children[0] {
5538                    Block::Markdown { content, .. } => {
5539                        assert!(
5540                            content.contains("```\n:: hero[title=\"X\"]\nSome text\n::\n```"),
5541                            "fence body was mangled: {content:?}"
5542                        );
5543                    }
5544                    other => panic!("Expected Markdown, got {other:?}"),
5545                }
5546            }
5547            other => panic!("Expected Page, got {other:?}"),
5548        }
5549    }
5550
5551    #[test]
5552    fn page_container_nested_fenced_directive_sample_stays_literal() {
5553        // Same fenced-sample hazard, but nested one level inside a real
5554        // container block (exercises scan_container_close's own fence
5555        // tracking of the tolerant opener, not just parse_page_children's
5556        // top loop).
5557        let content = "::callout[type=info]\nExample:\n```\n:: hero[title=\"X\"]\n```\n::";
5558        let block = unknown(
5559            "page",
5560            attrs(&[("route", AttrValue::String("/".into()))]),
5561            content,
5562        );
5563        match resolve_block(block) {
5564            Block::Page { children, .. } => {
5565                assert_eq!(children.len(), 1, "children: {children:#?}");
5566                match &children[0] {
5567                    Block::Callout { content, .. } => {
5568                        assert!(
5569                            content.contains("```\n:: hero[title=\"X\"]\n```"),
5570                            "nested fence body was mangled: {content:?}"
5571                        );
5572                    }
5573                    other => panic!("Expected Callout, got {other:?}"),
5574                }
5575            }
5576            other => panic!("Expected Page, got {other:?}"),
5577        }
5578    }
5579
5580    #[test]
5581    fn page_container_callout() {
5582        let content = "::callout[type=warning]\nWatch out for hot drinks!\n::";
5583        let block = unknown(
5584            "page",
5585            attrs(&[("route", AttrValue::String("/".into()))]),
5586            content,
5587        );
5588        match resolve_block(block) {
5589            Block::Page { children, .. } => {
5590                assert_eq!(children.len(), 1, "children: {children:#?}");
5591                match &children[0] {
5592                    Block::Callout { callout_type, content, .. } => {
5593                        assert_eq!(*callout_type, CalloutType::Warning);
5594                        assert_eq!(content, "Watch out for hot drinks!");
5595                    }
5596                    other => panic!("Expected Callout, got {other:?}"),
5597                }
5598            }
5599            other => panic!("Expected Page, got {other:?}"),
5600        }
5601    }
5602
5603    #[test]
5604    fn page_container_faq() {
5605        let content = "::faq\n### What are your hours?\nMon-Fri 7am-6pm.\n### Do you deliver?\nYes, within 5 miles.\n::";
5606        let block = unknown(
5607            "page",
5608            attrs(&[("route", AttrValue::String("/".into()))]),
5609            content,
5610        );
5611        match resolve_block(block) {
5612            Block::Page { children, .. } => {
5613                assert_eq!(children.len(), 1, "children: {children:#?}");
5614                match &children[0] {
5615                    Block::Faq { items, .. } => {
5616                        assert_eq!(items.len(), 2);
5617                        assert_eq!(items[0].question, "What are your hours?");
5618                        assert!(items[0].answer.contains("7am-6pm"));
5619                        assert_eq!(items[1].question, "Do you deliver?");
5620                    }
5621                    other => panic!("Expected Faq, got {other:?}"),
5622                }
5623            }
5624            other => panic!("Expected Page, got {other:?}"),
5625        }
5626    }
5627
5628    #[test]
5629    fn page_container_data() {
5630        let content = "::data\n| Name | Value |\n|------|-------|\n| Alpha | 100 |\n::";
5631        let block = unknown(
5632            "page",
5633            attrs(&[("route", AttrValue::String("/".into()))]),
5634            content,
5635        );
5636        match resolve_block(block) {
5637            Block::Page { children, .. } => {
5638                assert_eq!(children.len(), 1, "children: {children:#?}");
5639                match &children[0] {
5640                    Block::Data { headers, rows, .. } => {
5641                        assert_eq!(headers, &["Name", "Value"]);
5642                        assert_eq!(rows.len(), 1);
5643                        assert_eq!(rows[0], vec!["Alpha", "100"]);
5644                    }
5645                    other => panic!("Expected Data, got {other:?}"),
5646                }
5647            }
5648            other => panic!("Expected Page, got {other:?}"),
5649        }
5650    }
5651
5652    #[test]
5653    fn page_container_testimonial() {
5654        let content = "::testimonial[author=\"Jane\" role=\"Regular\"]\nBest bakery in town!\n::";
5655        let block = unknown(
5656            "page",
5657            attrs(&[("route", AttrValue::String("/".into()))]),
5658            content,
5659        );
5660        match resolve_block(block) {
5661            Block::Page { children, .. } => {
5662                assert_eq!(children.len(), 1, "children: {children:#?}");
5663                match &children[0] {
5664                    Block::Testimonial { content, author, role, .. } => {
5665                        assert_eq!(content, "Best bakery in town!");
5666                        assert_eq!(author.as_deref(), Some("Jane"));
5667                        assert_eq!(role.as_deref(), Some("Regular"));
5668                    }
5669                    other => panic!("Expected Testimonial, got {other:?}"),
5670                }
5671            }
5672            other => panic!("Expected Page, got {other:?}"),
5673        }
5674    }
5675
5676    #[test]
5677    fn page_container_columns_with_nesting() {
5678        let content = "::columns\n:::column\nLeft side.\n:::\n:::column\nRight side.\n:::\n::";
5679        let block = unknown(
5680            "page",
5681            attrs(&[("route", AttrValue::String("/".into()))]),
5682            content,
5683        );
5684        match resolve_block(block) {
5685            Block::Page { children, .. } => {
5686                assert_eq!(children.len(), 1, "children: {children:#?}");
5687                match &children[0] {
5688                    Block::Columns { columns, .. } => {
5689                        assert_eq!(columns.len(), 2);
5690                        assert_eq!(columns[0].content, "Left side.");
5691                        assert_eq!(columns[1].content, "Right side.");
5692                    }
5693                    other => panic!("Expected Columns, got {other:?}"),
5694                }
5695            }
5696            other => panic!("Expected Page, got {other:?}"),
5697        }
5698    }
5699
5700    #[test]
5701    fn page_mixed_leaf_and_container_preserves_order() {
5702        let content = "# Welcome\n\n::hero-image[src=\"hero.png\"]\n\n::callout[type=tip]\nPro tip: order early!\n::\n\n::cta[label=\"Order Now\" href=\"/order\" primary]\n\n::faq\n### How to order?\nOnline or in store.\n::";
5703        let block = unknown(
5704            "page",
5705            attrs(&[("route", AttrValue::String("/".into()))]),
5706            content,
5707        );
5708        match resolve_block(block) {
5709            Block::Page { children, .. } => {
5710                // Markdown, HeroImage, Callout, Cta, Faq
5711                assert_eq!(children.len(), 5, "children: {children:#?}");
5712                assert!(matches!(&children[0], Block::Markdown { .. }));
5713                assert!(matches!(&children[1], Block::HeroImage { .. }));
5714                assert!(matches!(&children[2], Block::Callout { .. }));
5715                assert!(matches!(&children[3], Block::Cta { .. }));
5716                assert!(matches!(&children[4], Block::Faq { .. }));
5717            }
5718            other => panic!("Expected Page, got {other:?}"),
5719        }
5720    }
5721
5722    #[test]
5723    fn page_bakery_example_no_leaked_markers() {
5724        // Simulate a typical AI-generated bakery site
5725        let content = r#"# Fresh Baked Daily
5726
5727Welcome to Sunrise Bakery! We bake fresh bread, pastries, and cakes every morning.
5728
5729::hero-image[src="/images/bakery.jpg" alt="Fresh bread"]
5730
5731::pricing-table
5732| Item | Price |
5733|------|-------|
5734| Sourdough Loaf | $6 |
5735| Croissant | $3.50 |
5736| Birthday Cake | $35 |
5737::
5738
5739::testimonial[author="Sarah M." role="Regular Customer"]
5740The best sourdough in the city. I come here every weekend!
5741::
5742
5743::faq
5744### Do you take custom orders?
5745Yes! Place custom cake orders at least 48 hours in advance.
5746### Are you open on weekends?
5747Saturday 7am-4pm, Sunday 8am-2pm.
5748::
5749
5750::cta[label="Order Online" href="/order" primary]"#;
5751
5752        let block = unknown(
5753            "page",
5754            attrs(&[("route", AttrValue::String("/".into()))]),
5755            content,
5756        );
5757        match resolve_block(block) {
5758            Block::Page { children, .. } => {
5759                // Markdown, HeroImage, PricingTable, Testimonial, Faq, Cta
5760                assert_eq!(children.len(), 6, "children: {children:#?}");
5761                assert!(matches!(&children[0], Block::Markdown { .. }));
5762                assert!(matches!(&children[1], Block::HeroImage { .. }));
5763                assert!(matches!(&children[2], Block::PricingTable { .. }));
5764                assert!(matches!(&children[3], Block::Testimonial { .. }));
5765                assert!(matches!(&children[4], Block::Faq { .. }));
5766                assert!(matches!(&children[5], Block::Cta { .. }));
5767
5768                // Verify no :: leaked into any markdown block
5769                for child in &children {
5770                    if let Block::Markdown { content, .. } = child {
5771                        assert!(
5772                            !content.contains("\n::") && !content.starts_with("::"),
5773                            "Leaked :: markers in markdown: {content}"
5774                        );
5775                    }
5776                }
5777            }
5778            other => panic!("Expected Page, got {other:?}"),
5779        }
5780    }
5781
5782    // -- Embed -------------------------------------------------
5783
5784    #[test]
5785    fn resolve_embed_basic() {
5786        let block = unknown(
5787            "embed",
5788            attrs(&[("src", AttrValue::String("https://www.google.com/maps/embed?pb=123".into()))]),
5789            "",
5790        );
5791        match resolve_block(block) {
5792            Block::Embed {
5793                src, embed_type, width, height, ..
5794            } => {
5795                assert!(src.contains("google.com/maps"));
5796                assert_eq!(embed_type, Some(crate::types::EmbedType::Map));
5797                assert!(width.is_none());
5798                assert!(height.is_none());
5799            }
5800            other => panic!("Expected Embed, got {other:?}"),
5801        }
5802    }
5803
5804    #[test]
5805    fn resolve_embed_youtube_auto_detect() {
5806        let block = unknown(
5807            "embed",
5808            attrs(&[("src", AttrValue::String("https://www.youtube.com/embed/abc123".into()))]),
5809            "",
5810        );
5811        match resolve_block(block) {
5812            Block::Embed { embed_type, .. } => {
5813                assert_eq!(embed_type, Some(crate::types::EmbedType::Video));
5814            }
5815            other => panic!("Expected Embed, got {other:?}"),
5816        }
5817    }
5818
5819    #[test]
5820    fn resolve_embed_explicit_type() {
5821        let block = unknown(
5822            "embed",
5823            attrs(&[
5824                ("src", AttrValue::String("https://example.com/widget".into())),
5825                ("type", AttrValue::String("generic".into())),
5826                ("width", AttrValue::String("600".into())),
5827                ("height", AttrValue::String("300".into())),
5828                ("title", AttrValue::String("My Widget".into())),
5829            ]),
5830            "",
5831        );
5832        match resolve_block(block) {
5833            Block::Embed {
5834                src, embed_type, width, height, title, ..
5835            } => {
5836                assert_eq!(src, "https://example.com/widget");
5837                assert_eq!(embed_type, Some(crate::types::EmbedType::Generic));
5838                assert_eq!(width, Some("600".to_string()));
5839                assert_eq!(height, Some("300".to_string()));
5840                assert_eq!(title, Some("My Widget".to_string()));
5841            }
5842            other => panic!("Expected Embed, got {other:?}"),
5843        }
5844    }
5845
5846    // -- Form --------------------------------------------------
5847
5848    #[test]
5849    fn resolve_form_basic_fields() {
5850        let content = "- Name (text) *\n- Email (email) *\n- Message (textarea)";
5851        let block = unknown("form", Attrs::new(), content);
5852        match resolve_block(block) {
5853            Block::Form { fields, submit_label, .. } => {
5854                assert_eq!(fields.len(), 3);
5855                assert_eq!(fields[0].label, "Name");
5856                assert_eq!(fields[0].field_type, crate::types::FormFieldType::Text);
5857                assert!(fields[0].required);
5858                assert_eq!(fields[1].label, "Email");
5859                assert_eq!(fields[1].field_type, crate::types::FormFieldType::Email);
5860                assert!(fields[1].required);
5861                assert_eq!(fields[2].label, "Message");
5862                assert_eq!(fields[2].field_type, crate::types::FormFieldType::Textarea);
5863                assert!(!fields[2].required);
5864                assert!(submit_label.is_none());
5865            }
5866            other => panic!("Expected Form, got {other:?}"),
5867        }
5868    }
5869
5870    #[test]
5871    fn resolve_form_with_select() {
5872        let content = "- Size (select: Small | Medium | Large) *";
5873        let block = unknown("form", Attrs::new(), content);
5874        match resolve_block(block) {
5875            Block::Form { fields, .. } => {
5876                assert_eq!(fields.len(), 1);
5877                assert_eq!(fields[0].label, "Size");
5878                assert_eq!(fields[0].field_type, crate::types::FormFieldType::Select);
5879                assert_eq!(fields[0].options, vec!["Small", "Medium", "Large"]);
5880                assert!(fields[0].required);
5881            }
5882            other => panic!("Expected Form, got {other:?}"),
5883        }
5884    }
5885
5886    #[test]
5887    fn resolve_form_with_submit_label() {
5888        let block = unknown(
5889            "form",
5890            attrs(&[("submit", AttrValue::String("Request Quote".into()))]),
5891            "- Name (text) *",
5892        );
5893        match resolve_block(block) {
5894            Block::Form { submit_label, .. } => {
5895                assert_eq!(submit_label, Some("Request Quote".to_string()));
5896            }
5897            other => panic!("Expected Form, got {other:?}"),
5898        }
5899    }
5900
5901    #[test]
5902    fn resolve_form_action_method_honeypot() {
5903        let block = unknown(
5904            "form",
5905            attrs(&[
5906                ("action", AttrValue::String("/contact".into())),
5907                ("method", AttrValue::String("post".into())),
5908                ("honeypot", AttrValue::Bool(true)),
5909            ]),
5910            "- Name (text) *",
5911        );
5912        match resolve_block(block) {
5913            Block::Form { action, method, honeypot, .. } => {
5914                assert_eq!(action, Some("/contact".to_string()));
5915                assert_eq!(method, Some("post".to_string()));
5916                assert!(honeypot);
5917            }
5918            other => panic!("Expected Form, got {other:?}"),
5919        }
5920    }
5921
5922    #[test]
5923    fn resolve_hero_image_alt_and_layout() {
5924        let block = unknown(
5925            "hero",
5926            attrs(&[
5927                ("image", AttrValue::String("/logo.png".into())),
5928                ("image-alt", AttrValue::String("CloudSurf".into())),
5929                ("layout", AttrValue::String("stacked".into())),
5930            ]),
5931            "# CloudSurf\n\nInnovate",
5932        );
5933        match resolve_block(block) {
5934            Block::Hero { image, image_alt, layout, .. } => {
5935                assert_eq!(image, Some("/logo.png".to_string()));
5936                assert_eq!(image_alt, Some("CloudSurf".to_string()));
5937                assert_eq!(layout, Some("stacked".to_string()));
5938            }
5939            other => panic!("Expected Hero, got {other:?}"),
5940        }
5941    }
5942
5943    #[test]
5944    fn resolve_hero_transparent_flag() {
5945        let block = unknown(
5946            "hero",
5947            attrs(&[("transparent", AttrValue::Bool(true))]),
5948            "# CloudSurf",
5949        );
5950        match resolve_block(block) {
5951            Block::Hero { transparent, .. } => assert!(transparent),
5952            other => panic!("Expected Hero, got {other:?}"),
5953        }
5954    }
5955
5956    #[test]
5957    fn resolve_banner_headline_subtitle_buttons() {
5958        let content = "# Building something custom?\n\nTell us what you need.\n\n[Start a project](/contact){primary}";
5959        let block = unknown("banner", Attrs::new(), content);
5960        match resolve_block(block) {
5961            Block::Banner { headline, subtitle, buttons, .. } => {
5962                assert_eq!(headline, Some("Building something custom?".to_string()));
5963                assert_eq!(subtitle, Some("Tell us what you need.".to_string()));
5964                assert_eq!(buttons.len(), 1);
5965                assert_eq!(buttons[0].label, "Start a project");
5966                assert_eq!(buttons[0].href, "/contact");
5967                assert!(buttons[0].primary);
5968            }
5969            other => panic!("Expected Banner, got {other:?}"),
5970        }
5971    }
5972
5973    #[test]
5974    fn resolve_product_grid_grouped() {
5975        let content = "### Platforms\n- Surf | /surf.png | https://app.surf | Docs, tasks & apps.\n- Wavesite | /ws.png | https://wave.site | Build sites.\n### Tools\n- Mako | /mako.png | https://mako.app.surf | The AI agent.";
5976        let block = unknown("product-grid", Attrs::new(), content);
5977        match resolve_block(block) {
5978            Block::ProductGrid { groups, .. } => {
5979                assert_eq!(groups.len(), 2);
5980                assert_eq!(groups[0].label, Some("Platforms".to_string()));
5981                assert_eq!(groups[0].items.len(), 2);
5982                assert_eq!(groups[0].items[0].name, "Surf");
5983                assert_eq!(groups[0].items[0].emblem, Some("/surf.png".to_string()));
5984                assert_eq!(groups[0].items[0].href, "https://app.surf");
5985                assert_eq!(groups[0].items[0].tagline, Some("Docs, tasks & apps.".to_string()));
5986                assert_eq!(groups[1].label, Some("Tools".to_string()));
5987                assert_eq!(groups[1].items.len(), 1);
5988            }
5989            other => panic!("Expected ProductGrid, got {other:?}"),
5990        }
5991    }
5992
5993    #[test]
5994    fn resolve_form_measurement_fields() {
5995        let content = "- Full Name (text) *\n- Email (email) *\n- Phone (tel)\n- Event Date (date) *\n- Height (number)\n- Notes (textarea)";
5996        let block = unknown("form", Attrs::new(), content);
5997        match resolve_block(block) {
5998            Block::Form { fields, .. } => {
5999                assert_eq!(fields.len(), 6);
6000                assert_eq!(fields[0].field_type, crate::types::FormFieldType::Text);
6001                assert_eq!(fields[1].field_type, crate::types::FormFieldType::Email);
6002                assert_eq!(fields[2].field_type, crate::types::FormFieldType::Tel);
6003                assert_eq!(fields[3].field_type, crate::types::FormFieldType::Date);
6004                assert_eq!(fields[4].field_type, crate::types::FormFieldType::Number);
6005                assert_eq!(fields[5].field_type, crate::types::FormFieldType::Textarea);
6006                assert_eq!(fields[0].name, "full_name");
6007                assert_eq!(fields[3].name, "event_date");
6008            }
6009            other => panic!("Expected Form, got {other:?}"),
6010        }
6011    }
6012
6013    // -- Gallery -----------------------------------------------
6014
6015    #[test]
6016    fn resolve_gallery_basic() {
6017        let content = "![Suit](suit.jpg) Suits: Classic black tuxedo\n![Vest](vest.jpg) Accessories: Silk vest";
6018        let block = unknown("gallery", Attrs::new(), content);
6019        match resolve_block(block) {
6020            Block::Gallery { items, columns, .. } => {
6021                assert_eq!(items.len(), 2);
6022                assert_eq!(items[0].src, "suit.jpg");
6023                assert_eq!(items[0].alt, Some("Suit".to_string()));
6024                assert_eq!(items[0].category, Some("Suits".to_string()));
6025                assert_eq!(items[0].caption, Some("Classic black tuxedo".to_string()));
6026                assert_eq!(items[1].src, "vest.jpg");
6027                assert_eq!(items[1].category, Some("Accessories".to_string()));
6028                assert_eq!(columns, Some(2));
6029            }
6030            other => panic!("Expected Gallery, got {other:?}"),
6031        }
6032    }
6033
6034    #[test]
6035    fn resolve_gallery_no_categories() {
6036        let content = "![](a.jpg) Photo one\n![](b.jpg) Photo two\n![](c.jpg) Photo three";
6037        let block = unknown("gallery", Attrs::new(), content);
6038        match resolve_block(block) {
6039            Block::Gallery { items, columns, .. } => {
6040                assert_eq!(items.len(), 3);
6041                assert!(items[0].category.is_none());
6042                assert_eq!(items[0].caption, Some("Photo one".to_string()));
6043                assert_eq!(columns, Some(3));
6044            }
6045            other => panic!("Expected Gallery, got {other:?}"),
6046        }
6047    }
6048
6049    #[test]
6050    fn resolve_gallery_empty() {
6051        let block = unknown("gallery", Attrs::new(), "");
6052        match resolve_block(block) {
6053            Block::Gallery { items, .. } => {
6054                assert!(items.is_empty());
6055            }
6056            other => panic!("Expected Gallery, got {other:?}"),
6057        }
6058    }
6059
6060    #[test]
6061    fn resolve_gallery_many_items() {
6062        let content = "![](1.jpg)\n![](2.jpg)\n![](3.jpg)\n![](4.jpg)\n![](5.jpg)\n![](6.jpg)\n![](7.jpg)";
6063        let block = unknown("gallery", Attrs::new(), content);
6064        match resolve_block(block) {
6065            Block::Gallery { items, columns, .. } => {
6066                assert_eq!(items.len(), 7);
6067                assert_eq!(columns, Some(4));
6068            }
6069            other => panic!("Expected Gallery, got {other:?}"),
6070        }
6071    }
6072
6073    // -- Footer ------------------------------------------------
6074
6075    #[test]
6076    fn resolve_footer_full() {
6077        let content = "## Company\n- [About](/about)\n- [Contact](/contact)\n\n## Legal\n- [Privacy](/privacy)\n- [Terms](/terms)\n\n@instagram https://instagram.com/bowties\n@facebook https://facebook.com/bowties\n\n(c) 2026 Bowties Tuxedo. All rights reserved.";
6078        let block = unknown("footer", Attrs::new(), content);
6079        match resolve_block(block) {
6080            Block::Footer {
6081                sections, copyright, social, ..
6082            } => {
6083                assert_eq!(sections.len(), 2);
6084                assert_eq!(sections[0].heading, "Company");
6085                assert_eq!(sections[0].links.len(), 2);
6086                assert_eq!(sections[0].links[0].label, "About");
6087                assert_eq!(sections[0].links[0].href, "/about");
6088                assert_eq!(sections[1].heading, "Legal");
6089                assert_eq!(sections[1].links.len(), 2);
6090                assert_eq!(social.len(), 2);
6091                assert_eq!(social[0].platform, "instagram");
6092                assert_eq!(social[1].platform, "facebook");
6093                assert!(copyright.unwrap().contains("2026 Bowties Tuxedo"));
6094            }
6095            other => panic!("Expected Footer, got {other:?}"),
6096        }
6097    }
6098
6099    #[test]
6100    fn resolve_footer_copyright_only() {
6101        let content = "\u{00a9} 2026 CloudSurf Software LLC";
6102        let block = unknown("footer", Attrs::new(), content);
6103        match resolve_block(block) {
6104            Block::Footer {
6105                sections, copyright, social, ..
6106            } => {
6107                assert!(sections.is_empty());
6108                assert!(social.is_empty());
6109                assert!(copyright.unwrap().contains("2026 CloudSurf"));
6110            }
6111            other => panic!("Expected Footer, got {other:?}"),
6112        }
6113    }
6114
6115    #[test]
6116    fn resolve_footer_empty() {
6117        let block = unknown("footer", Attrs::new(), "");
6118        match resolve_block(block) {
6119            Block::Footer {
6120                sections, copyright, social, ..
6121            } => {
6122                assert!(sections.is_empty());
6123                assert!(copyright.is_none());
6124                assert!(social.is_empty());
6125            }
6126            other => panic!("Expected Footer, got {other:?}"),
6127        }
6128    }
6129
6130    #[test]
6131    fn resolve_footer_plain_text_links() {
6132        let content = "## Hours\n- Mon-Fri 9am-5pm\n- Sat 10am-2pm";
6133        let block = unknown("footer", Attrs::new(), content);
6134        match resolve_block(block) {
6135            Block::Footer { sections, .. } => {
6136                assert_eq!(sections.len(), 1);
6137                assert_eq!(sections[0].heading, "Hours");
6138                assert_eq!(sections[0].links.len(), 2);
6139                assert_eq!(sections[0].links[0].label, "Mon-Fri 9am-5pm");
6140                assert!(sections[0].links[0].href.is_empty());
6141            }
6142            other => panic!("Expected Footer, got {other:?}"),
6143        }
6144    }
6145
6146    // -- New blocks inside Page --------------------------------
6147
6148    #[test]
6149    fn page_container_embed() {
6150        let content = "::embed[src=\"https://www.google.com/maps/embed?pb=123\"]\n\nVisit us!";
6151        let block = unknown(
6152            "page",
6153            attrs(&[("route", AttrValue::String("/".into()))]),
6154            content,
6155        );
6156        match resolve_block(block) {
6157            Block::Page { children, .. } => {
6158                assert_eq!(children.len(), 2, "children: {children:#?}");
6159                assert!(matches!(&children[0], Block::Embed { .. }));
6160                assert!(matches!(&children[1], Block::Markdown { .. }));
6161            }
6162            other => panic!("Expected Page, got {other:?}"),
6163        }
6164    }
6165
6166    #[test]
6167    fn page_container_form() {
6168        let content = "# Request Measurement\n\n::form[submit=\"Book Appointment\"]\n- Name (text) *\n- Phone (tel) *\n::";
6169        let block = unknown(
6170            "page",
6171            attrs(&[("route", AttrValue::String("/".into()))]),
6172            content,
6173        );
6174        match resolve_block(block) {
6175            Block::Page { children, .. } => {
6176                assert_eq!(children.len(), 2, "children: {children:#?}");
6177                assert!(matches!(&children[0], Block::Markdown { .. }));
6178                match &children[1] {
6179                    Block::Form { fields, submit_label, .. } => {
6180                        assert_eq!(fields.len(), 2);
6181                        assert_eq!(submit_label.as_deref(), Some("Book Appointment"));
6182                    }
6183                    other => panic!("Expected Form, got {other:?}"),
6184                }
6185            }
6186            other => panic!("Expected Page, got {other:?}"),
6187        }
6188    }
6189
6190    #[test]
6191    fn page_container_gallery() {
6192        let content = "::gallery\n![Suit](suit.jpg) Suits: Classic\n![Vest](vest.jpg) Accessories: Silk\n::";
6193        let block = unknown(
6194            "page",
6195            attrs(&[("route", AttrValue::String("/".into()))]),
6196            content,
6197        );
6198        match resolve_block(block) {
6199            Block::Page { children, .. } => {
6200                assert_eq!(children.len(), 1, "children: {children:#?}");
6201                match &children[0] {
6202                    Block::Gallery { items, .. } => {
6203                        assert_eq!(items.len(), 2);
6204                    }
6205                    other => panic!("Expected Gallery, got {other:?}"),
6206                }
6207            }
6208            other => panic!("Expected Page, got {other:?}"),
6209        }
6210    }
6211
6212    #[test]
6213    fn page_container_footer() {
6214        let content = "# Welcome\n\n::footer\n## Links\n- [Home](/)\n\n(c) 2026 Test\n::";
6215        let block = unknown(
6216            "page",
6217            attrs(&[("route", AttrValue::String("/".into()))]),
6218            content,
6219        );
6220        match resolve_block(block) {
6221            Block::Page { children, .. } => {
6222                assert_eq!(children.len(), 2, "children: {children:#?}");
6223                assert!(matches!(&children[0], Block::Markdown { .. }));
6224                match &children[1] {
6225                    Block::Footer { sections, copyright, .. } => {
6226                        assert_eq!(sections.len(), 1);
6227                        assert!(copyright.as_ref().unwrap().contains("2026"));
6228                    }
6229                    other => panic!("Expected Footer, got {other:?}"),
6230                }
6231            }
6232            other => panic!("Expected Page, got {other:?}"),
6233        }
6234    }
6235
6236    // -- Hero --------------------------------------------------
6237
6238    #[test]
6239    fn resolve_hero_basic() {
6240        let content = "# Build Something Great\n\nThe fastest way to ship your next project.\n\n[Get Started](/signup){primary}\n[Learn More](/docs)";
6241        let block = unknown("hero", Attrs::new(), content);
6242        match resolve_block(block) {
6243            Block::Hero {
6244                headline,
6245                subtitle,
6246                buttons,
6247                align,
6248                badge,
6249                image,
6250                ..
6251            } => {
6252                assert_eq!(headline, Some("Build Something Great".to_string()));
6253                assert_eq!(
6254                    subtitle,
6255                    Some("The fastest way to ship your next project.".to_string())
6256                );
6257                assert_eq!(buttons.len(), 2);
6258                assert_eq!(buttons[0].label, "Get Started");
6259                assert_eq!(buttons[0].href, "/signup");
6260                assert!(buttons[0].primary);
6261                assert_eq!(buttons[1].label, "Learn More");
6262                assert_eq!(buttons[1].href, "/docs");
6263                assert!(!buttons[1].primary);
6264                assert_eq!(align, "center");
6265                assert!(badge.is_none());
6266                assert!(image.is_none());
6267            }
6268            other => panic!("Expected Hero, got {other:?}"),
6269        }
6270    }
6271
6272    /// Lines consumed into headline/subtitle/buttons must NOT survive in
6273    /// `content` — native renderers draw `content` verbatim below the typed
6274    /// fields, so a full-body copy renders the whole hero twice (iOS
6275    /// duplicate-hero bug, 2026-07-26).
6276    #[test]
6277    fn resolve_hero_consumed_lines_leave_no_content() {
6278        let content = "# Build Something Great\n\nThe fastest way to ship your next project.\n\n[Get Started](/signup){primary}\n[Learn More](/docs)";
6279        let block = unknown("hero", Attrs::new(), content);
6280        match resolve_block(block) {
6281            Block::Hero { content, .. } => assert_eq!(content, ""),
6282            other => panic!("Expected Hero, got {other:?}"),
6283        }
6284    }
6285
6286    /// Free-form lines the field extraction does not claim (text before the
6287    /// headline, or after the buttons) still flow through as `content`.
6288    #[test]
6289    fn resolve_hero_keeps_unconsumed_lines_as_content() {
6290        let content = "# Title\n\nSubtitle line.\n\n[Go](/go){primary}\n\nTrusted by 40 surf schools.";
6291        let block = unknown("hero", Attrs::new(), content);
6292        match resolve_block(block) {
6293            Block::Hero {
6294                headline,
6295                subtitle,
6296                buttons,
6297                content,
6298                ..
6299            } => {
6300                assert_eq!(headline, Some("Title".to_string()));
6301                assert_eq!(subtitle, Some("Subtitle line.".to_string()));
6302                assert_eq!(buttons.len(), 1);
6303                assert_eq!(content, "Trusted by 40 surf schools.");
6304            }
6305            other => panic!("Expected Hero, got {other:?}"),
6306        }
6307    }
6308
6309    /// Banner reuses the hero grammar and consumes every non-blank line, so
6310    /// its `content` must always come back empty (same duplicate-render bug).
6311    #[test]
6312    fn resolve_banner_leaves_no_content() {
6313        let content = "# Ready to ride?\n\nJoin today.\n\n[Sign Up](/signup){primary}";
6314        let block = unknown("banner", Attrs::new(), content);
6315        match resolve_block(block) {
6316            Block::Banner { content, .. } => assert_eq!(content, ""),
6317            other => panic!("Expected Banner, got {other:?}"),
6318        }
6319    }
6320
6321    #[test]
6322    fn resolve_hero_button_external_flags() {
6323        let content = "# H\n\n[Signup](https://x.com){primary external}\n[Docs](https://y.com){external}\n[Home](/){primary}";
6324        let block = unknown("hero", Attrs::new(), content);
6325        match resolve_block(block) {
6326            Block::Hero { buttons, .. } => {
6327                assert_eq!(buttons.len(), 3);
6328                assert!(buttons[0].primary && buttons[0].external);
6329                assert!(!buttons[1].primary && buttons[1].external);
6330                assert!(buttons[2].primary && !buttons[2].external);
6331            }
6332            other => panic!("Expected Hero, got {other:?}"),
6333        }
6334    }
6335
6336    #[test]
6337    fn resolve_product_grid_linkless_card() {
6338        let content = "- Live | | /live | Ships\n- Soon | | - | Not public yet";
6339        let block = unknown("product-grid", Attrs::new(), content);
6340        match resolve_block(block) {
6341            Block::ProductGrid { groups, .. } => {
6342                let items = &groups[0].items;
6343                assert_eq!(items.len(), 2);
6344                assert_eq!(items[0].href, "/live");
6345                assert_eq!(items[1].href, "-");
6346            }
6347            other => panic!("Expected ProductGrid, got {other:?}"),
6348        }
6349    }
6350
6351    #[test]
6352    fn resolve_hero_button_dot_primary() {
6353        // The markdown class form `{.primary}` must mark the button primary,
6354        // same as the bare `{primary}` flag.
6355        let content = "# Title\n\nSub.\n\n[Start free](/signup){.primary}\n[Read the spec](/spec)";
6356        let block = unknown("hero", Attrs::new(), content);
6357        match resolve_block(block) {
6358            Block::Hero { buttons, .. } => {
6359                assert_eq!(buttons.len(), 2);
6360                assert_eq!(buttons[0].label, "Start free");
6361                assert!(buttons[0].primary, "{{.primary}} should be primary");
6362                assert!(!buttons[1].primary);
6363            }
6364            other => panic!("Expected Hero, got {other:?}"),
6365        }
6366    }
6367
6368    #[test]
6369    fn resolve_hero_with_badge_and_image() {
6370        let content = "# Welcome Home\n\nYour new favorite tool.\n\n[Try Free](/free){primary}";
6371        let block = unknown(
6372            "hero",
6373            attrs(&[
6374                ("badge", AttrValue::String("New".into())),
6375                ("image", AttrValue::String("hero.png".into())),
6376                ("align", AttrValue::String("left".into())),
6377            ]),
6378            content,
6379        );
6380        match resolve_block(block) {
6381            Block::Hero {
6382                headline,
6383                badge,
6384                image,
6385                align,
6386                buttons,
6387                ..
6388            } => {
6389                assert_eq!(headline, Some("Welcome Home".to_string()));
6390                assert_eq!(badge, Some("New".to_string()));
6391                assert_eq!(image, Some("hero.png".to_string()));
6392                assert_eq!(align, "left");
6393                assert_eq!(buttons.len(), 1);
6394                assert!(buttons[0].primary);
6395            }
6396            other => panic!("Expected Hero, got {other:?}"),
6397        }
6398    }
6399
6400    // -- Features ----------------------------------------------
6401
6402    #[test]
6403    fn resolve_features_basic() {
6404        let content = "### Fast {icon=zap}\n\nBlazingly fast builds.\n\n### Secure {icon=lock}\n\nEnd-to-end encryption.\n\n### Simple {icon=star}\n\nNo config needed.";
6405        let block = unknown("features", Attrs::new(), content);
6406        match resolve_block(block) {
6407            Block::Features { cards, cols, .. } => {
6408                assert_eq!(cards.len(), 3);
6409                assert_eq!(cards[0].title, "Fast");
6410                assert_eq!(cards[0].icon, Some("zap".to_string()));
6411                assert!(cards[0].body.contains("Blazingly fast"));
6412                assert_eq!(cards[1].title, "Secure");
6413                assert_eq!(cards[1].icon, Some("lock".to_string()));
6414                assert_eq!(cards[2].title, "Simple");
6415                assert_eq!(cards[2].icon, Some("star".to_string()));
6416                assert!(cols.is_none());
6417            }
6418            other => panic!("Expected Features, got {other:?}"),
6419        }
6420    }
6421
6422    #[test]
6423    fn resolve_features_h2_headings() {
6424        // `## ` headings (not just `### `) must start cards — mirrors parse_steps.
6425        let content = "## Typed\nEvery block is validated.\n## Portable\nAn open format you own.\n## AI-native\nSpecs that track themselves.";
6426        let block = unknown("features", Attrs::new(), content);
6427        match resolve_block(block) {
6428            Block::Features { cards, .. } => {
6429                assert_eq!(cards.len(), 3);
6430                assert_eq!(cards[0].title, "Typed");
6431                assert!(cards[0].body.contains("validated"));
6432                assert_eq!(cards[1].title, "Portable");
6433                assert_eq!(cards[2].title, "AI-native");
6434            }
6435            other => panic!("Expected Features, got {other:?}"),
6436        }
6437    }
6438
6439    #[test]
6440    fn resolve_features_with_link() {
6441        let content = "### Docs\n\nComprehensive documentation.\n\n[Read Docs](/docs)";
6442        let block = unknown(
6443            "features",
6444            attrs(&[("cols", AttrValue::String("2".into()))]),
6445            content,
6446        );
6447        match resolve_block(block) {
6448            Block::Features { cards, cols, .. } => {
6449                assert_eq!(cards.len(), 1);
6450                assert_eq!(cards[0].title, "Docs");
6451                assert_eq!(cards[0].link_label, Some("Read Docs".to_string()));
6452                assert_eq!(cards[0].link_href, Some("/docs".to_string()));
6453                assert!(!cards[0].body.contains("[Read Docs]"));
6454                assert_eq!(cols, Some(2));
6455            }
6456            other => panic!("Expected Features, got {other:?}"),
6457        }
6458    }
6459
6460    // -- Steps -------------------------------------------------
6461
6462    #[test]
6463    fn resolve_steps_basic() {
6464        let content = "### Sign Up {time=\"1 min\"}\n\nCreate your account.\n\n### Configure\n\nSet your preferences.\n\n### Deploy {time=\"5 min\"}\n\nShip to production.";
6465        let block = unknown("steps", Attrs::new(), content);
6466        match resolve_block(block) {
6467            Block::Steps { steps, .. } => {
6468                assert_eq!(steps.len(), 3);
6469                assert_eq!(steps[0].title, "Sign Up");
6470                assert_eq!(steps[0].time, Some("1 min".to_string()));
6471                assert!(steps[0].body.contains("Create your account"));
6472                assert_eq!(steps[1].title, "Configure");
6473                assert!(steps[1].time.is_none());
6474                assert_eq!(steps[2].title, "Deploy");
6475                assert_eq!(steps[2].time, Some("5 min".to_string()));
6476            }
6477            other => panic!("Expected Steps, got {other:?}"),
6478        }
6479    }
6480
6481    // -- Stats -------------------------------------------------
6482
6483    #[test]
6484    fn resolve_stats_basic() {
6485        let content = "- 99.9% {label=\"Uptime\" color=\"#22c55e\"}\n- 10K+ {label=\"Users\"}\n- <50ms {label=\"Latency\" color=\"#3b82f6\"}\n- 24/7 {label=\"Support\"}";
6486        let block = unknown("stats", Attrs::new(), content);
6487        match resolve_block(block) {
6488            Block::Stats { items, .. } => {
6489                assert_eq!(items.len(), 4);
6490                assert_eq!(items[0].value, "99.9%");
6491                assert_eq!(items[0].label, "Uptime");
6492                assert_eq!(items[0].color, Some("#22c55e".to_string()));
6493                assert_eq!(items[1].value, "10K+");
6494                assert_eq!(items[1].label, "Users");
6495                assert!(items[1].color.is_none());
6496                assert_eq!(items[2].value, "<50ms");
6497                assert_eq!(items[2].label, "Latency");
6498                assert_eq!(items[2].color, Some("#3b82f6".to_string()));
6499                assert_eq!(items[3].value, "24/7");
6500                assert_eq!(items[3].label, "Support");
6501            }
6502            other => panic!("Expected Stats, got {other:?}"),
6503        }
6504    }
6505
6506    #[test]
6507    fn resolve_stats_pipe_syntax() {
6508        // `value | label` pipe syntax must produce items (color defaults to None).
6509        let content = "- 91 | block types\n- 4 | renderers\n- 0 | lock-in";
6510        let block = unknown("stats", Attrs::new(), content);
6511        match resolve_block(block) {
6512            Block::Stats { items, .. } => {
6513                assert_eq!(items.len(), 3);
6514                assert_eq!(items[0].value, "91");
6515                assert_eq!(items[0].label, "block types");
6516                assert!(items[0].color.is_none());
6517                assert_eq!(items[1].value, "4");
6518                assert_eq!(items[1].label, "renderers");
6519                assert_eq!(items[2].value, "0");
6520                assert_eq!(items[2].label, "lock-in");
6521            }
6522            other => panic!("Expected Stats, got {other:?}"),
6523        }
6524    }
6525
6526    // -- Comparison --------------------------------------------
6527
6528    #[test]
6529    fn resolve_comparison_basic() {
6530        let content = "| Feature | Free | Pro | Team |\n|---|---|---|---|\n| Projects | 3 | Unlimited | Unlimited |\n| Storage | 1GB | 10GB | 100GB |\n| Support | Community | Email | Priority |";
6531        let block = unknown("comparison", Attrs::new(), content);
6532        match resolve_block(block) {
6533            Block::Comparison {
6534                headers,
6535                rows,
6536                highlight,
6537                ..
6538            } => {
6539                assert_eq!(headers, vec!["Feature", "Free", "Pro", "Team"]);
6540                assert_eq!(rows.len(), 3);
6541                assert_eq!(rows[0], vec!["Projects", "3", "Unlimited", "Unlimited"]);
6542                assert_eq!(rows[1][0], "Storage");
6543                assert_eq!(rows[2][3], "Priority");
6544                assert!(highlight.is_none());
6545            }
6546            other => panic!("Expected Comparison, got {other:?}"),
6547        }
6548    }
6549
6550    #[test]
6551    fn resolve_comparison_highlight() {
6552        let content = "| | Basic | Pro |\n|---|---|---|\n| Price | $0 | $12/mo |";
6553        let block = unknown(
6554            "comparison",
6555            attrs(&[("highlight", AttrValue::String("Pro".into()))]),
6556            content,
6557        );
6558        match resolve_block(block) {
6559            Block::Comparison {
6560                headers,
6561                rows,
6562                highlight,
6563                ..
6564            } => {
6565                assert_eq!(headers, vec!["", "Basic", "Pro"]);
6566                assert_eq!(rows.len(), 1);
6567                assert_eq!(highlight, Some("Pro".to_string()));
6568            }
6569            other => panic!("Expected Comparison, got {other:?}"),
6570        }
6571    }
6572
6573    // -- Logo --------------------------------------------------
6574
6575    #[test]
6576    fn resolve_logo_basic() {
6577        let block = unknown(
6578            "logo",
6579            attrs(&[
6580                ("src", AttrValue::String("/logo.svg".into())),
6581                ("alt", AttrValue::String("Acme Inc".into())),
6582                ("size", AttrValue::String("120".into())),
6583            ]),
6584            "",
6585        );
6586        match resolve_block(block) {
6587            Block::Logo {
6588                src, alt, size, ..
6589            } => {
6590                assert_eq!(src, "/logo.svg");
6591                assert_eq!(alt, Some("Acme Inc".to_string()));
6592                assert_eq!(size, Some(120));
6593            }
6594            other => panic!("Expected Logo, got {other:?}"),
6595        }
6596    }
6597
6598    #[test]
6599    fn resolve_logo_defaults() {
6600        let block = unknown(
6601            "logo",
6602            attrs(&[("src", AttrValue::String("brand.png".into()))]),
6603            "",
6604        );
6605        match resolve_block(block) {
6606            Block::Logo {
6607                src, alt, size, ..
6608            } => {
6609                assert_eq!(src, "brand.png");
6610                assert!(alt.is_none());
6611                assert!(size.is_none());
6612            }
6613            other => panic!("Expected Logo, got {other:?}"),
6614        }
6615    }
6616
6617    // -- Toc ---------------------------------------------------
6618
6619    #[test]
6620    fn resolve_toc_default_depth() {
6621        let block = unknown("toc", Attrs::new(), "");
6622        match resolve_block(block) {
6623            Block::Toc {
6624                depth, entries, ..
6625            } => {
6626                assert_eq!(depth, 3);
6627                assert!(entries.is_empty());
6628            }
6629            other => panic!("Expected Toc, got {other:?}"),
6630        }
6631    }
6632
6633    #[test]
6634    fn resolve_toc_custom_depth() {
6635        let block = unknown(
6636            "toc",
6637            attrs(&[("depth", AttrValue::String("2".into()))]),
6638            "",
6639        );
6640        match resolve_block(block) {
6641            Block::Toc { depth, .. } => {
6642                assert_eq!(depth, 2);
6643            }
6644            other => panic!("Expected Toc, got {other:?}"),
6645        }
6646    }
6647
6648    // -- BeforeAfter -----------------------------------------------
6649
6650    #[test]
6651    fn resolve_before_after_basic() {
6652        let content = "### Before\n- Manual | Write everything by hand\n- Slow | Takes hours\n### After\n- Automated | One-click generation\n- Fast | Takes seconds";
6653        let block = unknown(
6654            "before-after",
6655            attrs(&[("transition", AttrValue::String("SurfDoc".into()))]),
6656            content,
6657        );
6658        match resolve_block(block) {
6659            Block::BeforeAfter {
6660                before_items,
6661                after_items,
6662                transition,
6663                ..
6664            } => {
6665                assert_eq!(before_items.len(), 2);
6666                assert_eq!(before_items[0].label, "Manual");
6667                assert_eq!(before_items[0].detail, "Write everything by hand");
6668                assert_eq!(after_items.len(), 2);
6669                assert_eq!(after_items[1].label, "Fast");
6670                assert_eq!(transition, Some("SurfDoc".to_string()));
6671            }
6672            other => panic!("Expected BeforeAfter, got {other:?}"),
6673        }
6674    }
6675
6676    #[test]
6677    fn resolve_before_after_h2_plain_bullets() {
6678        // `## Before/After` headings, plain bullets (no `| detail`), `---` divider.
6679        let content =
6680            "## Before\n- Stale docs\n- Manual updates\n---\n## After\n- Living specs\n- Auto-tracked";
6681        let block = unknown(
6682            "before-after",
6683            attrs(&[("transition", AttrValue::String("with SurfDoc".into()))]),
6684            content,
6685        );
6686        match resolve_block(block) {
6687            Block::BeforeAfter {
6688                before_items,
6689                after_items,
6690                ..
6691            } => {
6692                assert_eq!(before_items.len(), 2);
6693                assert_eq!(before_items[0].label, "Stale docs");
6694                assert_eq!(before_items[0].detail, "");
6695                assert_eq!(after_items.len(), 2);
6696                assert_eq!(after_items[0].label, "Living specs");
6697            }
6698            other => panic!("Expected BeforeAfter, got {other:?}"),
6699        }
6700    }
6701
6702    #[test]
6703    fn resolve_before_after_no_transition() {
6704        let content = "### Before\n- Old | Legacy way\n### After\n- New | Modern way";
6705        let block = unknown("before-after", Attrs::new(), content);
6706        match resolve_block(block) {
6707            Block::BeforeAfter {
6708                before_items,
6709                after_items,
6710                transition,
6711                ..
6712            } => {
6713                assert_eq!(before_items.len(), 1);
6714                assert_eq!(after_items.len(), 1);
6715                assert_eq!(transition, None);
6716            }
6717            other => panic!("Expected BeforeAfter, got {other:?}"),
6718        }
6719    }
6720
6721    // -- Pipeline --------------------------------------------------
6722
6723    #[test]
6724    fn resolve_pipeline_basic() {
6725        let content = "Phone | User's device\nAI Chat | Natural language\nSurfDoc | Structured output";
6726        let block = unknown("pipeline", Attrs::new(), content);
6727        match resolve_block(block) {
6728            Block::Pipeline { steps, .. } => {
6729                assert_eq!(steps.len(), 3);
6730                assert_eq!(steps[0].label, "Phone");
6731                assert_eq!(steps[0].description, Some("User's device".to_string()));
6732                assert_eq!(steps[2].label, "SurfDoc");
6733            }
6734            other => panic!("Expected Pipeline, got {other:?}"),
6735        }
6736    }
6737
6738    #[test]
6739    fn resolve_pipeline_no_desc() {
6740        let content = "Step 1\nStep 2\nStep 3";
6741        let block = unknown("pipeline", Attrs::new(), content);
6742        match resolve_block(block) {
6743            Block::Pipeline { steps, .. } => {
6744                assert_eq!(steps.len(), 3);
6745                assert_eq!(steps[0].label, "Step 1");
6746                assert_eq!(steps[0].description, None);
6747            }
6748            other => panic!("Expected Pipeline, got {other:?}"),
6749        }
6750    }
6751
6752    // -- Section ---------------------------------------------------
6753
6754    #[test]
6755    fn resolve_section_with_headline() {
6756        let content = "## Why SurfDoc?\nThe future of documents\n\nSome body text here.";
6757        let block = unknown(
6758            "section",
6759            attrs(&[("bg", AttrValue::String("muted".into()))]),
6760            content,
6761        );
6762        match resolve_block(block) {
6763            Block::Section {
6764                bg,
6765                headline,
6766                subtitle,
6767                children,
6768                ..
6769            } => {
6770                assert_eq!(bg, Some("muted".to_string()));
6771                assert_eq!(headline, Some("Why SurfDoc?".to_string()));
6772                assert_eq!(subtitle, Some("The future of documents".to_string()));
6773                assert!(!children.is_empty());
6774            }
6775            other => panic!("Expected Section, got {other:?}"),
6776        }
6777    }
6778
6779    #[test]
6780    fn resolve_section_bg_attr() {
6781        let content = "## Features";
6782        let block = unknown(
6783            "section",
6784            attrs(&[("bg", AttrValue::String("dark".into()))]),
6785            content,
6786        );
6787        match resolve_block(block) {
6788            Block::Section { bg, headline, .. } => {
6789                assert_eq!(bg, Some("dark".to_string()));
6790                assert_eq!(headline, Some("Features".to_string()));
6791            }
6792            other => panic!("Expected Section, got {other:?}"),
6793        }
6794    }
6795
6796    // -- ProductCard -----------------------------------------------
6797
6798    #[test]
6799    fn resolve_product_card_full() {
6800        let content = "## Surf Browser\nNative SurfDoc viewer\n\nRender .surf files beautifully.\n\n- Fast rendering\n- Dark mode\n- Offline support\n\n[Download](/download)";
6801        let block = unknown(
6802            "product-card",
6803            attrs(&[
6804                ("badge", AttrValue::String("Available".into())),
6805                ("badge-color", AttrValue::String("green".into())),
6806            ]),
6807            content,
6808        );
6809        match resolve_block(block) {
6810            Block::ProductCard {
6811                title,
6812                subtitle,
6813                badge,
6814                badge_color,
6815                body,
6816                features,
6817                cta_label,
6818                cta_href,
6819                ..
6820            } => {
6821                assert_eq!(title, "Surf Browser");
6822                assert_eq!(subtitle, Some("Native SurfDoc viewer".to_string()));
6823                assert_eq!(badge, Some("Available".to_string()));
6824                assert_eq!(badge_color, Some("green".to_string()));
6825                assert!(body.contains("Render .surf files"));
6826                assert_eq!(features.len(), 3);
6827                assert_eq!(features[0], "Fast rendering");
6828                assert_eq!(cta_label, Some("Download".to_string()));
6829                assert_eq!(cta_href, Some("/download".to_string()));
6830            }
6831            other => panic!("Expected ProductCard, got {other:?}"),
6832        }
6833    }
6834
6835    #[test]
6836    fn resolve_product_card_minimal() {
6837        let content = "## Simple Product\n\n- One feature";
6838        let block = unknown("product-card", Attrs::new(), content);
6839        match resolve_block(block) {
6840            Block::ProductCard {
6841                title,
6842                subtitle,
6843                badge,
6844                features,
6845                cta_label,
6846                ..
6847            } => {
6848                assert_eq!(title, "Simple Product");
6849                assert_eq!(subtitle, None);
6850                assert_eq!(badge, None);
6851                assert_eq!(features.len(), 1);
6852                assert_eq!(cta_label, None);
6853            }
6854            other => panic!("Expected ProductCard, got {other:?}"),
6855        }
6856    }
6857
6858    #[test]
6859    fn resolve_product_card_attrs() {
6860        // Title/subtitle/CTA as attrs; body + features from content.
6861        let content = "For growing software teams.\n- 5 seats included\n- 10 workspaces";
6862        let block = unknown(
6863            "product-card",
6864            attrs(&[
6865                ("title", AttrValue::String("Team".into())),
6866                ("subtitle", AttrValue::String("$6.99/seat".into())),
6867                ("badge", AttrValue::String("Popular".into())),
6868                ("cta-label", AttrValue::String("Start trial".into())),
6869                ("cta-href", AttrValue::String("/signup".into())),
6870            ]),
6871            content,
6872        );
6873        match resolve_block(block) {
6874            Block::ProductCard {
6875                title,
6876                subtitle,
6877                badge,
6878                body,
6879                features,
6880                cta_label,
6881                cta_href,
6882                ..
6883            } => {
6884                assert_eq!(title, "Team");
6885                assert_eq!(subtitle, Some("$6.99/seat".to_string()));
6886                assert_eq!(badge, Some("Popular".to_string()));
6887                assert!(body.contains("growing software teams"));
6888                assert_eq!(features.len(), 2);
6889                assert_eq!(features[0], "5 seats included");
6890                assert_eq!(cta_label, Some("Start trial".to_string()));
6891                assert_eq!(cta_href, Some("/signup".to_string()));
6892            }
6893            other => panic!("Expected ProductCard, got {other:?}"),
6894        }
6895    }
6896
6897    // ---- App description block tests ----
6898
6899    #[test]
6900    fn resolve_list_with_filters() {
6901        let a = attrs(&[
6902            ("source", AttrValue::String("/api/tasks".into())),
6903            ("display", AttrValue::String("card".into())),
6904        ]);
6905        let content = "## {= title =}\nStatus: {= status =}\n\nfilter: status\nfilter: priority\nsort: created_at desc";
6906        let block = unknown("list", a, content);
6907        match resolve_block(block) {
6908            Block::List {
6909                source,
6910                display,
6911                filters,
6912                sort,
6913                item_template,
6914                ..
6915            } => {
6916                assert_eq!(source, "/api/tasks");
6917                assert_eq!(display, ListDisplay::Card);
6918                assert_eq!(filters.len(), 2);
6919                assert_eq!(filters[0].field, "status");
6920                assert_eq!(filters[1].field, "priority");
6921                assert!(sort.is_some());
6922                let s = sort.unwrap();
6923                assert_eq!(s.field, "created_at");
6924                assert!(s.descending);
6925                assert!(item_template.contains("{= title =}"));
6926            }
6927            other => panic!("Expected List, got {other:?}"),
6928        }
6929    }
6930
6931    #[test]
6932    fn resolve_board() {
6933        let a = attrs(&[
6934            ("source", AttrValue::String("/api/tasks/board".into())),
6935        ]);
6936        let content = "columns: To Do | In Progress | Done\ncard-template: {= title =} @{= assignee =}";
6937        let block = unknown("board", a, content);
6938        match resolve_block(block) {
6939            Block::Board {
6940                source,
6941                columns,
6942                card_template,
6943                ..
6944            } => {
6945                assert_eq!(source, "/api/tasks/board");
6946                assert_eq!(columns, vec!["To Do", "In Progress", "Done"]);
6947                assert_eq!(card_template.as_deref(), Some("{= title =} @{= assignee =}"));
6948            }
6949            other => panic!("Expected Board, got {other:?}"),
6950        }
6951    }
6952
6953    #[test]
6954    fn resolve_action_form() {
6955        let a = attrs(&[
6956            ("method", AttrValue::String("post".into())),
6957            ("target", AttrValue::String("/api/tasks".into())),
6958            ("label", AttrValue::String("Add Task".into())),
6959        ]);
6960        let content = "- Name (text) *\n- Priority (select: Low | Medium | High)";
6961        let block = unknown("action", a, content);
6962        match resolve_block(block) {
6963            Block::Action {
6964                method,
6965                target,
6966                label,
6967                fields,
6968                ..
6969            } => {
6970                assert_eq!(method, HttpMethod::Post);
6971                assert_eq!(target, "/api/tasks");
6972                assert_eq!(label, "Add Task");
6973                assert_eq!(fields.len(), 2);
6974                assert_eq!(fields[0].label, "Name");
6975                assert!(fields[0].required);
6976                assert_eq!(fields[0].field_type, FormFieldType::Text);
6977                assert_eq!(fields[1].label, "Priority");
6978                assert_eq!(fields[1].field_type, FormFieldType::Select);
6979                assert_eq!(fields[1].options, vec!["Low", "Medium", "High"]);
6980            }
6981            other => panic!("Expected Action, got {other:?}"),
6982        }
6983    }
6984
6985    #[test]
6986    fn resolve_filter_bar() {
6987        let a = attrs(&[
6988            ("target", AttrValue::String("#task-board".into())),
6989        ]);
6990        let content = "- Status (select: All | To Do | In Progress | Done)\n- Priority (select: All | Low | Medium | High)";
6991        let block = unknown("filter-bar", a, content);
6992        match resolve_block(block) {
6993            Block::FilterBar {
6994                target_selector,
6995                fields,
6996                ..
6997            } => {
6998                assert_eq!(target_selector, "#task-board");
6999                assert_eq!(fields.len(), 2);
7000                assert_eq!(fields[0].label, "Status");
7001                assert_eq!(fields[0].options, vec!["All", "To Do", "In Progress", "Done"]);
7002                assert_eq!(fields[1].label, "Priority");
7003                assert_eq!(fields[1].name, "priority");
7004            }
7005            other => panic!("Expected FilterBar, got {other:?}"),
7006        }
7007    }
7008
7009    #[test]
7010    fn resolve_search() {
7011        let a = attrs(&[
7012            ("source", AttrValue::String("/api/research/search".into())),
7013            ("placeholder", AttrValue::String("Search articles...".into())),
7014        ]);
7015        let block = unknown("search", a, "");
7016        match resolve_block(block) {
7017            Block::Search {
7018                source,
7019                placeholder,
7020                ..
7021            } => {
7022                assert_eq!(source, "/api/research/search");
7023                assert_eq!(placeholder.as_deref(), Some("Search articles..."));
7024            }
7025            other => panic!("Expected Search, got {other:?}"),
7026        }
7027    }
7028
7029    #[test]
7030    fn resolve_dashboard_with_refresh() {
7031        let a = attrs(&[
7032            ("source", AttrValue::String("/api/grow/metrics".into())),
7033            ("refresh", AttrValue::Number(60.0)),
7034        ]);
7035        let block = unknown("dashboard", a, "");
7036        match resolve_block(block) {
7037            Block::Dashboard {
7038                source,
7039                refresh,
7040                ..
7041            } => {
7042                assert_eq!(source, "/api/grow/metrics");
7043                assert_eq!(refresh, Some(60));
7044            }
7045            other => panic!("Expected Dashboard, got {other:?}"),
7046        }
7047    }
7048
7049    #[test]
7050    fn resolve_chat_input() {
7051        let a = attrs(&[
7052            ("action", AttrValue::String("/chat/message".into())),
7053            ("placeholder", AttrValue::String("Ask anything...".into())),
7054        ]);
7055        let content = "modes: Research | Plan | Build";
7056        let block = unknown("chat-input", a, content);
7057        match resolve_block(block) {
7058            Block::ChatInput {
7059                action,
7060                placeholder,
7061                modes,
7062                ..
7063            } => {
7064                assert_eq!(action, "/chat/message");
7065                assert_eq!(placeholder.as_deref(), Some("Ask anything..."));
7066                assert_eq!(modes, vec!["Research", "Plan", "Build"]);
7067            }
7068            other => panic!("Expected ChatInput, got {other:?}"),
7069        }
7070    }
7071
7072    #[test]
7073    fn resolve_feed_streaming() {
7074        let a = attrs(&[
7075            ("source", AttrValue::String("/api/chat/messages".into())),
7076            ("stream", AttrValue::Bool(true)),
7077        ]);
7078        let block = unknown("feed", a, "");
7079        match resolve_block(block) {
7080            Block::Feed {
7081                source,
7082                stream,
7083                ..
7084            } => {
7085                assert_eq!(source, "/api/chat/messages");
7086                assert!(stream);
7087            }
7088            other => panic!("Expected Feed, got {other:?}"),
7089        }
7090    }
7091
7092    #[test]
7093    fn resolve_editor() {
7094        let a = attrs(&[
7095            ("source", AttrValue::String("/api/docs/123".into())),
7096            ("lang", AttrValue::String("surfdoc".into())),
7097            ("preview", AttrValue::Bool(true)),
7098        ]);
7099        let block = unknown("editor", a, "");
7100        match resolve_block(block) {
7101            Block::Editor {
7102                source,
7103                lang,
7104                preview,
7105                ..
7106            } => {
7107                assert_eq!(source.as_deref(), Some("/api/docs/123"));
7108                assert_eq!(lang.as_deref(), Some("surfdoc"));
7109                assert!(preview);
7110            }
7111            other => panic!("Expected Editor, got {other:?}"),
7112        }
7113    }
7114
7115    #[test]
7116    fn resolve_chart() {
7117        let a = attrs(&[
7118            ("type", AttrValue::String("line".into())),
7119            ("source", AttrValue::String("/api/grow/metrics".into())),
7120            ("period", AttrValue::String("30d".into())),
7121        ]);
7122        let block = unknown("chart", a, "");
7123        match resolve_block(block) {
7124            Block::Chart {
7125                chart_type,
7126                source,
7127                period,
7128                ..
7129            } => {
7130                assert_eq!(chart_type, ChartType::Line);
7131                assert_eq!(source, "/api/grow/metrics");
7132                assert_eq!(period.as_deref(), Some("30d"));
7133            }
7134            other => panic!("Expected Chart, got {other:?}"),
7135        }
7136    }
7137
7138    /// L1: every chart type alias parses to the right `ChartType`.
7139    #[test]
7140    fn resolve_chart_all_types() {
7141        for (s, want) in [
7142            ("line", ChartType::Line),
7143            ("bar", ChartType::Bar),
7144            ("pie", ChartType::Pie),
7145            ("area", ChartType::Area),
7146            ("scatter", ChartType::Scatter),
7147            ("donut", ChartType::Donut),
7148            ("doughnut", ChartType::Donut),
7149            ("stacked-bar", ChartType::StackedBar),
7150            ("stackedbar", ChartType::StackedBar),
7151            ("radar", ChartType::Radar),
7152            ("spider", ChartType::Radar),
7153        ] {
7154            let a = attrs(&[("type", AttrValue::String(s.into()))]);
7155            match resolve_block(unknown("chart", a, "")) {
7156                Block::Chart { chart_type, .. } => assert_eq!(chart_type, want, "type={s}"),
7157                other => panic!("Expected Chart for {s}, got {other:?}"),
7158            }
7159        }
7160    }
7161
7162    /// L2: inline pipe-table data parses into aligned categories + series.
7163    #[test]
7164    fn resolve_chart_inline_data() {
7165        let a = attrs(&[
7166            ("type", AttrValue::String("line".into())),
7167            ("title", AttrValue::String("Revenue".into())),
7168        ]);
7169        let body = "Month | Web | Mobile\nJan | 120 | 80\nFeb | 150 | 95\nMar | 170 | 110";
7170        match resolve_block(unknown("chart", a, body)) {
7171            Block::Chart { title, data, .. } => {
7172                assert_eq!(title.as_deref(), Some("Revenue"));
7173                let d = data.expect("inline data parsed");
7174                assert_eq!(d.categories, vec!["Jan", "Feb", "Mar"]);
7175                assert_eq!(d.series.len(), 2);
7176                assert_eq!(d.series[0].name, "Web");
7177                assert_eq!(d.series[0].values, vec![120.0, 150.0, 170.0]);
7178                assert_eq!(d.series[1].name, "Mobile");
7179                assert_eq!(d.series[1].values, vec![80.0, 95.0, 110.0]);
7180            }
7181            other => panic!("Expected Chart, got {other:?}"),
7182        }
7183    }
7184
7185    /// L2: messy cells ($, %, commas) parse numerically; separator rows skipped.
7186    #[test]
7187    fn resolve_chart_data_cleans_cells() {
7188        let a = attrs(&[("type", AttrValue::String("bar".into()))]);
7189        let body = "Q | Sales\n|---|---|\nQ1 | $1,200\nQ2 | 95%\nQ3 | n/a";
7190        match resolve_block(unknown("chart", a, body)) {
7191            Block::Chart { data, .. } => {
7192                let d = data.expect("inline data parsed");
7193                assert_eq!(d.categories, vec!["Q1", "Q2", "Q3"]);
7194                assert_eq!(d.series[0].values, vec![1200.0, 95.0, 0.0]);
7195            }
7196            other => panic!("Expected Chart, got {other:?}"),
7197        }
7198    }
7199
7200    /// L2: an empty body (or <2 columns) leaves `data` None → mount-point fallback.
7201    #[test]
7202    fn resolve_chart_missing_data_falls_back() {
7203        let a = attrs(&[
7204            ("type", AttrValue::String("pie".into())),
7205            ("source", AttrValue::String("/api/metrics".into())),
7206        ]);
7207        match resolve_block(unknown("chart", a, "")) {
7208            Block::Chart { data, source, .. } => {
7209                assert!(data.is_none(), "empty body must not produce inline data");
7210                assert_eq!(source, "/api/metrics");
7211            }
7212            other => panic!("Expected Chart, got {other:?}"),
7213        }
7214
7215        // A single-column body (no series) is also not usable inline data.
7216        let a = attrs(&[("type", AttrValue::String("pie".into()))]);
7217        match resolve_block(unknown("chart", a, "Label\nA\nB")) {
7218            Block::Chart { data, .. } => assert!(data.is_none()),
7219            other => panic!("Expected Chart, got {other:?}"),
7220        }
7221    }
7222
7223    #[test]
7224    fn resolve_split_pane() {
7225        let a = attrs(&[
7226            ("ratio", AttrValue::String("60:40".into())),
7227        ]);
7228        let block = unknown("split-pane", a, "");
7229        match resolve_block(block) {
7230            Block::SplitPane { ratio, .. } => {
7231                assert_eq!(ratio, "60:40");
7232            }
7233            other => panic!("Expected SplitPane, got {other:?}"),
7234        }
7235    }
7236
7237    #[test]
7238    fn source_validation_blocks_javascript() {
7239        let a = attrs(&[
7240            ("source", AttrValue::String("javascript:alert(1)".into())),
7241        ]);
7242        let block = unknown("list", a, "");
7243        match resolve_block(block) {
7244            Block::List { source, .. } => {
7245                assert!(source.is_empty(), "javascript: URLs must be rejected");
7246            }
7247            other => panic!("Expected List, got {other:?}"),
7248        }
7249    }
7250
7251    #[test]
7252    fn source_validation_blocks_external_url() {
7253        let a = attrs(&[
7254            ("source", AttrValue::String("https://evil.com/steal".into())),
7255        ]);
7256        let block = unknown("search", a, "");
7257        match resolve_block(block) {
7258            Block::Search { source, .. } => {
7259                assert!(source.is_empty(), "external URLs must be rejected");
7260            }
7261            other => panic!("Expected Search, got {other:?}"),
7262        }
7263    }
7264
7265    #[test]
7266    fn source_validation_blocks_path_traversal() {
7267        let a = attrs(&[
7268            ("source", AttrValue::String("/api/../../../etc/passwd".into())),
7269        ]);
7270        let block = unknown("feed", a, "");
7271        match resolve_block(block) {
7272            Block::Feed { source, .. } => {
7273                assert!(source.is_empty(), "path traversal must be rejected");
7274            }
7275            other => panic!("Expected Feed, got {other:?}"),
7276        }
7277    }
7278
7279    #[test]
7280    fn parse_list_from_full_surf() {
7281        let source = "::list[source=\"/api/tasks\" display=card]\n## {= title =}\nfilter: status\nsort: created_at desc\n::";
7282        let result = crate::parse(source);
7283        assert!(result.diagnostics.is_empty(), "Diagnostics: {:?}", result.diagnostics);
7284        let has_list = result.doc.blocks.iter().any(|b| matches!(b, Block::List { .. }));
7285        assert!(has_list, "Should contain a List block");
7286    }
7287
7288    #[test]
7289    fn parse_board_from_full_surf() {
7290        let source = "::board[source=\"/api/tasks/board\"]\ncolumns: To Do | In Progress | Done\n::";
7291        let result = crate::parse(source);
7292        let has_board = result.doc.blocks.iter().any(|b| matches!(b, Block::Board { .. }));
7293        assert!(has_board, "Should contain a Board block");
7294    }
7295
7296    #[test]
7297    fn parse_action_from_full_surf() {
7298        let source = "::action[method=post target=\"/api/tasks\" label=\"Add Task\"]\n- Name (text) *\n- Priority (select: Low | Medium | High)\n::";
7299        let result = crate::parse(source);
7300        let has_action = result.doc.blocks.iter().any(|b| matches!(b, Block::Action { .. }));
7301        assert!(has_action, "Should contain an Action block");
7302    }
7303
7304    #[test]
7305    fn parse_filter_bar_from_full_surf() {
7306        let source = "::filter-bar[target=\"#tasks\"]\n- Status (select: All | To Do | Done)\n::";
7307        let result = crate::parse(source);
7308        let has_filter = result.doc.blocks.iter().any(|b| matches!(b, Block::FilterBar { .. }));
7309        assert!(has_filter, "Should contain a FilterBar block");
7310    }
7311
7312    #[test]
7313    fn parse_booking_services_and_days() {
7314        let source = "::booking[title=\"Book\" service-label=\"Service\"]\n\
7315            service: Intro Call | 15 min | Free\n\
7316            service: Strategy | 45 min | $120\n\
7317            day: 2026-07-06 | 9:00 AM, 10:00 AM\n\
7318            day: 2026-07-07 | full\n::";
7319        let result = crate::parse(source);
7320        let booking = result
7321            .doc
7322            .blocks
7323            .iter()
7324            .find_map(|b| match b {
7325                Block::Booking { services, days, .. } => Some((services, days)),
7326                _ => None,
7327            })
7328            .expect("Should contain a Booking block");
7329        assert_eq!(booking.0.len(), 2, "two services");
7330        assert_eq!(booking.0[0].duration.as_deref(), Some("15 min"));
7331        assert_eq!(booking.1.len(), 2, "two days");
7332        assert_eq!(booking.1[0].slots.len(), 2, "first day has two slots");
7333        assert!(booking.1[1].slots.is_empty(), "`full` day has no slots");
7334    }
7335
7336    #[test]
7337    fn parse_store_items_and_categories() {
7338        let source = "::store[title=\"Shop\" currency=\"$\"]\n\
7339            category: Ceramics\n\
7340            item: Mug | 24 | Hand-thrown | Bestseller\n\
7341            category: Home\n\
7342            item: Throw | 89 | Wool\n::";
7343        let result = crate::parse(source);
7344        let items = result
7345            .doc
7346            .blocks
7347            .iter()
7348            .find_map(|b| match b {
7349                Block::Store { items, .. } => Some(items),
7350                _ => None,
7351            })
7352            .expect("Should contain a Store block");
7353        assert_eq!(items.len(), 2);
7354        assert_eq!(items[0].category.as_deref(), Some("Ceramics"));
7355        assert_eq!(items[0].price, "24");
7356        assert_eq!(items[0].badge.as_deref(), Some("Bestseller"));
7357        assert_eq!(items[1].category.as_deref(), Some("Home"));
7358    }
7359
7360    #[test]
7361    fn parse_row_block() {
7362        let source = "::row[icon=sparkle, href=\"/wiki/test\"]\nTest Title\nA description\n::";
7363        let result = crate::parse(source);
7364        let has_row = result.doc.blocks.iter().any(|b| matches!(b, Block::Row { .. }));
7365        assert!(has_row, "Should contain a Row block");
7366    }
7367
7368    #[test]
7369    fn parse_row_loading_state() {
7370        let source = "::row[icon=book, state=loading]\n::\n";
7371        let result = crate::parse(source);
7372        let has_row = result.doc.blocks.iter().any(|b| matches!(b, Block::Row { state: RowState::Loading, .. }));
7373        assert!(has_row, "Should contain a Row block with loading state");
7374    }
7375
7376    #[test]
7377    fn parse_infocard_block() {
7378        let source = "::infocard[intent=who, image=\"https://example.com/img.jpg\"]\n# Morgan Freeman\nAmerican actor\n\nSummary text here.\n\nBorn: June 1, 1937\nKnown for: Shawshank\n::";
7379        let result = crate::parse(source);
7380        let has_card = result.doc.blocks.iter().any(|b| matches!(b, Block::InfoCard { .. }));
7381        assert!(has_card, "Should contain an InfoCard block");
7382    }
7383
7384    // -- Route with handler ------------------------------------------
7385
7386    #[test]
7387    fn resolve_route_without_handler() {
7388        let a = attrs(&[
7389            ("method", AttrValue::String("GET".into())),
7390            ("path", AttrValue::String("/api/tasks".into())),
7391        ]);
7392        let block = unknown("route", a, "auth: required\nreturns: list(Task)");
7393        match resolve_block(block) {
7394            Block::Route { method, path, auth, returns, handler, .. } => {
7395                assert_eq!(method, HttpMethod::Get);
7396                assert_eq!(path, "/api/tasks");
7397                assert_eq!(auth, Some("required".to_string()));
7398                assert_eq!(returns, Some("list(Task)".to_string()));
7399                assert!(handler.is_none());
7400            }
7401            other => panic!("Expected Route, got {other:?}"),
7402        }
7403    }
7404
7405    #[test]
7406    fn resolve_route_with_handler() {
7407        let a = attrs(&[
7408            ("method", AttrValue::String("POST".into())),
7409            ("path", AttrValue::String("/api/book".into())),
7410        ]);
7411        let content = "auth: required\n```rust\nasync fn handle(db: &PgPool) -> Result<Redirect> {\n    Ok(Redirect::to(\"/\"))\n}\n```";
7412        let block = unknown("route", a, content);
7413        match resolve_block(block) {
7414            Block::Route { method, path, auth, handler, .. } => {
7415                assert_eq!(method, HttpMethod::Post);
7416                assert_eq!(path, "/api/book");
7417                assert_eq!(auth, Some("required".to_string()));
7418                assert!(handler.is_some());
7419                let h = handler.unwrap();
7420                assert!(h.contains("async fn handle"));
7421                assert!(h.contains("Redirect::to"));
7422            }
7423            other => panic!("Expected Route, got {other:?}"),
7424        }
7425    }
7426
7427    #[test]
7428    fn parse_route_with_handler_from_full_surf() {
7429        let source = "::route[method=POST path=\"/api/book\"]\nauth: required\n```rust\nasync fn handle() -> Result<()> { Ok(()) }\n```\n::";
7430        let result = crate::parse(source);
7431        assert!(result.diagnostics.is_empty(), "Diagnostics: {:?}", result.diagnostics);
7432        let route = result.doc.blocks.iter().find(|b| matches!(b, Block::Route { .. }));
7433        assert!(route.is_some(), "Should contain a Route block");
7434        if let Some(Block::Route { handler, .. }) = route {
7435            assert!(handler.is_some(), "Route should have a handler");
7436        }
7437    }
7438
7439    // -- Schema ------------------------------------------------------
7440
7441    #[test]
7442    fn resolve_schema_basic() {
7443        let a = attrs(&[("name", AttrValue::String("bookings".into()))]);
7444        let content = "- id (uuid) primary\n- name (text) required\n- email (text) required unique\n- slot_id (uuid) required\n- created_at (timestamp) default:now()";
7445        let block = unknown("schema", a, content);
7446        match resolve_block(block) {
7447            Block::Schema { name, fields, .. } => {
7448                assert_eq!(name, "bookings");
7449                assert_eq!(fields.len(), 5);
7450                assert_eq!(fields[0].name, "id");
7451                assert_eq!(fields[0].field_type, ModelFieldType::Uuid);
7452                assert_eq!(fields[0].constraints, vec![FieldConstraint::Primary]);
7453                assert_eq!(fields[2].name, "email");
7454                assert_eq!(fields[2].constraints, vec![FieldConstraint::Required, FieldConstraint::Unique]);
7455                assert_eq!(fields[4].field_type, ModelFieldType::Datetime);
7456                assert_eq!(fields[4].constraints, vec![FieldConstraint::Default("now()".to_string())]);
7457            }
7458            other => panic!("Expected Schema, got {other:?}"),
7459        }
7460    }
7461
7462    #[test]
7463    fn parse_schema_field_type_base_types() {
7464        assert_eq!(parse_schema_field_type("uuid").unwrap(), ModelFieldType::Uuid);
7465        assert_eq!(parse_schema_field_type("string").unwrap(), ModelFieldType::String);
7466        assert_eq!(parse_schema_field_type("text").unwrap(), ModelFieldType::Text);
7467        assert_eq!(parse_schema_field_type("int").unwrap(), ModelFieldType::Int);
7468        assert_eq!(parse_schema_field_type("float").unwrap(), ModelFieldType::Float);
7469        assert_eq!(parse_schema_field_type("bool").unwrap(), ModelFieldType::Bool);
7470        assert_eq!(parse_schema_field_type("datetime").unwrap(), ModelFieldType::Datetime);
7471        assert_eq!(parse_schema_field_type("json").unwrap(), ModelFieldType::Json);
7472        assert_eq!(parse_schema_field_type("money").unwrap(), ModelFieldType::Money);
7473        assert_eq!(parse_schema_field_type("image").unwrap(), ModelFieldType::Image);
7474        assert_eq!(parse_schema_field_type("email").unwrap(), ModelFieldType::Email);
7475        assert_eq!(parse_schema_field_type("url").unwrap(), ModelFieldType::Url);
7476    }
7477
7478    #[test]
7479    fn parse_schema_field_type_aliases() {
7480        assert_eq!(parse_schema_field_type("str").unwrap(), ModelFieldType::String);
7481        assert_eq!(parse_schema_field_type("varchar").unwrap(), ModelFieldType::String);
7482        assert_eq!(parse_schema_field_type("integer").unwrap(), ModelFieldType::Int);
7483        assert_eq!(parse_schema_field_type("i64").unwrap(), ModelFieldType::Int);
7484        assert_eq!(parse_schema_field_type("f64").unwrap(), ModelFieldType::Float);
7485        assert_eq!(parse_schema_field_type("double").unwrap(), ModelFieldType::Float);
7486        assert_eq!(parse_schema_field_type("boolean").unwrap(), ModelFieldType::Bool);
7487        assert_eq!(parse_schema_field_type("timestamp").unwrap(), ModelFieldType::Datetime);
7488        assert_eq!(parse_schema_field_type("jsonb").unwrap(), ModelFieldType::Json);
7489        assert_eq!(parse_schema_field_type("cents").unwrap(), ModelFieldType::Money);
7490        assert_eq!(parse_schema_field_type("price").unwrap(), ModelFieldType::Money);
7491        assert_eq!(parse_schema_field_type("img").unwrap(), ModelFieldType::Image);
7492        assert_eq!(parse_schema_field_type("photo").unwrap(), ModelFieldType::Image);
7493        assert_eq!(parse_schema_field_type("uri").unwrap(), ModelFieldType::Url);
7494        assert_eq!(parse_schema_field_type("link").unwrap(), ModelFieldType::Url);
7495    }
7496
7497    #[test]
7498    fn parse_schema_field_type_parameterized() {
7499        assert_eq!(
7500            parse_schema_field_type("enum:active,inactive,pending").unwrap(),
7501            ModelFieldType::Enum(vec!["active".to_string(), "inactive".to_string(), "pending".to_string()])
7502        );
7503        assert_eq!(
7504            parse_schema_field_type("ref:users").unwrap(),
7505            ModelFieldType::Ref("users".to_string())
7506        );
7507    }
7508
7509    #[test]
7510    fn parse_schema_field_type_case_insensitive() {
7511        assert_eq!(parse_schema_field_type("UUID").unwrap(), ModelFieldType::Uuid);
7512        assert_eq!(parse_schema_field_type("String").unwrap(), ModelFieldType::String);
7513        assert_eq!(parse_schema_field_type("Bool").unwrap(), ModelFieldType::Bool);
7514        assert_eq!(parse_schema_field_type("DATETIME").unwrap(), ModelFieldType::Datetime);
7515    }
7516
7517    #[test]
7518    fn parse_schema_field_type_unknown() {
7519        assert!(parse_schema_field_type("foobar").is_err());
7520        assert!(parse_schema_field_type("").is_err());
7521    }
7522
7523    #[test]
7524    fn parse_schema_constraint_all() {
7525        assert_eq!(parse_schema_constraint("primary").unwrap(), FieldConstraint::Primary);
7526        assert_eq!(parse_schema_constraint("auto").unwrap(), FieldConstraint::Auto);
7527        assert_eq!(parse_schema_constraint("required").unwrap(), FieldConstraint::Required);
7528        assert_eq!(parse_schema_constraint("optional").unwrap(), FieldConstraint::Optional);
7529        assert_eq!(parse_schema_constraint("unique").unwrap(), FieldConstraint::Unique);
7530        assert_eq!(parse_schema_constraint("index").unwrap(), FieldConstraint::Index);
7531    }
7532
7533    #[test]
7534    fn parse_schema_constraint_parameterized() {
7535        assert_eq!(parse_schema_constraint("max:255").unwrap(), FieldConstraint::Max(255));
7536        assert_eq!(parse_schema_constraint("min:1").unwrap(), FieldConstraint::Min(1));
7537        assert_eq!(parse_schema_constraint("default:now()").unwrap(), FieldConstraint::Default("now()".to_string()));
7538        assert_eq!(parse_schema_constraint("default:true").unwrap(), FieldConstraint::Default("true".to_string()));
7539    }
7540
7541    #[test]
7542    fn parse_schema_constraint_case_insensitive() {
7543        assert_eq!(parse_schema_constraint("PRIMARY").unwrap(), FieldConstraint::Primary);
7544        assert_eq!(parse_schema_constraint("Required").unwrap(), FieldConstraint::Required);
7545        assert_eq!(parse_schema_constraint("UNIQUE").unwrap(), FieldConstraint::Unique);
7546    }
7547
7548    #[test]
7549    fn parse_schema_constraint_unknown() {
7550        assert!(parse_schema_constraint("foobar").is_err());
7551        assert!(parse_schema_constraint("references").is_err());
7552    }
7553
7554    #[test]
7555    fn parse_schema_from_full_surf() {
7556        let source = "::schema[name=\"users\"]\n- id (uuid) primary\n- name (text) required\n::";
7557        let result = crate::parse(source);
7558        assert!(result.diagnostics.is_empty(), "Diagnostics: {:?}", result.diagnostics);
7559        let has_schema = result.doc.blocks.iter().any(|b| matches!(b, Block::Schema { .. }));
7560        assert!(has_schema, "Should contain a Schema block");
7561    }
7562
7563    // -- Use ---------------------------------------------------------
7564
7565    #[test]
7566    fn resolve_use_content_lines() {
7567        let content = "- reqwest 0.12 [json, rustls-tls]\n- lettre\n- stripe 0.25";
7568        let block = unknown("use", Attrs::new(), content);
7569        match resolve_block(block) {
7570            Block::Use { crates, .. } => {
7571                assert_eq!(crates.len(), 3);
7572                assert_eq!(crates[0].name, "reqwest");
7573                assert_eq!(crates[0].version, Some("0.12".to_string()));
7574                assert_eq!(crates[0].features, vec!["json", "rustls-tls"]);
7575                assert_eq!(crates[1].name, "lettre");
7576                assert!(crates[1].version.is_none());
7577                assert_eq!(crates[2].name, "stripe");
7578                assert_eq!(crates[2].version, Some("0.25".to_string()));
7579            }
7580            other => panic!("Expected Use, got {other:?}"),
7581        }
7582    }
7583
7584    #[test]
7585    fn resolve_use_inline_attrs() {
7586        let a = attrs(&[
7587            ("reqwest", AttrValue::Null),
7588            ("lettre", AttrValue::Null),
7589            ("stripe", AttrValue::Null),
7590        ]);
7591        let block = unknown("use", a, "");
7592        match resolve_block(block) {
7593            Block::Use { crates, .. } => {
7594                assert_eq!(crates.len(), 3);
7595                let names: Vec<&str> = crates.iter().map(|c| c.name.as_str()).collect();
7596                assert!(names.contains(&"reqwest"));
7597                assert!(names.contains(&"lettre"));
7598                assert!(names.contains(&"stripe"));
7599            }
7600            other => panic!("Expected Use, got {other:?}"),
7601        }
7602    }
7603
7604    #[test]
7605    fn parse_use_from_full_surf() {
7606        let source = "::use\n- reqwest 0.12 [json]\n- lettre\n::";
7607        let result = crate::parse(source);
7608        assert!(result.diagnostics.is_empty(), "Diagnostics: {:?}", result.diagnostics);
7609        let has_use = result.doc.blocks.iter().any(|b| matches!(b, Block::Use { .. }));
7610        assert!(has_use, "Should contain a Use block");
7611    }
7612
7613    // -- App ---------------------------------------------------------
7614
7615    #[test]
7616    fn parse_app_honors_auth_attr() {
7617        let source = "::app[name=with-auth auth=password]\n::";
7618        let result = crate::parse(source);
7619        match result.doc.blocks.iter().find(|b| matches!(b, Block::App { .. })) {
7620            Some(Block::App { name, auth, .. }) => {
7621                assert_eq!(name, "with-auth");
7622                assert_eq!(auth.as_deref(), Some("password"));
7623            }
7624            other => panic!("Expected App, got {other:?}"),
7625        }
7626    }
7627
7628    #[test]
7629    fn parse_app_without_auth_attr_is_none() {
7630        let source = "::app[name=no-auth port=8080]\n::";
7631        let result = crate::parse(source);
7632        match result.doc.blocks.iter().find(|b| matches!(b, Block::App { .. })) {
7633            Some(Block::App { auth, .. }) => assert!(auth.is_none()),
7634            other => panic!("Expected App, got {other:?}"),
7635        }
7636    }
7637
7638    // -- AppEnv ------------------------------------------------------
7639
7640    #[test]
7641    fn resolve_app_env_content_lines() {
7642        let content = "- STRIPE_KEY * \"Stripe secret API key\"\n- SMTP_HOST \"Mail server hostname\"\n- DEBUG_MODE";
7643        let block = unknown("app-env", Attrs::new(), content);
7644        match resolve_block(block) {
7645            Block::AppEnv { vars, .. } => {
7646                assert_eq!(vars.len(), 3);
7647                assert_eq!(vars[0].name, "STRIPE_KEY");
7648                assert!(vars[0].required);
7649                assert_eq!(vars[0].description, Some("Stripe secret API key".to_string()));
7650                assert_eq!(vars[1].name, "SMTP_HOST");
7651                assert!(!vars[1].required);
7652                assert_eq!(vars[1].description, Some("Mail server hostname".to_string()));
7653                assert_eq!(vars[2].name, "DEBUG_MODE");
7654                assert!(!vars[2].required);
7655                assert!(vars[2].description.is_none());
7656            }
7657            other => panic!("Expected AppEnv, got {other:?}"),
7658        }
7659    }
7660
7661    #[test]
7662    fn resolve_app_env_inline_attrs() {
7663        let a = attrs(&[
7664            ("STRIPE_KEY", AttrValue::Null),
7665            ("SMTP_HOST", AttrValue::Null),
7666        ]);
7667        let block = unknown("app-env", a, "");
7668        match resolve_block(block) {
7669            Block::AppEnv { vars, .. } => {
7670                assert_eq!(vars.len(), 2);
7671                let names: Vec<&str> = vars.iter().map(|v| v.name.as_str()).collect();
7672                assert!(names.contains(&"STRIPE_KEY"));
7673                assert!(names.contains(&"SMTP_HOST"));
7674            }
7675            other => panic!("Expected AppEnv, got {other:?}"),
7676        }
7677    }
7678
7679    #[test]
7680    fn parse_app_env_from_full_surf() {
7681        let source = "::app-env\n- STRIPE_KEY * \"API key\"\n- DEBUG_MODE\n::";
7682        let result = crate::parse(source);
7683        assert!(result.diagnostics.is_empty(), "Diagnostics: {:?}", result.diagnostics);
7684        let has_env = result.doc.blocks.iter().any(|b| matches!(b, Block::AppEnv { .. }));
7685        assert!(has_env, "Should contain an AppEnv block");
7686    }
7687
7688    // -- AppDeploy ---------------------------------------------------
7689
7690    #[test]
7691    fn resolve_app_deploy_attrs() {
7692        let a = attrs(&[
7693            ("region", AttrValue::String("sjc".into())),
7694            ("scale", AttrValue::Number(2.0)),
7695            ("domain", AttrValue::String("myapp.surf.new".into())),
7696            ("memory", AttrValue::String("512mb".into())),
7697        ]);
7698        let block = unknown("app-deploy", a, "");
7699        match resolve_block(block) {
7700            Block::AppDeploy { region, scale, domain, memory, properties, .. } => {
7701                assert_eq!(region, Some("sjc".to_string()));
7702                assert_eq!(scale, Some(2));
7703                assert_eq!(domain, Some("myapp.surf.new".to_string()));
7704                assert_eq!(memory, Some("512mb".to_string()));
7705                assert!(properties.is_empty());
7706            }
7707            other => panic!("Expected AppDeploy, got {other:?}"),
7708        }
7709    }
7710
7711    #[test]
7712    fn resolve_app_deploy_with_content() {
7713        let a = attrs(&[("region", AttrValue::String("iad".into()))]);
7714        let content = "auto_stop: 5m\nmin_machines: 1";
7715        let block = unknown("app-deploy", a, content);
7716        match resolve_block(block) {
7717            Block::AppDeploy { region, properties, .. } => {
7718                assert_eq!(region, Some("iad".to_string()));
7719                assert_eq!(properties.len(), 2);
7720                assert_eq!(properties[0], ("auto_stop".to_string(), "5m".to_string()));
7721                assert_eq!(properties[1], ("min_machines".to_string(), "1".to_string()));
7722            }
7723            other => panic!("Expected AppDeploy, got {other:?}"),
7724        }
7725    }
7726
7727    #[test]
7728    fn parse_app_deploy_from_full_surf() {
7729        let source = "::app-deploy[region=\"sjc\" scale=\"2\" domain=\"myapp.surf.new\" memory=\"512mb\"]\n::";
7730        let result = crate::parse(source);
7731        assert!(result.diagnostics.is_empty(), "Diagnostics: {:?}", result.diagnostics);
7732        let has_deploy = result.doc.blocks.iter().any(|b| matches!(b, Block::AppDeploy { .. }));
7733        assert!(has_deploy, "Should contain an AppDeploy block");
7734    }
7735
7736    // ── Interactive / application block parse tests ───────────────
7737
7738    #[test]
7739    fn parse_app_shell() {
7740        let source = "::app-shell[layout=sidebar-main-panel]\nSome content\n::";
7741        let result = crate::parse(source);
7742        let block = &result.doc.blocks[0];
7743        match block {
7744            Block::AppShell { layout, .. } => assert_eq!(layout, "sidebar-main-panel"),
7745            other => panic!("Expected AppShell, got {:?}", other),
7746        }
7747    }
7748
7749    #[test]
7750    fn parse_sidebar_block() {
7751        let source = "::sidebar[position=left collapsible=true width=250]\nContent here\n::";
7752        let result = crate::parse(source);
7753        let block = &result.doc.blocks[0];
7754        match block {
7755            Block::Sidebar { position, collapsible, width, .. } => {
7756                assert_eq!(position, "left");
7757                assert!(*collapsible);
7758                assert_eq!(*width, Some(250));
7759            }
7760            other => panic!("Expected Sidebar, got {:?}", other),
7761        }
7762    }
7763
7764    #[test]
7765    fn parse_panel_block() {
7766        let source = "::panel[position=bottom resizable=true height=200 desktop-only=true]\nPanel content\n::";
7767        let result = crate::parse(source);
7768        let block = &result.doc.blocks[0];
7769        match block {
7770            Block::Panel { position, resizable, height, desktop_only, .. } => {
7771                assert_eq!(position, "bottom");
7772                assert!(*resizable);
7773                assert_eq!(*height, Some(200));
7774                assert!(*desktop_only);
7775            }
7776            other => panic!("Expected Panel, got {:?}", other),
7777        }
7778    }
7779
7780    #[test]
7781    fn parse_tab_bar_block() {
7782        let source = "::tab-bar[active=preview]\n- preview \"Preview\"\n- edit \"Edit\"\n- source \"Source\"\n::";
7783        let result = crate::parse(source);
7784        let block = &result.doc.blocks[0];
7785        match block {
7786            Block::TabBar { active, items, .. } => {
7787                assert_eq!(*active, Some("preview".to_string()));
7788                assert_eq!(items.len(), 3);
7789                assert_eq!(items[0].id, "preview");
7790                assert_eq!(items[0].label, "Preview");
7791                assert_eq!(items[2].id, "source");
7792            }
7793            other => panic!("Expected TabBar, got {:?}", other),
7794        }
7795    }
7796
7797    #[test]
7798    fn parse_tab_content_block() {
7799        let source = "::tab-content[tab=preview]\nTab body\n::";
7800        let result = crate::parse(source);
7801        let block = &result.doc.blocks[0];
7802        match block {
7803            Block::TabContent { tab, .. } => assert_eq!(tab, "preview"),
7804            other => panic!("Expected TabContent, got {:?}", other),
7805        }
7806    }
7807
7808    #[test]
7809    fn parse_toolbar_block() {
7810        let source = "::toolbar\n- button[label=\"Deploy\" action=deploy style=primary]\n- separator\n- spacer\n- badge[value=\"Live\" color=green]\n::";
7811        let result = crate::parse(source);
7812        let block = &result.doc.blocks[0];
7813        match block {
7814            Block::Toolbar { items, .. } => {
7815                assert_eq!(items.len(), 4);
7816                assert!(matches!(&items[0], ToolbarItem::Button { label: Some(l), .. } if l == "Deploy"));
7817                assert!(matches!(&items[1], ToolbarItem::Separator));
7818                assert!(matches!(&items[2], ToolbarItem::Spacer));
7819                assert!(matches!(&items[3], ToolbarItem::Badge { value, .. } if value == "Live"));
7820            }
7821            other => panic!("Expected Toolbar, got {:?}", other),
7822        }
7823    }
7824
7825    #[test]
7826    fn parse_drawer_block() {
7827        let source = "::drawer[name=mako position=right width=320 trigger=icon]\nDrawer content\n::";
7828        let result = crate::parse(source);
7829        let block = &result.doc.blocks[0];
7830        match block {
7831            Block::Drawer { name, position, width, trigger, .. } => {
7832                assert_eq!(name, "mako");
7833                assert_eq!(position, "right");
7834                assert_eq!(*width, Some(320));
7835                assert_eq!(*trigger, Some("icon".to_string()));
7836            }
7837            other => panic!("Expected Drawer, got {:?}", other),
7838        }
7839    }
7840
7841    #[test]
7842    fn parse_modal_block() {
7843        let source = "::modal[name=deploy title=\"Deploy App\"]\nModal body\n::";
7844        let result = crate::parse(source);
7845        let block = &result.doc.blocks[0];
7846        match block {
7847            Block::Modal { name, title, .. } => {
7848                assert_eq!(name, "deploy");
7849                assert_eq!(*title, Some("Deploy App".to_string()));
7850            }
7851            other => panic!("Expected Modal, got {:?}", other),
7852        }
7853    }
7854
7855    #[test]
7856    fn parse_command_palette_block() {
7857        let source = "::command-palette[trigger=cmd+/]\n- \"Data Table\" description=\"Define data\" action=add_schema icon=table group=Data\n::";
7858        let result = crate::parse(source);
7859        let block = &result.doc.blocks[0];
7860        match block {
7861            Block::CommandPalette { trigger, items, .. } => {
7862                assert_eq!(*trigger, Some("cmd+/".to_string()));
7863                assert_eq!(items.len(), 1);
7864                assert_eq!(items[0].label, "Data Table");
7865                assert_eq!(items[0].description, Some("Define data".to_string()));
7866                assert_eq!(items[0].group, Some("Data".to_string()));
7867            }
7868            other => panic!("Expected CommandPalette, got {:?}", other),
7869        }
7870    }
7871
7872    #[test]
7873    fn parse_code_editor_block() {
7874        let source = "::code-editor[lang=surf source=doc.source line-numbers=true]\nsome code\n::";
7875        let result = crate::parse(source);
7876        let block = &result.doc.blocks[0];
7877        match block {
7878            Block::CodeEditor { lang, source, line_numbers, content, .. } => {
7879                assert_eq!(*lang, Some("surf".to_string()));
7880                assert_eq!(*source, Some("doc.source".to_string()));
7881                assert!(*line_numbers);
7882                assert!(content.contains("some code"));
7883            }
7884            other => panic!("Expected CodeEditor, got {:?}", other),
7885        }
7886    }
7887
7888    #[test]
7889    fn parse_block_editor_block() {
7890        let source = "::block-editor[source=doc.blocks]\n::";
7891        let result = crate::parse(source);
7892        let block = &result.doc.blocks[0];
7893        match block {
7894            Block::BlockEditor { source, .. } => {
7895                assert_eq!(*source, Some("doc.blocks".to_string()));
7896            }
7897            other => panic!("Expected BlockEditor, got {:?}", other),
7898        }
7899    }
7900
7901    #[test]
7902    fn parse_terminal_block() {
7903        let source = "::terminal[shell=default cwd=workspace.path]\n::";
7904        let result = crate::parse(source);
7905        let block = &result.doc.blocks[0];
7906        match block {
7907            Block::Terminal { shell, cwd, .. } => {
7908                assert_eq!(*shell, Some("default".to_string()));
7909                assert_eq!(*cwd, Some("workspace.path".to_string()));
7910            }
7911            other => panic!("Expected Terminal, got {:?}", other),
7912        }
7913    }
7914
7915    #[test]
7916    fn parse_nav_tree_block() {
7917        let source = "::nav-tree[source=workspace.files on-select=open_file]\n::";
7918        let result = crate::parse(source);
7919        let block = &result.doc.blocks[0];
7920        match block {
7921            Block::NavTree { source, on_select, .. } => {
7922                assert_eq!(*source, Some("workspace.files".to_string()));
7923                assert_eq!(*on_select, Some("open_file".to_string()));
7924            }
7925            other => panic!("Expected NavTree, got {:?}", other),
7926        }
7927    }
7928
7929    #[test]
7930    fn parse_rich_nav_groups_and_attrs() {
7931        let source = "::nav[drawer brand=\"CloudSurf\" reg logo=/logo.png cta-label=\"Get in touch\" cta-href=#contact]\n## Explore\n- [Research](/research){icon=flask}\n## Products\n- [Surf](https://app.surf){image=/surf.png external}\n::";
7932        let result = crate::parse(source);
7933        match &result.doc.blocks[0] {
7934            Block::Nav { groups, brand, brand_reg, cta, drawer, logo, .. } => {
7935                assert!(*drawer);
7936                assert!(*brand_reg);
7937                assert_eq!(brand.as_deref(), Some("CloudSurf"));
7938                assert_eq!(logo.as_deref(), Some("/logo.png"));
7939                let c = cta.as_ref().expect("cta");
7940                assert_eq!(c.label, "Get in touch");
7941                assert_eq!(c.href, "#contact");
7942                assert_eq!(groups.len(), 2);
7943                assert_eq!(groups[0].label.as_deref(), Some("Explore"));
7944                assert_eq!(groups[0].items[0].icon.as_deref(), Some("flask"));
7945                assert_eq!(groups[1].items[0].image.as_deref(), Some("/surf.png"));
7946                assert!(groups[1].items[0].external);
7947            }
7948            other => panic!("Expected Nav, got {other:?}"),
7949        }
7950    }
7951
7952    #[test]
7953    fn parse_rich_footer_brand_attrs() {
7954        let source = "::footer[brand=\"CloudSurf\" reg logo=/logo.png tagline=\"Innovate • Simplify • Scale\"]\n## Products\n- [Surf](https://app.surf)\n(c) 2026 CloudSurf\n::";
7955        let result = crate::parse(source);
7956        match &result.doc.blocks[0] {
7957            Block::Footer { brand, brand_reg, brand_logo, tagline, sections, .. } => {
7958                assert_eq!(brand.as_deref(), Some("CloudSurf"));
7959                assert!(*brand_reg);
7960                assert_eq!(brand_logo.as_deref(), Some("/logo.png"));
7961                assert_eq!(tagline.as_deref(), Some("Innovate • Simplify • Scale"));
7962                assert_eq!(sections.len(), 1);
7963            }
7964            other => panic!("Expected Footer, got {other:?}"),
7965        }
7966    }
7967
7968    #[test]
7969    fn rich_nav_round_trips_through_builder() {
7970        let source = "::nav[drawer brand=\"CloudSurf\" reg cta-label=\"Apply\" cta-href=/jobs]\n## Explore\n- [Research](/research){icon=flask}\n- [Surf](https://app.surf){image=/surf.png external}\n::";
7971        let parsed = crate::parse(source);
7972        let surf = crate::builder::to_surf_source(&parsed.doc);
7973        // Re-parse the serialized form and confirm structure survives.
7974        let reparsed = crate::parse(&surf);
7975        match &reparsed.doc.blocks[0] {
7976            Block::Nav { groups, brand, brand_reg, cta, drawer, .. } => {
7977                assert!(*drawer);
7978                assert!(*brand_reg);
7979                assert_eq!(brand.as_deref(), Some("CloudSurf"));
7980                assert_eq!(cta.as_ref().unwrap().label, "Apply");
7981                assert_eq!(groups.len(), 1);
7982                assert_eq!(groups[0].items.len(), 2);
7983                assert_eq!(groups[0].items[0].icon.as_deref(), Some("flask"));
7984                assert!(groups[0].items[1].external);
7985                assert_eq!(groups[0].items[1].image.as_deref(), Some("/surf.png"));
7986            }
7987            other => panic!("Expected Nav, got {other:?}"),
7988        }
7989    }
7990
7991    #[test]
7992    fn parse_badge_block() {
7993        let source = "::badge[value=\"Live\" color=green]\n::";
7994        let result = crate::parse(source);
7995        let block = &result.doc.blocks[0];
7996        match block {
7997            Block::Badge { value, color, .. } => {
7998                assert_eq!(value, "Live");
7999                assert_eq!(*color, Some("green".to_string()));
8000            }
8001            other => panic!("Expected Badge, got {:?}", other),
8002        }
8003    }
8004
8005    #[test]
8006    fn parse_suggestion_chips_block() {
8007        let source = "::suggestion-chips[source=mako.suggestions max=3 dismissible=true]\n::";
8008        let result = crate::parse(source);
8009        let block = &result.doc.blocks[0];
8010        match block {
8011            Block::SuggestionChips { source, max, dismissible, .. } => {
8012                assert_eq!(*source, Some("mako.suggestions".to_string()));
8013                assert_eq!(*max, Some(3));
8014                assert!(*dismissible);
8015            }
8016            other => panic!("Expected SuggestionChips, got {:?}", other),
8017        }
8018    }
8019
8020    #[test]
8021    fn parse_chat_thread_block() {
8022        let source = "::chat-thread[source=mako.conversation]\n::";
8023        let result = crate::parse(source);
8024        let block = &result.doc.blocks[0];
8025        match block {
8026            Block::ChatThread { source, .. } => {
8027                assert_eq!(*source, Some("mako.conversation".to_string()));
8028            }
8029            other => panic!("Expected ChatThread, got {:?}", other),
8030        }
8031    }
8032
8033    #[test]
8034    fn parse_chat_input_simple_block() {
8035        let source = "::chat-input-simple[placeholder=\"Message Mako...\" action=mako.send]\n::";
8036        let result = crate::parse(source);
8037        let block = &result.doc.blocks[0];
8038        match block {
8039            Block::ChatInputSimple { placeholder, action, .. } => {
8040                assert_eq!(*placeholder, Some("Message Mako...".to_string()));
8041                assert_eq!(*action, Some("mako.send".to_string()));
8042            }
8043            other => panic!("Expected ChatInputSimple, got {:?}", other),
8044        }
8045    }
8046
8047    #[test]
8048    fn parse_progress_block() {
8049        let source = "::progress[source=deploy.progress]\n- Parsing app \u{2713}\n- Creating database \u{25CF}\n- Registering routes \u{25CB}\n::";
8050        let result = crate::parse(source);
8051        let block = &result.doc.blocks[0];
8052        match block {
8053            Block::Progress { source, steps, .. } => {
8054                assert_eq!(*source, Some("deploy.progress".to_string()));
8055                assert_eq!(steps.len(), 3);
8056                assert_eq!(steps[0].label, "Parsing app");
8057                assert_eq!(steps[0].status, "done");
8058                assert_eq!(steps[1].status, "active");
8059                assert_eq!(steps[2].status, "pending");
8060            }
8061            other => panic!("Expected Progress, got {:?}", other),
8062        }
8063    }
8064
8065    #[test]
8066    fn parse_log_stream_block() {
8067        let source = "::log-stream[source=app.logs tail=100]\n::";
8068        let result = crate::parse(source);
8069        let block = &result.doc.blocks[0];
8070        match block {
8071            Block::LogStream { source, tail, .. } => {
8072                assert_eq!(*source, Some("app.logs".to_string()));
8073                assert_eq!(*tail, Some(100));
8074            }
8075            other => panic!("Expected LogStream, got {:?}", other),
8076        }
8077    }
8078
8079    #[test]
8080    fn parse_problem_list_block() {
8081        let source = "::problem-list[source=doc.diagnostics]\n::";
8082        let result = crate::parse(source);
8083        let block = &result.doc.blocks[0];
8084        match block {
8085            Block::ProblemList { source, .. } => {
8086                assert_eq!(*source, Some("doc.diagnostics".to_string()));
8087            }
8088            other => panic!("Expected ProblemList, got {:?}", other),
8089        }
8090    }
8091
8092    // -- PostGrid --------------------------------------------------
8093
8094    #[test]
8095    fn resolve_post_grid_full() {
8096        let content = "\
8097- [First Post](/blog/first){external} | News · 2026-01-01 | A short excerpt | /img/a.png
8098- [Second Post](/blog/second) | Events · 2026-02-02 | Another excerpt
8099- [Bare Post](/blog/bare)";
8100        let block = unknown(
8101            "post-grid",
8102            attrs(&[
8103                ("title", AttrValue::String("Blog".into())),
8104                ("subtitle", AttrValue::String("Latest news".into())),
8105            ]),
8106            content,
8107        );
8108        match resolve_block(block) {
8109            Block::PostGrid { title, subtitle, items, .. } => {
8110                assert_eq!(title, Some("Blog".to_string()));
8111                assert_eq!(subtitle, Some("Latest news".to_string()));
8112                assert_eq!(items.len(), 3);
8113
8114                assert_eq!(items[0].title, "First Post");
8115                assert_eq!(items[0].href, "/blog/first");
8116                assert!(items[0].external);
8117                assert_eq!(items[0].meta.as_deref(), Some("News · 2026-01-01"));
8118                assert_eq!(items[0].excerpt.as_deref(), Some("A short excerpt"));
8119                assert_eq!(items[0].image.as_deref(), Some("/img/a.png"));
8120
8121                assert!(!items[1].external);
8122                assert_eq!(items[1].meta.as_deref(), Some("Events · 2026-02-02"));
8123                assert_eq!(items[1].excerpt.as_deref(), Some("Another excerpt"));
8124                assert_eq!(items[1].image, None);
8125
8126                assert_eq!(items[2].title, "Bare Post");
8127                assert_eq!(items[2].meta, None);
8128                assert_eq!(items[2].excerpt, None);
8129            }
8130            other => panic!("Expected PostGrid, got {other:?}"),
8131        }
8132    }
8133
8134    #[test]
8135    fn parse_post_grid_round_trip() {
8136        let source = "\
8137::post-grid[title=\"News\" subtitle=\"Updates\"]
8138- [Hello](/x){external} | Meta | Excerpt | /img.png
8139::";
8140        let result = crate::parse(source);
8141        match &result.doc.blocks[0] {
8142            Block::PostGrid { title, items, .. } => {
8143                assert_eq!(title.as_deref(), Some("News"));
8144                assert_eq!(items.len(), 1);
8145                assert!(items[0].external);
8146                assert_eq!(items[0].image.as_deref(), Some("/img.png"));
8147            }
8148            other => panic!("Expected PostGrid, got {other:?}"),
8149        }
8150    }
8151
8152    // -- Gate ------------------------------------------------------
8153
8154    #[test]
8155    fn resolve_gate_full() {
8156        let block = unknown(
8157            "gate",
8158            attrs(&[
8159                ("title", AttrValue::String("Members".into())),
8160                ("subtitle", AttrValue::String("Enter the code".into())),
8161                ("action", AttrValue::String("/unlock".into())),
8162                ("field", AttrValue::String("Your code".into())),
8163                ("submit", AttrValue::String("Unlock".into())),
8164                ("error", AttrValue::String("Wrong code".into())),
8165            ]),
8166            "",
8167        );
8168        match resolve_block(block) {
8169            Block::Gate { title, subtitle, action, field_label, submit_label, error, .. } => {
8170                assert_eq!(title.as_deref(), Some("Members"));
8171                assert_eq!(subtitle.as_deref(), Some("Enter the code"));
8172                assert_eq!(action, "/unlock");
8173                assert_eq!(field_label.as_deref(), Some("Your code"));
8174                assert_eq!(submit_label.as_deref(), Some("Unlock"));
8175                assert_eq!(error.as_deref(), Some("Wrong code"));
8176            }
8177            other => panic!("Expected Gate, got {other:?}"),
8178        }
8179    }
8180
8181    #[test]
8182    fn resolve_gate_body_becomes_subtitle() {
8183        let block = unknown("gate", Attrs::new(), "This page is private.");
8184        match resolve_block(block) {
8185            Block::Gate { subtitle, action, .. } => {
8186                assert_eq!(subtitle.as_deref(), Some("This page is private."));
8187                assert_eq!(action, "");
8188            }
8189            other => panic!("Expected Gate, got {other:?}"),
8190        }
8191    }
8192
8193    // -- Nav minimal ----------------------------------------------
8194
8195    #[test]
8196    fn parse_nav_minimal() {
8197        let source = "::nav[brand=\"Acme\" minimal]\n::";
8198        let result = crate::parse(source);
8199        match &result.doc.blocks[0] {
8200            Block::Nav { minimal, brand, .. } => {
8201                assert!(*minimal);
8202                assert_eq!(brand.as_deref(), Some("Acme"));
8203            }
8204            other => panic!("Expected Nav, got {other:?}"),
8205        }
8206    }
8207
8208    #[test]
8209    fn parse_nav_minimal_defaults_false() {
8210        let source = "::nav[logo=\"Surf\"]\n- [Home](/)\n::";
8211        let result = crate::parse(source);
8212        match &result.doc.blocks[0] {
8213            Block::Nav { minimal, .. } => assert!(!*minimal),
8214            other => panic!("Expected Nav, got {other:?}"),
8215        }
8216    }
8217
8218    // -- p3-copy-sweep: attrs displaced onto their own line ---------
8219
8220    #[test]
8221    fn attrs_on_own_line_adopted() {
8222        // `::features` newline `[cols=3 title="Why us"]` — the bracket line is
8223        // adopted as block attrs instead of rendering as body text.
8224        let block = unknown(
8225            "features",
8226            Attrs::new(),
8227            "[cols=3 title=\"Why us\"]\n### Fast\nQuick.\n### Safe\nSecure.",
8228        );
8229        match resolve_block(block) {
8230            Block::Features { cards, cols, .. } => {
8231                assert_eq!(cols, Some(3), "cols=3 honored");
8232                assert_eq!(cards.len(), 2);
8233                for card in &cards {
8234                    assert!(
8235                        !card.title.contains("[cols=") && !card.body.contains("[cols="),
8236                        "bracket text leaked into card: {card:?}"
8237                    );
8238                }
8239            }
8240            other => panic!("Expected Features, got {other:?}"),
8241        }
8242    }
8243
8244    #[test]
8245    fn attrs_on_own_line_not_adopted_for_markdown_shapes() {
8246        // A markdown link-reference definition is NOT a displaced attr line.
8247        let block = unknown("summary", Attrs::new(), "[1]: https://example.com\nBody.");
8248        match resolve_block(block) {
8249            Block::Summary { content, .. } => {
8250                assert!(content.contains("[1]: https://example.com"), "{content:?}");
8251            }
8252            other => panic!("Expected Summary, got {other:?}"),
8253        }
8254        // Existing attrs win: the bracket line stays content.
8255        let block = unknown(
8256            "callout",
8257            attrs(&[("type", AttrValue::String("info".into()))]),
8258            "[cols=3]\nBody.",
8259        );
8260        match resolve_block(block) {
8261            Block::Callout { content, .. } => {
8262                assert!(content.contains("[cols=3]"), "{content:?}");
8263            }
8264            other => panic!("Expected Callout, got {other:?}"),
8265        }
8266    }
8267}