1use 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
19pub 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 "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 "editor" => parse_editor(attrs, *span),
93 "chart" => parse_chart(attrs, content, *span),
94 "split-pane" => parse_split_pane(attrs, *span),
95 "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 "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 "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 "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
146fn 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
220fn 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 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
295fn 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 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
328fn 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
339fn split_pipe_row(line: &str) -> Vec<String> {
342 let trimmed = line.trim();
343 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
353fn 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
384fn 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 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
454fn 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 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
525fn parse_cite(attrs: &Attrs, content: &str, span: Span) -> Block {
532 let mut reference = Reference::default();
533
534 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 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
585fn 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
597fn 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 if let Some(rest) = trimmed.strip_prefix("## ") {
621 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 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 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 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 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 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 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 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 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 if let Some(rest) = trimmed.strip_prefix("### ") {
817 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 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 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 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
907fn parse_deck(attrs: &Attrs, content: &str, span: Span) -> Block {
913 let mut properties = Vec::new();
914
915 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 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
943fn 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 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
988fn 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
998fn 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 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 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 if let Some((content_str, end_idx)) =
1037 scan_container_close(&lines, i + 1, depth)
1038 {
1039 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; continue;
1060 } else {
1061 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 }
1070 }
1071
1072 if !in_fence && crate::parse::closing_directive_depth(trimmed).is_some() {
1076 i += 1;
1078 continue;
1079 }
1080
1081 md_lines.push(lines[i]);
1082 i += 1;
1083 }
1084
1085 flush_md_lines(&mut md_lines, &mut children);
1087
1088 children
1089}
1090
1091fn 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..]; let href_end = after_bracket.find(')')?;
1100 let href = after_bracket[..href_end].to_string();
1101
1102 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 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 for line in content.lines() {
1149 let trimmed = line.trim();
1150 if let Some(rest) = trimmed.strip_prefix("## ").or_else(|| trimmed.strip_prefix("### ")) {
1151 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 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 if let Some(rest) = trimmed.strip_prefix("- ") {
1237 let rest = rest.trim();
1238 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 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 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 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 if let Some(after_bang) = trimmed.strip_prefix(" {
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 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 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 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 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 if let Some(rest) = trimmed.strip_prefix("## ") {
1434 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 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 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 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
1522fn 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 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 if let Some(close_depth) = crate::parse::closing_directive_depth(trimmed) {
1547 if close_depth == open_depth && nesting == 0 {
1548 return Some((content_lines.join("\n"), i));
1550 }
1551 if nesting > 0 {
1553 nesting -= 1;
1554 content_lines.push(lines[i]);
1555 continue;
1556 }
1557 content_lines.push(lines[i]);
1559 continue;
1560 }
1561
1562 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 return None;
1571 }
1572 if nested_depth > open_depth {
1573 nesting += 1;
1574 }
1575 }
1576
1577 content_lines.push(lines[i]);
1578 }
1579
1580 None
1582}
1583
1584fn try_parse_leaf_directive(line: &str) -> Option<Block> {
1588 let trimmed = line.trim();
1589 if !trimmed.starts_with("::") {
1590 return None;
1591 }
1592
1593 let depth = trimmed.chars().take_while(|&c| c == ':').count();
1595 if depth != 2 {
1596 return None;
1597 }
1598
1599 let rest = trimmed[2..].trim_start_matches([' ', '\t']);
1602 if rest.is_empty() {
1603 return None; }
1605
1606 let first = rest.chars().next()?;
1608 if !first.is_alphabetic() {
1609 return None;
1610 }
1611
1612 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 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
1648fn 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
1666fn 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 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 if let Some(rest) = trimmed.strip_prefix("# ") {
1718 headline = Some(rest.trim().to_string());
1719 in_subtitle = false;
1720 continue;
1721 }
1722
1723 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 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 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
1774fn 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 content: String::new(),
1831 span,
1832 }
1833}
1834
1835fn 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 if started && (!current.items.is_empty() || current.label.is_some()) {
1870 groups.push(current);
1871 }
1872 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 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
1949fn 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 let mut external = false;
1987 if let Some(stripped) = tail.strip_prefix("{external}") {
1988 external = true;
1989 tail = stripped.trim();
1990 }
1991
1992 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
2027fn 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 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 while body_lines.last().is_some_and(|l| l.trim().is_empty()) {
2089 body_lines.pop();
2090 }
2091 body_lines.pop(); }
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 if let Some(rest) = trimmed
2110 .strip_prefix("### ")
2111 .or_else(|| trimmed.strip_prefix("## "))
2112 {
2113 if let Some(title) = current_title.take() {
2115 cards.push(flush(title, current_icon.take(), ¤t_lines));
2116 current_lines.clear();
2117 }
2118 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 if let Some(title) = current_title {
2137 cards.push(flush(title, current_icon, ¤t_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 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 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 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
2193fn 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
2251fn 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 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 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
2340fn 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 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 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 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 if trimmed.is_empty() || trimmed.chars().all(|c| c == '-') {
2440 continue;
2441 }
2442 let text = trimmed.strip_prefix("- ").unwrap_or(trimmed);
2443 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 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 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 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 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
2652fn 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
2664fn 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 if lower.starts_with("javascript:")
2676 || lower.starts_with("data:")
2677 || lower.starts_with("vbscript:")
2678 {
2679 return None;
2680 }
2681 if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("//") {
2683 return None;
2684 }
2685 if trimmed.contains("..") {
2687 return None;
2688 }
2689 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 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 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 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
3057fn 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
3093fn 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
3109fn 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
3232fn 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
3310fn 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 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
3346fn 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
3376fn 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 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 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 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 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
3426fn 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
3446fn 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
3465fn 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 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 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 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 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, }
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 constraints.push(FieldConstraint::Default(part[8..].to_string()));
3580 }
3581 }
3582 constraints
3583}
3584
3585fn 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 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
3659fn 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
3703fn 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
3735fn 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 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
3766fn 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 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 in_summary = true;
3797 }
3798 continue;
3799 }
3800
3801 if title.is_empty() {
3803 title = trimmed.trim_start_matches('#').trim().to_string();
3804 continue;
3805 }
3806
3807 if subtitle.is_empty() && !in_summary {
3809 subtitle = trimmed.to_string();
3810 in_summary = true;
3811 continue;
3812 }
3813
3814 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 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 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
3854fn 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 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 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 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 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
3991fn parse_use(attrs: &Attrs, content: &str, span: Span) -> Block {
3996 let mut crates = Vec::new();
3997
3998 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 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
4050fn parse_app_env(attrs: &Attrs, content: &str, span: Span) -> Block {
4056 let mut vars = Vec::new();
4057
4058 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 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
4109fn 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
4142fn 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 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 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 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 (label.trim().to_string(), "done".to_string())
4428 } else if let Some(label) = rest.strip_suffix('\u{25CF}') {
4429 (label.trim().to_string(), "active".to_string())
4431 } else if let Some(label) = rest.strip_suffix('\u{25CB}') {
4432 (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#[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[test]
6016 fn resolve_gallery_basic() {
6017 let content = " Suits: Classic black tuxedo\n 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 = " Photo one\n Photo two\n 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 = "\n\n\n\n\n\n";
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 #[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 #[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 Suits: Classic\n 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[test]
8221 fn attrs_on_own_line_adopted() {
8222 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 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 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}