magical markdown slides
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}