magical markdown slides
1/// TODO: Add --no-bg flag to present command to allow users to disable background color
2use clap::{Parser, Subcommand};
3use lantern_core::validator::{validate_slides, validate_theme_file};
4use lantern_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeRegistry};
5use lantern_ui::App;
6use owo_colors::OwoColorize;
7use ratatui::{Terminal, backend::CrosstermBackend};
8use std::{
9 io,
10 path::{Path, PathBuf},
11};
12use tracing::Level;
13
14/// A modern terminal-based presentation tool
15#[derive(Parser, Debug)]
16#[command(name = "slides")]
17#[command(version, about, long_about = None)]
18struct ArgParser {
19 /// Set logging level (error, warn, info, debug, trace)
20 #[arg(short, long, global = true, default_value = "info")]
21 log_level: Level,
22
23 #[command(subcommand)]
24 command: Commands,
25}
26
27#[derive(Subcommand, Debug)]
28enum Commands {
29 /// Present slides in interactive TUI mode
30 Present {
31 /// Path to the markdown file
32 file: PathBuf,
33 /// Theme to use for presentation
34 #[arg(short, long)]
35 theme: Option<String>,
36 },
37
38 /// Print slides to stdout with formatting
39 Print {
40 /// Path to the markdown file
41 file: PathBuf,
42 /// Maximum width for output (in characters)
43 #[arg(short, long, default_value = "80")]
44 width: usize,
45 /// Theme to use for coloring
46 #[arg(short, long)]
47 theme: Option<String>,
48 },
49
50 /// Initialize a new slide deck with example content
51 Init {
52 /// Directory to create the deck in
53 #[arg(default_value = ".")]
54 path: PathBuf,
55 /// Name of the deck file
56 #[arg(short, long, default_value = "slides.md")]
57 name: String,
58 },
59
60 /// Check slides for errors and lint issues
61 Check {
62 /// Path to the markdown file
63 file: PathBuf,
64 /// Enable strict mode with additional checks
65 #[arg(short, long)]
66 strict: bool,
67 /// Validate file as a theme instead of slides
68 #[arg(short, long)]
69 theme: bool,
70 },
71}
72
73fn main() {
74 let cli = ArgParser::parse();
75
76 if let Ok(log_path) = std::env::var("LANTERN_LOG_FILE") {
77 let log_file = std::fs::OpenOptions::new()
78 .create(true)
79 .write(true)
80 .truncate(true)
81 .open(&log_path)
82 .unwrap_or_else(|e| panic!("Failed to create log file at {log_path}: {e}"));
83
84 tracing_subscriber::fmt()
85 .with_max_level(cli.log_level)
86 .with_writer(std::sync::Mutex::new(log_file))
87 .with_ansi(false)
88 .init();
89 } else {
90 tracing_subscriber::fmt()
91 .with_max_level(cli.log_level)
92 .with_writer(std::io::sink)
93 .with_ansi(false)
94 .init();
95 }
96
97 match cli.command {
98 Commands::Present { file, theme } => {
99 if let Err(e) = run_present(&file, theme) {
100 eprintln!("Error: {e}");
101 std::process::exit(1);
102 }
103 }
104 Commands::Print { file, width, theme } => {
105 if let Err(e) = run_print(&file, width, theme) {
106 eprintln!("Error: {e}");
107 std::process::exit(1);
108 }
109 }
110 Commands::Init { path, name } => {
111 tracing::info!("Initializing new deck: {} in {}", name, path.display());
112 eprintln!("Init command not yet implemented");
113 }
114 Commands::Check { file, strict, theme } => {
115 if let Err(e) = run_check(&file, strict, theme) {
116 eprintln!("Error: {e}");
117 std::process::exit(1);
118 }
119 }
120 }
121}
122
123fn run_present(file: &PathBuf, theme_arg: Option<String>) -> io::Result<()> {
124 tracing::info!("Presenting slides from: {}", file.display());
125
126 let markdown = std::fs::read_to_string(file)
127 .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?;
128
129 let (meta, slides) = parse_slides_with_meta(&markdown)
130 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {e}")))?;
131
132 if slides.is_empty() {
133 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file"));
134 }
135
136 let theme_name = theme_arg.clone().unwrap_or_else(|| meta.theme.clone());
137 tracing::info!(
138 "Theme selection: CLI arg={:?}, frontmatter={}, final={}",
139 theme_arg,
140 meta.theme,
141 theme_name
142 );
143
144 let theme = ThemeRegistry::get(&theme_name);
145
146 let filename = file
147 .file_name()
148 .and_then(|n| n.to_str())
149 .unwrap_or("unknown")
150 .to_string();
151
152 let mut slide_terminal = SlideTerminal::setup()?;
153
154 let result = (|| -> io::Result<()> {
155 let stdout = io::stdout();
156 let backend = CrosstermBackend::new(stdout);
157 let mut terminal = Terminal::new(backend)?;
158
159 terminal.clear()?;
160
161 let mut app = App::new(slides, theme, filename, meta);
162 app.run(&mut terminal)?;
163
164 Ok(())
165 })();
166
167 slide_terminal.restore()?;
168
169 result
170}
171
172fn run_check(file: &Path, strict: bool, is_theme: bool) -> io::Result<()> {
173 if is_theme {
174 tracing::info!("Validating theme file: {}", file.display());
175 let result = validate_theme_file(file);
176
177 if result.is_valid() {
178 println!("{} Theme is valid", "✓".green().bold());
179 } else {
180 println!("{} Theme validation failed", "✗".red().bold());
181 }
182
183 for error in &result.errors {
184 println!(" {} {}", "Error:".red().bold(), error);
185 }
186
187 for warning in &result.warnings {
188 println!(" {} {}", "Warning:".yellow().bold(), warning);
189 }
190
191 if !result.is_valid() {
192 return Err(io::Error::new(io::ErrorKind::InvalidData, "Theme validation failed"));
193 }
194 } else {
195 tracing::info!("Validating slides: {}", file.display());
196 if strict {
197 tracing::debug!("Strict mode enabled");
198 }
199
200 let result = validate_slides(file, strict);
201
202 if result.is_valid() && !result.has_issues() {
203 println!("{} Slides are valid", "✓".green().bold());
204 } else if result.is_valid() {
205 println!("{} Slides are valid (with warnings)", "✓".yellow().bold());
206 } else {
207 println!("{} Slide validation failed", "✗".red().bold());
208 }
209
210 for error in &result.errors {
211 println!(" {} {}", "Error:".red().bold(), error);
212 }
213
214 for warning in &result.warnings {
215 println!(" {} {}", "Warning:".yellow().bold(), warning);
216 }
217
218 if !result.is_valid() {
219 return Err(io::Error::new(io::ErrorKind::InvalidData, "Slide validation failed"));
220 }
221 }
222
223 Ok(())
224}
225
226fn run_print(file: &PathBuf, width: usize, theme_arg: Option<String>) -> io::Result<()> {
227 tracing::info!("Printing slides from: {} (width: {})", file.display(), width);
228
229 let markdown = std::fs::read_to_string(file)
230 .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?;
231
232 let (meta, slides) = parse_slides_with_meta(&markdown)
233 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {e}")))?;
234
235 if slides.is_empty() {
236 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file"));
237 }
238
239 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone());
240 tracing::debug!("Using theme: {}", theme_name);
241
242 let theme = ThemeRegistry::get(&theme_name);
243
244 lantern_core::printer::print_slides_to_stdout(&slides, &theme, width)?;
245
246 Ok(())
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn cli_present_command() {
255 let cli = ArgParser::parse_from(["slides", "present", "test.md"]);
256 match cli.command {
257 Commands::Present { file, theme } => {
258 assert_eq!(file, PathBuf::from("test.md"));
259 assert_eq!(theme, None);
260 }
261 _ => panic!("Expected Present command"),
262 }
263 }
264
265 #[test]
266 fn cli_present_with_theme() {
267 let cli = ArgParser::parse_from(["slides", "present", "test.md", "--theme", "dark"]);
268 match cli.command {
269 Commands::Present { file, theme } => {
270 assert_eq!(file, PathBuf::from("test.md"));
271 assert_eq!(theme, Some("dark".to_string()));
272 }
273 _ => panic!("Expected Present command"),
274 }
275 }
276
277 #[test]
278 fn cli_print_command() {
279 let cli = ArgParser::parse_from(["slides", "print", "test.md", "-w", "100"]);
280 match cli.command {
281 Commands::Print { file, width, theme } => {
282 assert_eq!(file, PathBuf::from("test.md"));
283 assert_eq!(width, 100);
284 assert_eq!(theme, None);
285 }
286 _ => panic!("Expected Print command"),
287 }
288 }
289
290 #[test]
291 fn cli_init_command() {
292 let cli = ArgParser::parse_from(["slides", "init", "--name", "my-deck.md"]);
293 match cli.command {
294 Commands::Init { path, name } => {
295 assert_eq!(path, PathBuf::from("."));
296 assert_eq!(name, "my-deck.md");
297 }
298 _ => panic!("Expected Init command"),
299 }
300 }
301
302 #[test]
303 fn cli_check_command() {
304 let cli = ArgParser::parse_from(["slides", "check", "test.md", "--strict"]);
305 match cli.command {
306 Commands::Check { file, strict, theme } => {
307 assert_eq!(file, PathBuf::from("test.md"));
308 assert!(strict);
309 assert!(!theme);
310 }
311 _ => panic!("Expected Check command"),
312 }
313 }
314
315 #[test]
316 fn cli_check_theme_command() {
317 let cli = ArgParser::parse_from(["slides", "check", "theme.yml", "--theme"]);
318 match cli.command {
319 Commands::Check { file, strict, theme } => {
320 assert_eq!(file, PathBuf::from("theme.yml"));
321 assert!(!strict);
322 assert!(theme);
323 }
324 _ => panic!("Expected Check command"),
325 }
326 }
327
328 #[test]
329 fn run_print_with_test_file() {
330 let temp_dir = std::env::temp_dir();
331 let test_file = temp_dir.join("test_slides.md");
332
333 let content = "# Test Slide\n\nThis is a test paragraph.\n\n---\n\n# Second Slide\n\n- Item 1\n- Item 2";
334 std::fs::write(&test_file, content).expect("Failed to write test file");
335
336 let result = run_print(&test_file, 80, None);
337 assert!(result.is_ok());
338
339 std::fs::remove_file(&test_file).ok();
340 }
341
342 #[test]
343 fn run_print_empty_file() {
344 let temp_dir = std::env::temp_dir();
345 let test_file = temp_dir.join("empty_slides.md");
346
347 std::fs::write(&test_file, "").expect("Failed to write test file");
348
349 let result = run_print(&test_file, 80, None);
350 assert!(result.is_err());
351
352 std::fs::remove_file(&test_file).ok();
353 }
354
355 #[test]
356 fn run_print_nonexistent_file() {
357 let test_file = PathBuf::from("/nonexistent/file.md");
358 let result = run_print(&test_file, 80, None);
359 assert!(result.is_err());
360 }
361
362 #[test]
363 fn run_print_with_theme_from_frontmatter() {
364 let temp_dir = std::env::temp_dir();
365 let test_file = temp_dir.join("test_themed_slides.md");
366
367 let content = "---\ntheme: dark\n---\n# Test Slide\n\nThis is a test paragraph.";
368 std::fs::write(&test_file, content).expect("Failed to write test file");
369
370 let result = run_print(&test_file, 80, None);
371 assert!(result.is_ok());
372
373 std::fs::remove_file(&test_file).ok();
374 }
375
376 #[test]
377 fn run_print_with_theme_override() {
378 let temp_dir = std::env::temp_dir();
379 let test_file = temp_dir.join("test_override_slides.md");
380
381 let content = "---\ntheme: light\n---\n# Test Slide\n\nThis is a test paragraph.";
382 std::fs::write(&test_file, content).expect("Failed to write test file");
383
384 let result = run_print(&test_file, 80, Some("monokai".to_string()));
385 assert!(result.is_ok());
386
387 std::fs::remove_file(&test_file).ok();
388 }
389
390 #[test]
391 fn run_check_valid_slides() {
392 let temp_dir = std::env::temp_dir();
393 let test_file = temp_dir.join("test_check_valid.md");
394 let content = "# Test Slide\n\nThis is a test paragraph.";
395 std::fs::write(&test_file, content).expect("Failed to write test file");
396
397 let result = run_check(&test_file, false, false);
398 assert!(result.is_ok());
399
400 std::fs::remove_file(&test_file).ok();
401 }
402
403 #[test]
404 fn run_check_invalid_slides() {
405 let temp_dir = std::env::temp_dir();
406 let test_file = temp_dir.join("test_check_invalid.md");
407 let content = "";
408 std::fs::write(&test_file, content).expect("Failed to write test file");
409
410 let result = run_check(&test_file, false, false);
411 assert!(result.is_err());
412
413 std::fs::remove_file(&test_file).ok();
414 }
415
416 #[test]
417 fn run_check_nonexistent_file() {
418 let test_file = PathBuf::from("/nonexistent/test_check.md");
419 let result = run_check(&test_file, false, false);
420 assert!(result.is_err());
421 }
422
423 #[test]
424 fn run_check_strict_mode() {
425 let temp_dir = std::env::temp_dir();
426 let test_file = temp_dir.join("test_check_strict.md");
427 let content = "---\ntheme: nonexistent-theme\n---\n# Slide 1\n\nContent";
428 std::fs::write(&test_file, content).expect("Failed to write test file");
429
430 let result = run_check(&test_file, true, false);
431 assert!(result.is_ok());
432
433 std::fs::remove_file(&test_file).ok();
434 }
435
436 #[test]
437 fn run_check_valid_theme() {
438 let temp_dir = std::env::temp_dir();
439 let test_file = temp_dir.join("test_check_valid_theme.yml");
440 let content = r###"
441system: "base16"
442name: "Test Theme"
443author: "Test Author"
444variant: "dark"
445palette:
446 base00: "#000000"
447 base01: "#111111"
448 base02: "#222222"
449 base03: "#333333"
450 base04: "#444444"
451 base05: "#555555"
452 base06: "#666666"
453 base07: "#777777"
454 base08: "#888888"
455 base09: "#999999"
456 base0A: "#aaaaaa"
457 base0B: "#bbbbbb"
458 base0C: "#cccccc"
459 base0D: "#dddddd"
460 base0E: "#eeeeee"
461 base0F: "#ffffff"
462"###;
463 std::fs::write(&test_file, content).expect("Failed to write test file");
464
465 let result = run_check(&test_file, false, true);
466 assert!(result.is_ok());
467
468 std::fs::remove_file(&test_file).ok();
469 }
470
471 #[test]
472 fn run_check_invalid_theme() {
473 let temp_dir = std::env::temp_dir();
474 let test_file = temp_dir.join("test_check_invalid_theme.yml");
475 let content = "invalid: yaml: content: [unclosed";
476 std::fs::write(&test_file, content).expect("Failed to write test file");
477
478 let result = run_check(&test_file, false, true);
479 assert!(result.is_err());
480
481 std::fs::remove_file(&test_file).ok();
482 }
483
484 #[test]
485 fn run_check_invalid_frontmatter() {
486 let temp_dir = std::env::temp_dir();
487 let test_file = temp_dir.join("test_check_bad_frontmatter.md");
488 let content = "---\ninvalid yaml: [unclosed\n---\n# Slide";
489 std::fs::write(&test_file, content).expect("Failed to write test file");
490
491 let result = run_check(&test_file, false, false);
492 assert!(result.is_err());
493
494 std::fs::remove_file(&test_file).ok();
495 }
496}