1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//! Slide parsing utilities for markdown presentations
//!
//! This module provides functionality to parse markdown content into slides,
//! using `---` as the slide delimiter (Slidev/Marp style).
//!
//! # Example
//!
//! ```rust
//! use revue::widget::slides::{parse_slides, SlideContent};
//!
//! let markdown = "# First Slide\n\nWelcome!\n\n---\n\n# Second Slide\n\n- Point 1\n- Point 2";
//!
//! let slides = parse_slides(markdown);
//! assert_eq!(slides.len(), 2);
//! assert_eq!(slides[0].title(), Some("First Slide"));
//! ```
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
/// Content of a single slide
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlideContent {
/// Raw markdown content of the slide
markdown: String,
/// Extracted title (first H1 or H2)
title: Option<String>,
/// Speaker notes (from HTML comments)
notes: Option<String>,
}
impl SlideContent {
/// Create a new slide from markdown content
pub fn new(markdown: impl Into<String>) -> Self {
let markdown = markdown.into();
let title = Self::extract_title(&markdown);
let notes = Self::extract_notes(&markdown);
Self {
markdown,
title,
notes,
}
}
/// Get the raw markdown content
pub fn markdown(&self) -> &str {
&self.markdown
}
/// Get the slide title (extracted from first H1/H2)
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
/// Get speaker notes (extracted from HTML comments)
pub fn notes(&self) -> Option<&str> {
self.notes.as_deref()
}
/// Check if the slide is empty
pub fn is_empty(&self) -> bool {
self.markdown.trim().is_empty()
}
/// Extract title from markdown (first H1 or H2 heading)
fn extract_title(markdown: &str) -> Option<String> {
let options = Options::empty();
let parser = Parser::new_ext(markdown, options);
let mut in_heading = false;
let mut heading_level = 0u8;
let mut title_text = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
let level_num = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
// Only capture H1 or H2 as title
if level_num <= 2 {
in_heading = true;
heading_level = level_num;
title_text.clear();
}
}
Event::End(TagEnd::Heading(_)) => {
if in_heading && heading_level <= 2 && !title_text.is_empty() {
return Some(title_text);
}
in_heading = false;
}
Event::Text(text) if in_heading => {
title_text.push_str(&text);
}
Event::Code(code) if in_heading => {
title_text.push_str(&code);
}
_ => {}
}
}
None
}
/// Extract speaker notes from HTML comments
///
/// Notes are expected in format: `<!-- notes: Your notes here -->`
/// or multi-line:
/// ```html
/// <!--
/// notes:
/// - Point 1
/// - Point 2
/// -->
/// ```
fn extract_notes(markdown: &str) -> Option<String> {
// Simple regex-free approach: find <!-- notes: ... -->
let lower = markdown.to_lowercase();
if let Some(start_idx) = lower.find("<!-- notes:") {
let content_start = start_idx + "<!-- notes:".len();
if let Some(end_offset) = markdown[content_start..].find("-->") {
let notes = markdown[content_start..content_start + end_offset].trim();
if !notes.is_empty() {
return Some(notes.to_string());
}
}
}
// Also try <!-- notes\n format
if let Some(start_idx) = lower.find("<!--\nnotes:") {
let content_start = start_idx + "<!--\nnotes:".len();
if let Some(end_offset) = markdown[content_start..].find("-->") {
let notes = markdown[content_start..content_start + end_offset].trim();
if !notes.is_empty() {
return Some(notes.to_string());
}
}
}
None
}
}
/// Parse markdown content into slides
///
/// Slides are separated by `---` (horizontal rule) on its own line.
/// Code blocks containing `---` are properly handled and won't split.
///
/// # Arguments
/// * `source` - The markdown source to parse
///
/// # Returns
/// A vector of `SlideContent`, one for each slide
pub fn parse_slides(source: &str) -> Vec<SlideContent> {
let mut slides = Vec::new();
let mut current = String::new();
let mut in_code_block = false;
let mut code_fence = String::new();
for line in source.lines() {
let trimmed = line.trim_start();
// Track code blocks to avoid splitting on --- inside them
if !in_code_block {
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_code_block = true;
code_fence = if trimmed.starts_with("```") {
"```".to_string()
} else {
"~~~".to_string()
};
}
} else if trimmed.starts_with(&code_fence) {
in_code_block = false;
code_fence.clear();
}
// Check for slide delimiter (only outside code blocks)
if !in_code_block && is_slide_delimiter(line) {
// Save current slide if not empty
if !current.trim().is_empty() {
slides.push(SlideContent::new(current.clone()));
}
current.clear();
continue;
}
// Add line to current slide
current.push_str(line);
current.push('\n');
}
// Don't forget the last slide
if !current.trim().is_empty() {
slides.push(SlideContent::new(current));
}
slides
}
/// Check if a line is a slide delimiter
///
/// A slide delimiter is `---` (3 or more dashes) on its own line,
/// optionally surrounded by whitespace.
#[doc(hidden)]
pub fn is_slide_delimiter(line: &str) -> bool {
let trimmed = line.trim();
// Must be only dashes (3 or more)
if trimmed.len() < 3 {
return false;
}
trimmed.chars().all(|c| c == '-')
}
/// State for slide navigation
#[derive(Debug, Clone, Default)]
pub struct SlideNav {
/// All slides
slides: Vec<SlideContent>,
/// Current slide index
current: usize,
}
impl SlideNav {
/// Create a new slide navigator from markdown source
pub fn new(source: &str) -> Self {
let slides = parse_slides(source);
Self { slides, current: 0 }
}
/// Create from pre-parsed slides
pub fn from_slides(slides: Vec<SlideContent>) -> Self {
Self { slides, current: 0 }
}
/// Get the current slide index (0-based)
pub fn current_index(&self) -> usize {
self.current
}
/// Get total number of slides
pub fn slide_count(&self) -> usize {
self.slides.len()
}
/// Get the current slide
pub fn current_slide(&self) -> Option<&SlideContent> {
self.slides.get(self.current)
}
/// Go to the next slide
///
/// Returns `true` if navigation succeeded, `false` if already at last slide.
pub fn advance(&mut self) -> bool {
if self.current < self.slides.len().saturating_sub(1) {
self.current += 1;
true
} else {
false
}
}
/// Go to the previous slide
///
/// Returns `true` if navigation succeeded, `false` if already at first slide.
pub fn prev(&mut self) -> bool {
if self.current > 0 {
self.current -= 1;
true
} else {
false
}
}
/// Go to a specific slide by index
pub fn goto(&mut self, index: usize) {
if index < self.slides.len() {
self.current = index;
}
}
/// Go to the first slide
pub fn first(&mut self) {
self.current = 0;
}
/// Go to the last slide
pub fn last(&mut self) {
self.current = self.slides.len().saturating_sub(1);
}
/// Get the slide indicator string (e.g., "3/10")
pub fn indicator(&self) -> String {
format!("{}/{}", self.current + 1, self.slides.len())
}
/// Get the slide indicator with brackets (e.g., "[3/10]")
pub fn indicator_bracketed(&self) -> String {
format!("[{}/{}]", self.current + 1, self.slides.len())
}
/// Check if at the first slide
pub fn is_first(&self) -> bool {
self.current == 0
}
/// Check if at the last slide
pub fn is_last(&self) -> bool {
self.current >= self.slides.len().saturating_sub(1)
}
/// Get all slides
pub fn slides(&self) -> &[SlideContent] {
&self.slides
}
/// Get progress as a fraction (0.0 to 1.0)
pub fn progress(&self) -> f32 {
if self.slides.is_empty() {
return 0.0;
}
(self.current + 1) as f32 / self.slides.len() as f32
}
}