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