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/// 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(), ¤t_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(), ¤t_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}