magical markdown slides
at main 36 kB view raw
1use crate::error::Result; 2use crate::metadata::Meta; 3use crate::slide::*; 4use pulldown_cmark::{Alignment as PulldownAlignment, Event, Options, Parser, Tag, TagEnd}; 5 6/// Parse markdown content into metadata and slides 7/// 8/// Extracts frontmatter metadata, then splits content on `---` separators. 9pub fn parse_slides_with_meta(markdown: &str) -> Result<(Meta, Vec<Slide>)> { 10 let (meta, content) = Meta::extract_from_markdown(markdown)?; 11 let slides = parse_slides(&content)?; 12 Ok((meta, slides)) 13} 14 15/// Parse markdown content into a vector of slides 16pub fn parse_slides(markdown: &str) -> Result<Vec<Slide>> { 17 let sections = split_slides(markdown); 18 sections.into_iter().map(parse_slide).collect() 19} 20 21/// Preprocess markdown to convert admonition syntax to a format we can parse 22/// 23/// Converts both GitHub/Obsidian syntax (`> [!NOTE]`) and fence syntax (`:::note`) 24/// into a special HTML-like format that we can detect in the event stream 25fn preprocess_admonitions(markdown: &str) -> String { 26 let mut result = String::new(); 27 let lines: Vec<&str> = markdown.lines().collect(); 28 let mut i = 0; 29 30 while i < lines.len() { 31 let line = lines[i]; 32 let trimmed = line.trim(); 33 34 if let Some(admonition_type) = parse_fence_admonition(trimmed) { 35 result.push_str(&format!("<admonition type=\"{admonition_type}\">\n")); 36 i += 1; 37 while i < lines.len() { 38 let content_line = lines[i]; 39 if content_line.trim() == ":::" { 40 result.push_str("</admonition>\n"); 41 i += 1; 42 break; 43 } 44 result.push_str(content_line); 45 result.push('\n'); 46 i += 1; 47 } 48 continue; 49 } 50 51 if trimmed.starts_with('>') { 52 if let Some((admonition_type, title)) = parse_blockquote_admonition(trimmed) { 53 result.push_str(&format!("<admonition type=\"{admonition_type}\"")); 54 if let Some(t) = title { 55 result.push_str(&format!(" title=\"{t}\"")); 56 } 57 result.push_str(">\n"); 58 i += 1; 59 60 while i < lines.len() { 61 let next_line = lines[i]; 62 let next_trimmed = next_line.trim(); 63 if next_trimmed.starts_with('>') { 64 let content = next_trimmed.strip_prefix('>').unwrap_or("").trim(); 65 if !content.is_empty() { 66 result.push_str(content); 67 result.push('\n'); 68 } 69 i += 1; 70 } else { 71 break; 72 } 73 } 74 result.push_str("</admonition>\n"); 75 continue; 76 } 77 } 78 79 result.push_str(line); 80 result.push('\n'); 81 i += 1; 82 } 83 84 result 85} 86 87/// Parse fence-style admonition: `:::note` or `:::warning Title` 88fn parse_fence_admonition(line: &str) -> Option<String> { 89 let trimmed = line.trim(); 90 if !trimmed.starts_with(":::") { 91 return None; 92 } 93 94 let content = trimmed.strip_prefix(":::").unwrap_or("").trim(); 95 if content.is_empty() { 96 return None; 97 } 98 99 let parts: Vec<&str> = content.splitn(2, ' ').collect(); 100 let admonition_type = parts[0].to_lowercase(); 101 102 if admonition_type.is_empty() { None } else { Some(admonition_type) } 103} 104 105/// Parse blockquote-style admonition: `> [!NOTE]` or `> [!TIP] Custom Title` 106fn parse_blockquote_admonition(line: &str) -> Option<(String, Option<String>)> { 107 let content = line.trim().strip_prefix('>')?.trim(); 108 109 if !content.starts_with("[!") { 110 return None; 111 } 112 113 let rest = content.strip_prefix("[!")?; 114 let close_bracket = rest.find(']')?; 115 let admonition_type = rest[..close_bracket].to_lowercase(); 116 117 let title = rest[close_bracket + 1..].trim(); 118 let title = if title.is_empty() { None } else { Some(title.to_string()) }; 119 120 Some((admonition_type, title)) 121} 122 123/// Parse HTML admonition tag: `<admonition type="note" title="Title">` 124fn parse_admonition_html_start(html: &str) -> Option<(AdmonitionType, Option<String>)> { 125 let html = html.trim(); 126 if !html.starts_with("<admonition") { 127 return None; 128 } 129 130 let type_start = html.find("type=\"")?; 131 let type_value_start = type_start + 6; 132 let type_end = html[type_value_start..].find('"')? + type_value_start; 133 let admonition_type_str = &html[type_value_start..type_end]; 134 let admonition_type = admonition_type_str.parse().ok()?; 135 136 let title = if let Some(title_start) = html.find("title=\"") { 137 let title_value_start = title_start + 7; 138 let title_end = html[title_value_start..].find('"')? + title_value_start; 139 Some(html[title_value_start..title_end].to_string()) 140 } else { 141 None 142 }; 143 144 Some((admonition_type, title)) 145} 146 147/// Split markdown content on `---` separators 148/// 149/// Ignores `---` inside fenced code blocks to avoid incorrect slide splits 150fn split_slides(markdown: &str) -> Vec<String> { 151 let mut slides = Vec::new(); 152 let mut current = String::new(); 153 let mut in_code_block = false; 154 155 for line in markdown.lines() { 156 let trimmed = line.trim(); 157 158 if trimmed.starts_with("```") || trimmed.starts_with("~~~") { 159 in_code_block = !in_code_block; 160 } 161 162 if trimmed == "---" && !in_code_block { 163 if !current.trim().is_empty() { 164 slides.push(current); 165 current = String::new(); 166 } 167 } else { 168 current.push_str(line); 169 current.push('\n'); 170 } 171 } 172 173 if !current.trim().is_empty() { 174 slides.push(current); 175 } 176 177 slides 178} 179 180/// Parse a single slide from markdown 181fn parse_slide(markdown: String) -> Result<Slide> { 182 let preprocessed = preprocess_admonitions(&markdown); 183 let mut options = Options::empty(); 184 options.insert(Options::ENABLE_TABLES); 185 options.insert(Options::ENABLE_STRIKETHROUGH); 186 let parser = Parser::new_ext(&preprocessed, options); 187 let mut blocks = Vec::new(); 188 let mut block_stack: Vec<BlockBuilder> = Vec::new(); 189 let mut current_style = TextStyle::default(); 190 191 for event in parser { 192 match event { 193 Event::Start(tag) => match tag { 194 Tag::Heading { level, .. } => { 195 block_stack.push(BlockBuilder::Heading { level: level as u8, spans: Vec::new() }); 196 } 197 Tag::Paragraph => { 198 block_stack.push(BlockBuilder::Paragraph { spans: Vec::new() }); 199 } 200 Tag::CodeBlock(kind) => { 201 let language = match kind { 202 pulldown_cmark::CodeBlockKind::Fenced(lang) => { 203 if lang.is_empty() { 204 None 205 } else { 206 Some(lang.to_string()) 207 } 208 } 209 pulldown_cmark::CodeBlockKind::Indented => None, 210 }; 211 block_stack.push(BlockBuilder::Code { language, code: String::new() }); 212 } 213 Tag::List(first) => { 214 block_stack.push(BlockBuilder::List { 215 ordered: first.is_some(), 216 items: Vec::new(), 217 current_item: Vec::new(), 218 pending_nested: None, 219 }); 220 } 221 Tag::BlockQuote(_) => { 222 block_stack.push(BlockBuilder::BlockQuote { blocks: Vec::new() }); 223 } 224 Tag::Table(alignments) => { 225 let converted_alignments = alignments 226 .iter() 227 .map(|a| match a { 228 PulldownAlignment::None | PulldownAlignment::Left => Alignment::Left, 229 PulldownAlignment::Center => Alignment::Center, 230 PulldownAlignment::Right => Alignment::Right, 231 }) 232 .collect(); 233 block_stack.push(BlockBuilder::Table { 234 headers: Vec::new(), 235 rows: Vec::new(), 236 current_row: Vec::new(), 237 current_cell: Vec::new(), 238 alignments: converted_alignments, 239 in_header: false, 240 }); 241 } 242 Tag::TableHead => { 243 if let Some(BlockBuilder::Table { in_header, .. }) = block_stack.last_mut() { 244 *in_header = true; 245 } 246 } 247 Tag::TableRow => {} 248 Tag::TableCell => {} 249 Tag::Item => {} 250 Tag::Emphasis => { 251 current_style.italic = true; 252 } 253 Tag::Strong => { 254 current_style.bold = true; 255 } 256 Tag::Strikethrough => { 257 current_style.strikethrough = true; 258 } 259 Tag::Image { dest_url, .. } => { 260 block_stack.push(BlockBuilder::Image { path: dest_url.to_string(), alt: String::new() }); 261 } 262 _ => {} 263 }, 264 265 Event::End(tag_end) => match tag_end { 266 TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { 267 if let Some(builder) = block_stack.pop() { 268 let block = builder.build(); 269 if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 270 adm_blocks.push(block); 271 } else { 272 blocks.push(block); 273 } 274 } 275 } 276 TagEnd::List(_) => { 277 if let Some(builder) = block_stack.pop() { 278 let block = builder.build(); 279 280 if let Some(BlockBuilder::List { pending_nested, .. }) = block_stack.last_mut() { 281 if let Block::List(list) = block { 282 *pending_nested = Some(list); 283 } 284 } else if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() 285 { 286 adm_blocks.push(block); 287 } else { 288 blocks.push(block); 289 } 290 } 291 } 292 TagEnd::BlockQuote(_) => { 293 if let Some(builder) = block_stack.pop() { 294 let block = builder.build(); 295 if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 296 adm_blocks.push(block); 297 } else { 298 blocks.push(block); 299 } 300 } 301 } 302 TagEnd::Table => { 303 if let Some(builder) = block_stack.pop() { 304 let block = builder.build(); 305 if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 306 adm_blocks.push(block); 307 } else { 308 blocks.push(block); 309 } 310 } 311 } 312 TagEnd::TableHead => { 313 if let Some(BlockBuilder::Table { current_row, headers, in_header, .. }) = block_stack.last_mut() { 314 if !current_row.is_empty() { 315 *headers = std::mem::take(current_row); 316 } 317 *in_header = false; 318 } 319 } 320 TagEnd::TableRow => { 321 if let Some(BlockBuilder::Table { current_row, rows, .. }) = block_stack.last_mut() { 322 if !current_row.is_empty() { 323 rows.push(std::mem::take(current_row)); 324 } 325 } 326 } 327 TagEnd::TableCell => { 328 if let Some(BlockBuilder::Table { current_cell, current_row, .. }) = block_stack.last_mut() { 329 current_row.push(std::mem::take(current_cell)); 330 } 331 } 332 TagEnd::Item => { 333 if let Some(BlockBuilder::List { current_item, items, pending_nested, .. }) = block_stack.last_mut() 334 { 335 if !current_item.is_empty() { 336 let nested = pending_nested.take().map(Box::new); 337 items.push(ListItem { spans: std::mem::take(current_item), nested }); 338 } 339 } 340 } 341 TagEnd::Emphasis => { 342 current_style.italic = false; 343 } 344 TagEnd::Strong => { 345 current_style.bold = false; 346 } 347 TagEnd::Strikethrough => { 348 current_style.strikethrough = false; 349 } 350 TagEnd::Image => { 351 if let Some(builder) = block_stack.pop() { 352 let block = builder.build(); 353 if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 354 adm_blocks.push(block); 355 } else { 356 blocks.push(block); 357 } 358 } 359 } 360 _ => {} 361 }, 362 363 Event::Text(text) => { 364 if let Some(builder) = block_stack.last_mut() { 365 builder.add_text(text.to_string(), &current_style); 366 } 367 } 368 369 Event::Code(code) => { 370 if let Some(builder) = block_stack.last_mut() { 371 builder.add_code_span(code.to_string()); 372 } 373 } 374 375 Event::SoftBreak | Event::HardBreak => { 376 if let Some(builder) = block_stack.last_mut() { 377 builder.add_text(" ".to_string(), &current_style); 378 } 379 } 380 381 Event::Rule => { 382 blocks.push(Block::Rule); 383 } 384 385 Event::Html(html) => { 386 if let Some((admonition_type, title)) = parse_admonition_html_start(&html) { 387 block_stack.push(BlockBuilder::Admonition { admonition_type, title, blocks: Vec::new() }); 388 } else if html.trim().starts_with("</admonition>") { 389 if let Some(builder) = block_stack.pop() { 390 blocks.push(builder.build()); 391 } 392 } else if !block_stack.is_empty() { 393 if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 394 let inner_markdown = html.to_string(); 395 let inner_options = Options::empty(); 396 let inner_parser = Parser::new_ext(&inner_markdown, inner_options); 397 let mut inner_block_stack: Vec<BlockBuilder> = Vec::new(); 398 let inner_style = TextStyle::default(); 399 400 for inner_event in inner_parser { 401 match inner_event { 402 Event::Start(Tag::Paragraph) => { 403 inner_block_stack.push(BlockBuilder::Paragraph { spans: Vec::new() }); 404 } 405 Event::Text(text) => { 406 if let Some(builder) = inner_block_stack.last_mut() { 407 builder.add_text(text.to_string(), &inner_style); 408 } 409 } 410 Event::End(TagEnd::Paragraph) => { 411 if let Some(builder) = inner_block_stack.pop() { 412 adm_blocks.push(builder.build()); 413 } 414 } 415 _ => {} 416 } 417 } 418 } 419 } 420 } 421 422 _ => {} 423 } 424 } 425 426 Ok(Slide::with_blocks(blocks)) 427} 428 429/// Helper to build blocks while parsing 430enum BlockBuilder { 431 Heading { 432 level: u8, 433 spans: Vec<TextSpan>, 434 }, 435 Paragraph { 436 spans: Vec<TextSpan>, 437 }, 438 Code { 439 language: Option<String>, 440 code: String, 441 }, 442 List { 443 ordered: bool, 444 items: Vec<ListItem>, 445 current_item: Vec<TextSpan>, 446 pending_nested: Option<List>, 447 }, 448 BlockQuote { 449 blocks: Vec<Block>, 450 }, 451 Table { 452 headers: Vec<Vec<TextSpan>>, 453 rows: Vec<Vec<Vec<TextSpan>>>, 454 current_row: Vec<Vec<TextSpan>>, 455 current_cell: Vec<TextSpan>, 456 alignments: Vec<Alignment>, 457 in_header: bool, 458 }, 459 Admonition { 460 admonition_type: AdmonitionType, 461 title: Option<String>, 462 blocks: Vec<Block>, 463 }, 464 Image { 465 path: String, 466 alt: String, 467 }, 468} 469 470impl BlockBuilder { 471 fn add_text(&mut self, text: String, current_style: &TextStyle) { 472 match self { 473 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 474 if !text.is_empty() { 475 spans.push(TextSpan { text, style: current_style.clone() }); 476 } 477 } 478 Self::Code { code, .. } => { 479 code.push_str(&text); 480 } 481 Self::List { current_item, .. } => { 482 if !text.is_empty() { 483 current_item.push(TextSpan { text, style: current_style.clone() }); 484 } 485 } 486 Self::Table { current_cell, .. } => { 487 if !text.is_empty() { 488 current_cell.push(TextSpan { text, style: current_style.clone() }); 489 } 490 } 491 Self::Image { alt, .. } => { 492 alt.push_str(&text); 493 } 494 Self::Admonition { .. } => {} 495 _ => {} 496 } 497 } 498 499 fn add_code_span(&mut self, code: String) { 500 match self { 501 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 502 spans.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } }); 503 } 504 Self::List { current_item, .. } => { 505 current_item.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } }); 506 } 507 Self::Table { current_cell, .. } => { 508 current_cell.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } }); 509 } 510 Self::Admonition { .. } => {} 511 _ => {} 512 } 513 } 514 515 fn build(self) -> Block { 516 match self { 517 Self::Heading { level, spans } => Block::Heading { level, spans }, 518 Self::Paragraph { spans } => Block::Paragraph { spans }, 519 Self::Code { language, code } => Block::Code(CodeBlock { language, code }), 520 Self::List { ordered, items, .. } => Block::List(List { ordered, items }), 521 Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, 522 Self::Table { headers, rows, alignments, .. } => Block::Table(Table { headers, rows, alignments }), 523 Self::Admonition { admonition_type, title, blocks } => { 524 Block::Admonition(Admonition { admonition_type, title, blocks }) 525 } 526 Self::Image { path, alt } => Block::Image { path, alt }, 527 } 528 } 529} 530 531#[cfg(test)] 532mod tests { 533 use super::*; 534 535 #[test] 536 fn split_slides_basic() { 537 let markdown = "# Slide 1\n---\n# Slide 2"; 538 let slides = split_slides(markdown); 539 assert_eq!(slides.len(), 2); 540 assert!(slides[0].contains("Slide 1")); 541 assert!(slides[1].contains("Slide 2")); 542 } 543 544 #[test] 545 fn split_slides_empty() { 546 let markdown = ""; 547 let slides = split_slides(markdown); 548 assert_eq!(slides.len(), 0); 549 } 550 551 #[test] 552 fn split_slides_single() { 553 let markdown = "# Only Slide"; 554 let slides = split_slides(markdown); 555 assert_eq!(slides.len(), 1); 556 } 557 558 #[test] 559 fn split_slides_ignores_separator_in_code_block() { 560 let markdown = r#"# Slide 1 561 562```markdown 563--- 564``` 565 566Content after code block 567 568--- 569 570# Slide 2"#; 571 let slides = split_slides(markdown); 572 assert_eq!(slides.len(), 2); 573 assert!(slides[0].contains("Slide 1")); 574 assert!(slides[0].contains("---")); 575 assert!(slides[0].contains("Content after code block")); 576 assert!(slides[1].contains("Slide 2")); 577 } 578 579 #[test] 580 fn parse_heading() { 581 let slides = parse_slides("# Hello World").unwrap(); 582 assert_eq!(slides.len(), 1); 583 584 match &slides[0].blocks[0] { 585 Block::Heading { level, spans } => { 586 assert_eq!(*level, 1); 587 assert_eq!(spans[0].text, "Hello World"); 588 } 589 _ => panic!("Expected heading"), 590 } 591 } 592 593 #[test] 594 fn parse_paragraph() { 595 let slides = parse_slides("This is a paragraph").unwrap(); 596 assert_eq!(slides.len(), 1); 597 598 match &slides[0].blocks[0] { 599 Block::Paragraph { spans } => { 600 assert_eq!(spans[0].text, "This is a paragraph"); 601 } 602 _ => panic!("Expected paragraph"), 603 } 604 } 605 606 #[test] 607 fn parse_code_block() { 608 let markdown = "```rust\nfn main() {}\n```"; 609 let slides = parse_slides(markdown).unwrap(); 610 611 match &slides[0].blocks[0] { 612 Block::Code(code) => { 613 assert_eq!(code.language, Some("rust".to_string())); 614 assert!(code.code.contains("fn main()")); 615 } 616 _ => panic!("Expected code block"), 617 } 618 } 619 620 #[test] 621 fn parse_list() { 622 let markdown = "- Item 1\n- Item 2"; 623 let slides = parse_slides(markdown).unwrap(); 624 625 match &slides[0].blocks[0] { 626 Block::List(list) => { 627 assert!(!list.ordered); 628 assert_eq!(list.items.len(), 2); 629 assert_eq!(list.items[0].spans[0].text, "Item 1"); 630 } 631 _ => panic!("Expected list"), 632 } 633 } 634 635 #[test] 636 fn parse_nested_unordered_list() { 637 let markdown = "- Item 1\n - Nested 1\n - Nested 2\n- Item 2"; 638 let slides = parse_slides(markdown).unwrap(); 639 640 match &slides[0].blocks[0] { 641 Block::List(list) => { 642 assert!(!list.ordered); 643 assert_eq!(list.items.len(), 2); 644 assert_eq!(list.items[0].spans[0].text, "Item 1"); 645 646 let nested = list.items[0].nested.as_ref().expect("Expected nested list"); 647 assert!(!nested.ordered); 648 assert_eq!(nested.items.len(), 2); 649 assert_eq!(nested.items[0].spans[0].text, "Nested 1"); 650 assert_eq!(nested.items[1].spans[0].text, "Nested 2"); 651 } 652 _ => panic!("Expected list"), 653 } 654 } 655 656 #[test] 657 fn parse_nested_ordered_list() { 658 let markdown = "1. First item\n 1. Nested first\n 2. Nested second\n2. Second item"; 659 let slides = parse_slides(markdown).unwrap(); 660 661 match &slides[0].blocks[0] { 662 Block::List(list) => { 663 assert!(list.ordered); 664 assert_eq!(list.items.len(), 2); 665 assert_eq!(list.items[0].spans[0].text, "First item"); 666 667 let nested = list.items[0].nested.as_ref().expect("Expected nested list"); 668 assert!(nested.ordered); 669 assert_eq!(nested.items.len(), 2); 670 assert_eq!(nested.items[0].spans[0].text, "Nested first"); 671 assert_eq!(nested.items[1].spans[0].text, "Nested second"); 672 } 673 _ => panic!("Expected list"), 674 } 675 } 676 677 #[test] 678 fn parse_mixed_nested_list() { 679 let markdown = "- Unordered item\n 1. Ordered nested\n 2. Another ordered\n- Second unordered"; 680 let slides = parse_slides(markdown).unwrap(); 681 682 match &slides[0].blocks[0] { 683 Block::List(list) => { 684 assert!(!list.ordered); 685 assert_eq!(list.items.len(), 2); 686 assert_eq!(list.items[0].spans[0].text, "Unordered item"); 687 688 let nested = list.items[0].nested.as_ref().expect("Expected nested list"); 689 assert!(nested.ordered); 690 assert_eq!(nested.items.len(), 2); 691 assert_eq!(nested.items[0].spans[0].text, "Ordered nested"); 692 } 693 _ => panic!("Expected list"), 694 } 695 } 696 697 #[test] 698 fn parse_deeply_nested_list() { 699 let markdown = "- Level 1\n - Level 2\n - Level 3\n - Back to level 2"; 700 let slides = parse_slides(markdown).unwrap(); 701 702 match &slides[0].blocks[0] { 703 Block::List(list) => { 704 assert!(!list.ordered); 705 assert_eq!(list.items.len(), 1); 706 assert_eq!(list.items[0].spans[0].text, "Level 1"); 707 708 let level2 = list.items[0].nested.as_ref().expect("Expected level 2"); 709 assert_eq!(level2.items.len(), 2); 710 assert_eq!(level2.items[0].spans[0].text, "Level 2"); 711 assert_eq!(level2.items[1].spans[0].text, "Back to level 2"); 712 713 let level3 = level2.items[0].nested.as_ref().expect("Expected level 3"); 714 assert_eq!(level3.items.len(), 1); 715 assert_eq!(level3.items[0].spans[0].text, "Level 3"); 716 } 717 _ => panic!("Expected list"), 718 } 719 } 720 721 #[test] 722 fn parse_multiple_slides() { 723 let markdown = "# Slide 1\nContent 1\n---\n# Slide 2\nContent 2"; 724 let slides = parse_slides(markdown).unwrap(); 725 assert_eq!(slides.len(), 2); 726 } 727 728 #[test] 729 fn parse_with_yaml_metadata() { 730 let markdown = r#"--- 731theme: dark 732author: Test Author 733--- 734# First Slide 735Content here 736--- 737# Second Slide 738More content"#; 739 740 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 741 assert_eq!(meta.theme, "dark"); 742 assert_eq!(meta.author, "Test Author"); 743 assert_eq!(slides.len(), 2); 744 } 745 746 #[test] 747 fn parse_with_toml_metadata() { 748 let markdown = r#"+++ 749theme = "monokai" 750author = "Jane Doe" 751+++ 752# Slide One 753Test content"#; 754 755 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 756 assert_eq!(meta.theme, "monokai"); 757 assert_eq!(meta.author, "Jane Doe"); 758 assert_eq!(slides.len(), 1); 759 } 760 761 #[test] 762 fn parse_without_metadata() { 763 let markdown = "# Slide\nContent"; 764 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 765 assert_eq!(meta, Meta::default()); 766 assert_eq!(slides.len(), 1); 767 } 768 769 #[test] 770 fn parse_table() { 771 let markdown = r#"| Name | Age | 772| ---- | --- | 773| Alice | 30 | 774| Bob | 25 |"#; 775 let slides = parse_slides(markdown).unwrap(); 776 assert_eq!(slides.len(), 1); 777 778 match &slides[0].blocks[0] { 779 Block::Table(table) => { 780 assert_eq!(table.headers.len(), 2); 781 assert_eq!(table.rows.len(), 2); 782 assert_eq!(table.headers[0][0].text, "Name"); 783 assert_eq!(table.headers[1][0].text, "Age"); 784 assert_eq!(table.rows[0][0][0].text, "Alice"); 785 assert_eq!(table.rows[0][1][0].text, "30"); 786 assert_eq!(table.rows[1][0][0].text, "Bob"); 787 assert_eq!(table.rows[1][1][0].text, "25"); 788 } 789 _ => panic!("Expected table"), 790 } 791 } 792 793 #[test] 794 fn parse_table_with_alignment() { 795 let markdown = r#"| Left | Center | Right | 796| :--- | :----: | ----: | 797| A | B | C |"#; 798 let slides = parse_slides(markdown).unwrap(); 799 800 match &slides[0].blocks[0] { 801 Block::Table(table) => { 802 assert_eq!(table.alignments.len(), 3); 803 assert!(matches!(table.alignments[0], Alignment::Left)); 804 assert!(matches!(table.alignments[1], Alignment::Center)); 805 assert!(matches!(table.alignments[2], Alignment::Right)); 806 } 807 _ => panic!("Expected table"), 808 } 809 } 810 811 #[test] 812 fn parse_table_with_styled_text() { 813 let markdown = r#"| Name | Status | 814| ---- | ------ | 815| **Bold** | `code` |"#; 816 let slides = parse_slides(markdown).unwrap(); 817 818 match &slides[0].blocks[0] { 819 Block::Table(table) => { 820 assert!(table.rows[0][0][0].style.bold); 821 assert!(table.rows[0][1][0].style.code); 822 } 823 _ => panic!("Expected table"), 824 } 825 } 826 827 #[test] 828 fn preprocess_github_admonition() { 829 let markdown = r#"> [!NOTE] 830> This is a note"#; 831 let preprocessed = preprocess_admonitions(markdown); 832 assert!(preprocessed.contains("<admonition")); 833 assert!(preprocessed.contains("type=\"note\"")); 834 assert!(preprocessed.contains("</admonition>")); 835 836 let mut options = Options::empty(); 837 options.insert(Options::ENABLE_TABLES); 838 options.insert(Options::ENABLE_STRIKETHROUGH); 839 let parser = Parser::new_ext(&preprocessed, options); 840 let events: Vec<_> = parser.collect(); 841 842 let has_html = events.iter().any(|e| matches!(e, Event::Html(_))); 843 assert!(has_html, "Should have HTML events"); 844 } 845 846 #[test] 847 fn parse_github_admonition_note() { 848 let markdown = r#"> [!NOTE] 849> This is a note"#; 850 let slides = parse_slides(markdown).unwrap(); 851 assert_eq!(slides.len(), 1); 852 853 match &slides[0].blocks[0] { 854 Block::Admonition(admonition) => { 855 assert_eq!(admonition.admonition_type, AdmonitionType::Note); 856 assert_eq!(admonition.title, None); 857 assert_eq!(admonition.blocks.len(), 1); 858 } 859 _ => panic!("Expected admonition, got: {:?}", slides[0].blocks[0]), 860 } 861 } 862 863 #[test] 864 fn parse_github_admonition_with_title() { 865 let markdown = r#"> [!WARNING] Custom Warning 866> Be careful!"#; 867 let slides = parse_slides(markdown).unwrap(); 868 869 match &slides[0].blocks[0] { 870 Block::Admonition(admonition) => { 871 assert_eq!(admonition.admonition_type, AdmonitionType::Warning); 872 assert_eq!(admonition.title, Some("Custom Warning".to_string())); 873 assert_eq!(admonition.blocks.len(), 1); 874 } 875 _ => panic!("Expected admonition"), 876 } 877 } 878 879 #[test] 880 fn parse_fence_admonition() { 881 let markdown = r#":::tip 882This is a helpful tip 883:::"#; 884 let slides = parse_slides(markdown).unwrap(); 885 assert_eq!(slides.len(), 1); 886 887 match &slides[0].blocks[0] { 888 Block::Admonition(admonition) => { 889 assert_eq!(admonition.admonition_type, AdmonitionType::Tip); 890 assert_eq!(admonition.blocks.len(), 1); 891 } 892 _ => panic!("Expected admonition"), 893 } 894 } 895 896 #[test] 897 fn parse_admonition_danger_alias() { 898 let markdown = r#"> [!DANGER] 899> Dangerous content"#; 900 let slides = parse_slides(markdown).unwrap(); 901 902 match &slides[0].blocks[0] { 903 Block::Admonition(admonition) => { 904 assert_eq!(admonition.admonition_type, AdmonitionType::Danger); 905 } 906 _ => panic!("Expected admonition"), 907 } 908 } 909 910 #[test] 911 fn admonition_type_from_str_note() { 912 assert_eq!("note".parse::<AdmonitionType>(), Ok(AdmonitionType::Note)); 913 assert_eq!("NOTE".parse::<AdmonitionType>(), Ok(AdmonitionType::Note)); 914 } 915 916 #[test] 917 fn admonition_type_from_str_tip_alias() { 918 assert_eq!("tip".parse::<AdmonitionType>(), Ok(AdmonitionType::Tip)); 919 assert_eq!("hint".parse::<AdmonitionType>(), Ok(AdmonitionType::Tip)); 920 } 921 922 #[test] 923 fn admonition_type_from_str_warning_aliases() { 924 assert_eq!("warning".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning)); 925 assert_eq!("caution".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning)); 926 assert_eq!("attention".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning)); 927 } 928 929 #[test] 930 fn admonition_type_from_str_invalid() { 931 assert!("invalid".parse::<AdmonitionType>().is_err()); 932 assert!("".parse::<AdmonitionType>().is_err()); 933 } 934 935 #[test] 936 fn parse_image() { 937 let markdown = "![Test image](path/to/image.png)"; 938 let slides = parse_slides(markdown).unwrap(); 939 assert_eq!(slides.len(), 1); 940 941 match &slides[0].blocks[0] { 942 Block::Image { path, alt } => { 943 assert_eq!(path, "path/to/image.png"); 944 assert_eq!(alt, "Test image"); 945 } 946 _ => panic!("Expected image block"), 947 } 948 } 949 950 #[test] 951 fn parse_image_no_alt_text() { 952 let markdown = "![](image.jpg)"; 953 let slides = parse_slides(markdown).unwrap(); 954 955 match &slides[0].blocks[0] { 956 Block::Image { path, alt } => { 957 assert_eq!(path, "image.jpg"); 958 assert_eq!(alt, ""); 959 } 960 _ => panic!("Expected image block"), 961 } 962 } 963 964 #[test] 965 fn parse_image_with_absolute_path() { 966 let markdown = "![Diagram](/home/user/diagram.svg)"; 967 let slides = parse_slides(markdown).unwrap(); 968 969 match &slides[0].blocks[0] { 970 Block::Image { path, alt } => { 971 assert_eq!(path, "/home/user/diagram.svg"); 972 assert_eq!(alt, "Diagram"); 973 } 974 _ => panic!("Expected image block"), 975 } 976 } 977 978 #[test] 979 fn parse_multiple_images() { 980 let markdown = "![First](image1.png)\n\n![Second](image2.png)"; 981 let slides = parse_slides(markdown).unwrap(); 982 983 let image_blocks: Vec<_> = slides[0] 984 .blocks 985 .iter() 986 .filter(|b| matches!(b, Block::Image { .. })) 987 .collect(); 988 989 assert_eq!(image_blocks.len(), 2); 990 991 match image_blocks[0] { 992 Block::Image { path, alt } => { 993 assert_eq!(path, "image1.png"); 994 assert_eq!(alt, "First"); 995 } 996 _ => panic!("Expected image block"), 997 } 998 999 match image_blocks[1] { 1000 Block::Image { path, alt } => { 1001 assert_eq!(path, "image2.png"); 1002 assert_eq!(alt, "Second"); 1003 } 1004 _ => panic!("Expected image block"), 1005 } 1006 } 1007}