magical markdown slides
at f522cbe1c6de6ff5ea56d79be8bfd98676ccbee5 552 lines 18 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/// Split markdown content on `---` separators 22fn split_slides(markdown: &str) -> Vec<String> { 23 let mut slides = Vec::new(); 24 let mut current = String::new(); 25 26 for line in markdown.lines() { 27 let trimmed = line.trim(); 28 if trimmed == "---" { 29 if !current.trim().is_empty() { 30 slides.push(current); 31 current = String::new(); 32 } 33 } else { 34 current.push_str(line); 35 current.push('\n'); 36 } 37 } 38 39 if !current.trim().is_empty() { 40 slides.push(current); 41 } 42 43 slides 44} 45 46/// Parse a single slide from markdown 47fn parse_slide(markdown: String) -> Result<Slide> { 48 let mut options = Options::empty(); 49 options.insert(Options::ENABLE_TABLES); 50 options.insert(Options::ENABLE_STRIKETHROUGH); 51 let parser = Parser::new_ext(&markdown, options); 52 let mut blocks = Vec::new(); 53 let mut block_stack: Vec<BlockBuilder> = Vec::new(); 54 let mut current_style = TextStyle::default(); 55 56 for event in parser { 57 match event { 58 Event::Start(tag) => match tag { 59 Tag::Heading { level, .. } => { 60 block_stack.push(BlockBuilder::Heading { 61 level: level as u8, 62 spans: Vec::new(), 63 }); 64 } 65 Tag::Paragraph => { 66 block_stack.push(BlockBuilder::Paragraph { 67 spans: Vec::new(), 68 }); 69 } 70 Tag::CodeBlock(kind) => { 71 let language = match kind { 72 pulldown_cmark::CodeBlockKind::Fenced(lang) => { 73 if lang.is_empty() { 74 None 75 } else { 76 Some(lang.to_string()) 77 } 78 } 79 pulldown_cmark::CodeBlockKind::Indented => None, 80 }; 81 block_stack.push(BlockBuilder::Code { 82 language, 83 code: String::new(), 84 }); 85 } 86 Tag::List(first) => { 87 block_stack.push(BlockBuilder::List { 88 ordered: first.is_some(), 89 items: Vec::new(), 90 current_item: Vec::new(), 91 }); 92 } 93 Tag::BlockQuote(_) => { 94 block_stack.push(BlockBuilder::BlockQuote { blocks: Vec::new() }); 95 } 96 Tag::Table(alignments) => { 97 let converted_alignments = alignments 98 .iter() 99 .map(|a| match a { 100 PulldownAlignment::None | PulldownAlignment::Left => Alignment::Left, 101 PulldownAlignment::Center => Alignment::Center, 102 PulldownAlignment::Right => Alignment::Right, 103 }) 104 .collect(); 105 block_stack.push(BlockBuilder::Table { 106 headers: Vec::new(), 107 rows: Vec::new(), 108 current_row: Vec::new(), 109 current_cell: Vec::new(), 110 alignments: converted_alignments, 111 in_header: false, 112 }); 113 } 114 Tag::TableHead => { 115 if let Some(BlockBuilder::Table { in_header, .. }) = block_stack.last_mut() { 116 *in_header = true; 117 } 118 } 119 Tag::TableRow => {} 120 Tag::TableCell => {} 121 Tag::Item => {} 122 Tag::Emphasis => { 123 current_style.italic = true; 124 } 125 Tag::Strong => { 126 current_style.bold = true; 127 } 128 Tag::Strikethrough => { 129 current_style.strikethrough = true; 130 } 131 _ => {} 132 }, 133 134 Event::End(tag_end) => match tag_end { 135 TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { 136 if let Some(builder) = block_stack.pop() { 137 blocks.push(builder.build()); 138 } 139 } 140 TagEnd::List(_) => { 141 if let Some(builder) = block_stack.pop() { 142 blocks.push(builder.build()); 143 } 144 } 145 TagEnd::BlockQuote(_) => { 146 if let Some(builder) = block_stack.pop() { 147 blocks.push(builder.build()); 148 } 149 } 150 TagEnd::Table => { 151 if let Some(builder) = block_stack.pop() { 152 blocks.push(builder.build()); 153 } 154 } 155 TagEnd::TableHead => { 156 if let Some(BlockBuilder::Table { 157 current_row, 158 headers, 159 in_header, 160 .. 161 }) = block_stack.last_mut() 162 { 163 if !current_row.is_empty() { 164 *headers = std::mem::take(current_row); 165 } 166 *in_header = false; 167 } 168 } 169 TagEnd::TableRow => { 170 if let Some(BlockBuilder::Table { 171 current_row, 172 rows, 173 .. 174 }) = block_stack.last_mut() 175 { 176 if !current_row.is_empty() { 177 rows.push(std::mem::take(current_row)); 178 } 179 } 180 } 181 TagEnd::TableCell => { 182 if let Some(BlockBuilder::Table { 183 current_cell, 184 current_row, 185 .. 186 }) = block_stack.last_mut() 187 { 188 current_row.push(std::mem::take(current_cell)); 189 } 190 } 191 TagEnd::Item => { 192 if let Some(BlockBuilder::List { 193 current_item, items, .. 194 }) = block_stack.last_mut() 195 { 196 if !current_item.is_empty() { 197 items.push(ListItem { 198 spans: std::mem::take(current_item), 199 nested: None, 200 }); 201 } 202 } 203 } 204 TagEnd::Emphasis => { 205 current_style.italic = false; 206 } 207 TagEnd::Strong => { 208 current_style.bold = false; 209 } 210 TagEnd::Strikethrough => { 211 current_style.strikethrough = false; 212 } 213 _ => {} 214 }, 215 216 Event::Text(text) => { 217 if let Some(builder) = block_stack.last_mut() { 218 builder.add_text(text.to_string(), &current_style); 219 } 220 } 221 222 Event::Code(code) => { 223 if let Some(builder) = block_stack.last_mut() { 224 builder.add_code_span(code.to_string()); 225 } 226 } 227 228 Event::SoftBreak | Event::HardBreak => { 229 if let Some(builder) = block_stack.last_mut() { 230 builder.add_text(" ".to_string(), &current_style); 231 } 232 } 233 234 Event::Rule => { 235 blocks.push(Block::Rule); 236 } 237 238 _ => {} 239 } 240 } 241 242 Ok(Slide::with_blocks(blocks)) 243} 244 245/// Helper to build blocks while parsing 246enum BlockBuilder { 247 Heading { 248 level: u8, 249 spans: Vec<TextSpan>, 250 }, 251 Paragraph { 252 spans: Vec<TextSpan>, 253 }, 254 Code { 255 language: Option<String>, 256 code: String, 257 }, 258 List { 259 ordered: bool, 260 items: Vec<ListItem>, 261 current_item: Vec<TextSpan>, 262 }, 263 BlockQuote { 264 blocks: Vec<Block>, 265 }, 266 Table { 267 headers: Vec<Vec<TextSpan>>, 268 rows: Vec<Vec<Vec<TextSpan>>>, 269 current_row: Vec<Vec<TextSpan>>, 270 current_cell: Vec<TextSpan>, 271 alignments: Vec<Alignment>, 272 in_header: bool, 273 }, 274} 275 276impl BlockBuilder { 277 fn add_text(&mut self, text: String, current_style: &TextStyle) { 278 match self { 279 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 280 if !text.is_empty() { 281 spans.push(TextSpan { 282 text, 283 style: current_style.clone(), 284 }); 285 } 286 } 287 Self::Code { code, .. } => { 288 code.push_str(&text); 289 } 290 Self::List { current_item, .. } => { 291 if !text.is_empty() { 292 current_item.push(TextSpan { 293 text, 294 style: current_style.clone(), 295 }); 296 } 297 } 298 Self::Table { current_cell, .. } => { 299 if !text.is_empty() { 300 current_cell.push(TextSpan { 301 text, 302 style: current_style.clone(), 303 }); 304 } 305 } 306 _ => {} 307 } 308 } 309 310 fn add_code_span(&mut self, code: String) { 311 match self { 312 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 313 spans.push(TextSpan { 314 text: code, 315 style: TextStyle { 316 code: true, 317 ..Default::default() 318 }, 319 }); 320 } 321 Self::List { current_item, .. } => { 322 current_item.push(TextSpan { 323 text: code, 324 style: TextStyle { 325 code: true, 326 ..Default::default() 327 }, 328 }); 329 } 330 Self::Table { current_cell, .. } => { 331 current_cell.push(TextSpan { 332 text: code, 333 style: TextStyle { 334 code: true, 335 ..Default::default() 336 }, 337 }); 338 } 339 _ => {} 340 } 341 } 342 343 fn build(self) -> Block { 344 match self { 345 Self::Heading { level, spans } => Block::Heading { level, spans }, 346 Self::Paragraph { spans } => Block::Paragraph { spans }, 347 Self::Code { language, code } => Block::Code(CodeBlock { language, code }), 348 Self::List { ordered, items, .. } => Block::List(List { ordered, items }), 349 Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, 350 Self::Table { 351 headers, 352 rows, 353 alignments, 354 .. 355 } => Block::Table(Table { 356 headers, 357 rows, 358 alignments, 359 }), 360 } 361 } 362} 363 364#[cfg(test)] 365mod tests { 366 use super::*; 367 368 #[test] 369 fn split_slides_basic() { 370 let markdown = "# Slide 1\n---\n# Slide 2"; 371 let slides = split_slides(markdown); 372 assert_eq!(slides.len(), 2); 373 assert!(slides[0].contains("Slide 1")); 374 assert!(slides[1].contains("Slide 2")); 375 } 376 377 #[test] 378 fn split_slides_empty() { 379 let markdown = ""; 380 let slides = split_slides(markdown); 381 assert_eq!(slides.len(), 0); 382 } 383 384 #[test] 385 fn split_slides_single() { 386 let markdown = "# Only Slide"; 387 let slides = split_slides(markdown); 388 assert_eq!(slides.len(), 1); 389 } 390 391 #[test] 392 fn parse_heading() { 393 let slides = parse_slides("# Hello World").unwrap(); 394 assert_eq!(slides.len(), 1); 395 396 match &slides[0].blocks[0] { 397 Block::Heading { level, spans } => { 398 assert_eq!(*level, 1); 399 assert_eq!(spans[0].text, "Hello World"); 400 } 401 _ => panic!("Expected heading"), 402 } 403 } 404 405 #[test] 406 fn parse_paragraph() { 407 let slides = parse_slides("This is a paragraph").unwrap(); 408 assert_eq!(slides.len(), 1); 409 410 match &slides[0].blocks[0] { 411 Block::Paragraph { spans } => { 412 assert_eq!(spans[0].text, "This is a paragraph"); 413 } 414 _ => panic!("Expected paragraph"), 415 } 416 } 417 418 #[test] 419 fn parse_code_block() { 420 let markdown = "```rust\nfn main() {}\n```"; 421 let slides = parse_slides(markdown).unwrap(); 422 423 match &slides[0].blocks[0] { 424 Block::Code(code) => { 425 assert_eq!(code.language, Some("rust".to_string())); 426 assert!(code.code.contains("fn main()")); 427 } 428 _ => panic!("Expected code block"), 429 } 430 } 431 432 #[test] 433 fn parse_list() { 434 let markdown = "- Item 1\n- Item 2"; 435 let slides = parse_slides(markdown).unwrap(); 436 437 match &slides[0].blocks[0] { 438 Block::List(list) => { 439 assert!(!list.ordered); 440 assert_eq!(list.items.len(), 2); 441 assert_eq!(list.items[0].spans[0].text, "Item 1"); 442 } 443 _ => panic!("Expected list"), 444 } 445 } 446 447 #[test] 448 fn parse_multiple_slides() { 449 let markdown = "# Slide 1\nContent 1\n---\n# Slide 2\nContent 2"; 450 let slides = parse_slides(markdown).unwrap(); 451 assert_eq!(slides.len(), 2); 452 } 453 454 #[test] 455 fn parse_with_yaml_metadata() { 456 let markdown = r#"--- 457theme: dark 458author: Test Author 459--- 460# First Slide 461Content here 462--- 463# Second Slide 464More content"#; 465 466 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 467 assert_eq!(meta.theme, "dark"); 468 assert_eq!(meta.author, "Test Author"); 469 assert_eq!(slides.len(), 2); 470 } 471 472 #[test] 473 fn parse_with_toml_metadata() { 474 let markdown = r#"+++ 475theme = "monokai" 476author = "Jane Doe" 477+++ 478# Slide One 479Test content"#; 480 481 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 482 assert_eq!(meta.theme, "monokai"); 483 assert_eq!(meta.author, "Jane Doe"); 484 assert_eq!(slides.len(), 1); 485 } 486 487 #[test] 488 fn parse_without_metadata() { 489 let markdown = "# Slide\nContent"; 490 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 491 assert_eq!(meta, Meta::default()); 492 assert_eq!(slides.len(), 1); 493 } 494 495 #[test] 496 fn parse_table() { 497 let markdown = r#"| Name | Age | 498| ---- | --- | 499| Alice | 30 | 500| Bob | 25 |"#; 501 let slides = parse_slides(markdown).unwrap(); 502 assert_eq!(slides.len(), 1); 503 504 match &slides[0].blocks[0] { 505 Block::Table(table) => { 506 assert_eq!(table.headers.len(), 2); 507 assert_eq!(table.rows.len(), 2); 508 assert_eq!(table.headers[0][0].text, "Name"); 509 assert_eq!(table.headers[1][0].text, "Age"); 510 assert_eq!(table.rows[0][0][0].text, "Alice"); 511 assert_eq!(table.rows[0][1][0].text, "30"); 512 assert_eq!(table.rows[1][0][0].text, "Bob"); 513 assert_eq!(table.rows[1][1][0].text, "25"); 514 } 515 _ => panic!("Expected table"), 516 } 517 } 518 519 #[test] 520 fn parse_table_with_alignment() { 521 let markdown = r#"| Left | Center | Right | 522| :--- | :----: | ----: | 523| A | B | C |"#; 524 let slides = parse_slides(markdown).unwrap(); 525 526 match &slides[0].blocks[0] { 527 Block::Table(table) => { 528 assert_eq!(table.alignments.len(), 3); 529 assert!(matches!(table.alignments[0], Alignment::Left)); 530 assert!(matches!(table.alignments[1], Alignment::Center)); 531 assert!(matches!(table.alignments[2], Alignment::Right)); 532 } 533 _ => panic!("Expected table"), 534 } 535 } 536 537 #[test] 538 fn parse_table_with_styled_text() { 539 let markdown = r#"| Name | Status | 540| ---- | ------ | 541| **Bold** | `code` |"#; 542 let slides = parse_slides(markdown).unwrap(); 543 544 match &slides[0].blocks[0] { 545 Block::Table(table) => { 546 assert!(table.rows[0][0][0].style.bold); 547 assert!(table.rows[0][1][0].style.code); 548 } 549 _ => panic!("Expected table"), 550 } 551 } 552}