magical markdown slides
at main 16 kB view raw
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}