magical markdown slides
at 769f04da397e1abfaaa62271b484e1d3cdcfa8e5 398 lines 12 kB view raw
1use crate::error::Result; 2use crate::metadata::Meta; 3use crate::slide::*; 4use pulldown_cmark::{Event, 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 parser = Parser::new(&markdown); 49 let mut blocks = Vec::new(); 50 let mut block_stack: Vec<BlockBuilder> = Vec::new(); 51 let mut current_style = TextStyle::default(); 52 53 for event in parser { 54 match event { 55 Event::Start(tag) => match tag { 56 Tag::Heading { level, .. } => { 57 block_stack.push(BlockBuilder::Heading { 58 level: level as u8, 59 spans: Vec::new(), 60 style: current_style.clone(), 61 }); 62 } 63 Tag::Paragraph => { 64 block_stack.push(BlockBuilder::Paragraph { 65 spans: Vec::new(), 66 style: current_style.clone(), 67 }); 68 } 69 Tag::CodeBlock(kind) => { 70 let language = match kind { 71 pulldown_cmark::CodeBlockKind::Fenced(lang) => { 72 if lang.is_empty() { 73 None 74 } else { 75 Some(lang.to_string()) 76 } 77 } 78 pulldown_cmark::CodeBlockKind::Indented => None, 79 }; 80 block_stack.push(BlockBuilder::Code { 81 language, 82 code: String::new(), 83 }); 84 } 85 Tag::List(first) => { 86 block_stack.push(BlockBuilder::List { 87 ordered: first.is_some(), 88 items: Vec::new(), 89 current_item: Vec::new(), 90 style: current_style.clone(), 91 }); 92 } 93 Tag::BlockQuote(_) => { 94 block_stack.push(BlockBuilder::BlockQuote { blocks: Vec::new() }); 95 } 96 Tag::Item => {} 97 Tag::Emphasis => { 98 current_style.italic = true; 99 } 100 Tag::Strong => { 101 current_style.bold = true; 102 } 103 Tag::Strikethrough => { 104 current_style.strikethrough = true; 105 } 106 _ => {} 107 }, 108 109 Event::End(tag_end) => match tag_end { 110 TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { 111 if let Some(builder) = block_stack.pop() { 112 blocks.push(builder.build()); 113 } 114 } 115 TagEnd::List(_) => { 116 if let Some(builder) = block_stack.pop() { 117 blocks.push(builder.build()); 118 } 119 } 120 TagEnd::BlockQuote(_) => { 121 if let Some(builder) = block_stack.pop() { 122 blocks.push(builder.build()); 123 } 124 } 125 TagEnd::Item => { 126 if let Some(BlockBuilder::List { 127 current_item, items, .. 128 }) = block_stack.last_mut() 129 { 130 if !current_item.is_empty() { 131 items.push(ListItem { 132 spans: current_item.drain(..).collect(), 133 nested: None, 134 }); 135 } 136 } 137 } 138 TagEnd::Emphasis => { 139 current_style.italic = false; 140 } 141 TagEnd::Strong => { 142 current_style.bold = false; 143 } 144 TagEnd::Strikethrough => { 145 current_style.strikethrough = false; 146 } 147 _ => {} 148 }, 149 150 Event::Text(text) => { 151 if let Some(builder) = block_stack.last_mut() { 152 builder.add_text(text.to_string()); 153 } 154 } 155 156 Event::Code(code) => { 157 if let Some(builder) = block_stack.last_mut() { 158 builder.add_code_span(code.to_string()); 159 } 160 } 161 162 Event::SoftBreak | Event::HardBreak => { 163 if let Some(builder) = block_stack.last_mut() { 164 builder.add_text(" ".to_string()); 165 } 166 } 167 168 Event::Rule => { 169 blocks.push(Block::Rule); 170 } 171 172 _ => {} 173 } 174 } 175 176 Ok(Slide::with_blocks(blocks)) 177} 178 179/// Helper to build blocks while parsing 180enum BlockBuilder { 181 Heading { 182 level: u8, 183 spans: Vec<TextSpan>, 184 style: TextStyle, 185 }, 186 Paragraph { 187 spans: Vec<TextSpan>, 188 style: TextStyle, 189 }, 190 Code { 191 language: Option<String>, 192 code: String, 193 }, 194 List { 195 ordered: bool, 196 items: Vec<ListItem>, 197 current_item: Vec<TextSpan>, 198 style: TextStyle, 199 }, 200 BlockQuote { 201 blocks: Vec<Block>, 202 }, 203} 204 205impl BlockBuilder { 206 fn add_text(&mut self, text: String) { 207 match self { 208 Self::Heading { spans, style, .. } | Self::Paragraph { spans, style } => { 209 if !text.is_empty() { 210 spans.push(TextSpan { 211 text, 212 style: style.clone(), 213 }); 214 } 215 } 216 Self::Code { code, .. } => { 217 code.push_str(&text); 218 } 219 Self::List { 220 current_item, style, .. 221 } => { 222 if !text.is_empty() { 223 current_item.push(TextSpan { 224 text, 225 style: style.clone(), 226 }); 227 } 228 } 229 _ => {} 230 } 231 } 232 233 fn add_code_span(&mut self, code: String) { 234 match self { 235 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 236 spans.push(TextSpan { 237 text: code, 238 style: TextStyle { 239 code: true, 240 ..Default::default() 241 }, 242 }); 243 } 244 Self::List { current_item, .. } => { 245 current_item.push(TextSpan { 246 text: code, 247 style: TextStyle { 248 code: true, 249 ..Default::default() 250 }, 251 }); 252 } 253 _ => {} 254 } 255 } 256 257 fn build(self) -> Block { 258 match self { 259 Self::Heading { level, spans, .. } => Block::Heading { level, spans }, 260 Self::Paragraph { spans, .. } => Block::Paragraph { spans }, 261 Self::Code { language, code } => Block::Code(CodeBlock { language, code }), 262 Self::List { ordered, items, .. } => Block::List(List { ordered, items }), 263 Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, 264 } 265 } 266} 267 268#[cfg(test)] 269mod tests { 270 use super::*; 271 272 #[test] 273 fn split_slides_basic() { 274 let markdown = "# Slide 1\n---\n# Slide 2"; 275 let slides = split_slides(markdown); 276 assert_eq!(slides.len(), 2); 277 assert!(slides[0].contains("Slide 1")); 278 assert!(slides[1].contains("Slide 2")); 279 } 280 281 #[test] 282 fn split_slides_empty() { 283 let markdown = ""; 284 let slides = split_slides(markdown); 285 assert_eq!(slides.len(), 0); 286 } 287 288 #[test] 289 fn split_slides_single() { 290 let markdown = "# Only Slide"; 291 let slides = split_slides(markdown); 292 assert_eq!(slides.len(), 1); 293 } 294 295 #[test] 296 fn parse_heading() { 297 let slides = parse_slides("# Hello World").unwrap(); 298 assert_eq!(slides.len(), 1); 299 300 match &slides[0].blocks[0] { 301 Block::Heading { level, spans } => { 302 assert_eq!(*level, 1); 303 assert_eq!(spans[0].text, "Hello World"); 304 } 305 _ => panic!("Expected heading"), 306 } 307 } 308 309 #[test] 310 fn parse_paragraph() { 311 let slides = parse_slides("This is a paragraph").unwrap(); 312 assert_eq!(slides.len(), 1); 313 314 match &slides[0].blocks[0] { 315 Block::Paragraph { spans } => { 316 assert_eq!(spans[0].text, "This is a paragraph"); 317 } 318 _ => panic!("Expected paragraph"), 319 } 320 } 321 322 #[test] 323 fn parse_code_block() { 324 let markdown = "```rust\nfn main() {}\n```"; 325 let slides = parse_slides(markdown).unwrap(); 326 327 match &slides[0].blocks[0] { 328 Block::Code(code) => { 329 assert_eq!(code.language, Some("rust".to_string())); 330 assert!(code.code.contains("fn main()")); 331 } 332 _ => panic!("Expected code block"), 333 } 334 } 335 336 #[test] 337 fn parse_list() { 338 let markdown = "- Item 1\n- Item 2"; 339 let slides = parse_slides(markdown).unwrap(); 340 341 match &slides[0].blocks[0] { 342 Block::List(list) => { 343 assert!(!list.ordered); 344 assert_eq!(list.items.len(), 2); 345 assert_eq!(list.items[0].spans[0].text, "Item 1"); 346 } 347 _ => panic!("Expected list"), 348 } 349 } 350 351 #[test] 352 fn parse_multiple_slides() { 353 let markdown = "# Slide 1\nContent 1\n---\n# Slide 2\nContent 2"; 354 let slides = parse_slides(markdown).unwrap(); 355 assert_eq!(slides.len(), 2); 356 } 357 358 #[test] 359 fn parse_with_yaml_metadata() { 360 let markdown = r#"--- 361theme: dark 362author: Test Author 363--- 364# First Slide 365Content here 366--- 367# Second Slide 368More content"#; 369 370 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 371 assert_eq!(meta.theme, "dark"); 372 assert_eq!(meta.author, "Test Author"); 373 assert_eq!(slides.len(), 2); 374 } 375 376 #[test] 377 fn parse_with_toml_metadata() { 378 let markdown = r#"+++ 379theme = "monokai" 380author = "Jane Doe" 381+++ 382# Slide One 383Test content"#; 384 385 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 386 assert_eq!(meta.theme, "monokai"); 387 assert_eq!(meta.author, "Jane Doe"); 388 assert_eq!(slides.len(), 1); 389 } 390 391 #[test] 392 fn parse_without_metadata() { 393 let markdown = "# Slide\nContent"; 394 let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 395 assert_eq!(meta, Meta::default()); 396 assert_eq!(slides.len(), 1); 397 } 398}