magical markdown slides
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(), ¤t_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(), ¤t_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 = "";
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 = "";
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 = "";
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 = "\n\n";
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}