magical markdown slides
at main 7.2 kB view raw
1use crate::error::{Result, SlideError}; 2use serde::{Deserialize, Serialize}; 3use std::env; 4use std::time::SystemTime; 5 6/// Slide deck metadata from YAML frontmatter 7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 8pub struct Meta { 9 #[serde(default = "Meta::default_theme")] 10 pub theme: String, 11 #[serde(default = "Meta::default_author")] 12 pub author: String, 13 #[serde(default = "Meta::default_date")] 14 pub date: String, 15 #[serde(default = "Meta::default_paging")] 16 pub paging: String, 17} 18 19impl Default for Meta { 20 fn default() -> Self { 21 Self { 22 theme: Self::default_theme(), 23 author: Self::default_author(), 24 date: Self::default_date(), 25 paging: Self::default_paging(), 26 } 27 } 28} 29 30impl Meta { 31 pub fn new() -> Self { 32 Self::default() 33 } 34 35 /// Parse metadata from YAML or TOML frontmatter header 36 fn parse(header: &str, format: FrontmatterFormat) -> Result<Self> { 37 if header.trim().is_empty() { 38 return Ok(Self::default()); 39 } 40 41 match format { 42 FrontmatterFormat::Yaml => match serde_yml::from_str(header) { 43 Ok(meta) => Ok(meta), 44 Err(e) => Err(SlideError::front_matter(format!("Failed to parse YAML: {e}"))), 45 }, 46 FrontmatterFormat::Toml => match toml::from_str(header) { 47 Ok(meta) => Ok(meta), 48 Err(e) => Err(SlideError::front_matter(format!("Failed to parse TOML: {e}"))), 49 }, 50 } 51 } 52 53 /// Extract frontmatter block with the given delimiter and format 54 fn extract_frontmatter(rest: &str, delimiter: &str, format: FrontmatterFormat) -> Result<(Self, String)> { 55 match rest.find(&format!("\n{delimiter}")) { 56 Some(end_pos) => Ok(( 57 Self::parse(&rest[..end_pos], format)?, 58 rest[end_pos + delimiter.len() + 1..].to_string(), 59 )), 60 None => Err(SlideError::front_matter(format!( 61 "Unclosed {format} frontmatter block (missing closing {delimiter})" 62 ))), 63 } 64 } 65 66 /// Extract metadata and content from markdown 67 pub fn extract_from_markdown(markdown: &str) -> Result<(Self, String)> { 68 let trimmed = markdown.trim_start(); 69 match trimmed.chars().take(3).collect::<String>().as_str() { 70 "---" => Self::extract_frontmatter(&trimmed[3..], "---", FrontmatterFormat::Yaml), 71 "+++" => Self::extract_frontmatter(&trimmed[3..], "+++", FrontmatterFormat::Toml), 72 _ => Ok((Self::default(), markdown.to_string())), 73 } 74 } 75 76 /// Get theme from environment variable or return "oxocarbon-dark" 77 fn default_theme() -> String { 78 env::var("SLIDES_THEME").unwrap_or_else(|_| "oxocarbon-dark".to_string()) 79 } 80 81 /// Get current system user's name 82 fn default_author() -> String { 83 env::var("USER") 84 .or_else(|_| env::var("USERNAME")) 85 .unwrap_or_else(|_| "Unknown".to_string()) 86 } 87 88 /// Get current date in YYYY-MM-DD format 89 fn default_date() -> String { 90 match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { 91 Ok(duration) => { 92 let days = duration.as_secs() / 86400; 93 let epoch_days = days as i64; 94 let year = 1970 + (epoch_days / 365); 95 96 let day_of_year = epoch_days % 365; 97 let month = (day_of_year / 30) + 1; 98 let day = (day_of_year % 30) + 1; 99 format!("{year:04}-{month:02}-{day:02}") 100 } 101 Err(_) => "Unknown".to_string(), 102 } 103 } 104 105 /// Default paging format 106 fn default_paging() -> String { 107 "Slide %d / %d".to_string() 108 } 109} 110 111/// Frontmatter format type 112#[derive(Debug, Clone, Copy, PartialEq, Eq)] 113enum FrontmatterFormat { 114 Yaml, 115 Toml, 116} 117 118impl std::fmt::Display for FrontmatterFormat { 119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 120 write!( 121 f, 122 "{}", 123 match self { 124 FrontmatterFormat::Yaml => "YAML", 125 FrontmatterFormat::Toml => "TOML", 126 } 127 ) 128 } 129} 130 131#[cfg(test)] 132mod tests { 133 use super::*; 134 135 #[test] 136 fn meta_default() { 137 let meta = Meta::default(); 138 assert_eq!(meta.paging, "Slide %d / %d"); 139 assert!(!meta.theme.is_empty()); 140 } 141 142 #[test] 143 fn meta_parse_yaml_empty() { 144 let meta = Meta::parse("", FrontmatterFormat::Yaml).unwrap(); 145 assert_eq!(meta, Meta::default()); 146 } 147 148 #[test] 149 fn meta_parse_yaml_partial() { 150 let yaml = "theme: dark\nauthor: Test Author"; 151 let meta = Meta::parse(yaml, FrontmatterFormat::Yaml).unwrap(); 152 assert_eq!(meta.theme, "dark"); 153 assert_eq!(meta.author, "Test Author"); 154 assert_eq!(meta.paging, "Slide %d / %d"); 155 } 156 157 #[test] 158 fn meta_parse_yaml_full() { 159 let yaml = r#" 160theme: monokai 161author: John Doe 162date: 2024-01-15 163paging: "Page %d of %d" 164 "#; 165 let meta = Meta::parse(yaml, FrontmatterFormat::Yaml).unwrap(); 166 assert_eq!(meta.theme, "monokai"); 167 assert_eq!(meta.author, "John Doe"); 168 assert_eq!(meta.date, "2024-01-15"); 169 assert_eq!(meta.paging, "Page %d of %d"); 170 } 171 172 #[test] 173 fn meta_parse_toml() { 174 let toml = r#" 175theme = "dracula" 176author = "Jane Doe" 177date = "2024-01-20" 178paging = "Slide %d of %d" 179 "#; 180 let meta = Meta::parse(toml, FrontmatterFormat::Toml).unwrap(); 181 assert_eq!(meta.theme, "dracula"); 182 assert_eq!(meta.author, "Jane Doe"); 183 assert_eq!(meta.date, "2024-01-20"); 184 assert_eq!(meta.paging, "Slide %d of %d"); 185 } 186 187 #[test] 188 fn extract_frontmatter() { 189 let markdown = r#"--- 190theme: dark 191author: Test 192--- 193# First Slide 194Content here"#; 195 196 let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); 197 assert_eq!(meta.theme, "dark"); 198 assert_eq!(meta.author, "Test"); 199 assert!(content.contains("# First Slide")); 200 } 201 202 #[test] 203 fn extract_no_frontmatter() { 204 let markdown = "# First Slide\nContent"; 205 let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); 206 assert_eq!(meta, Meta::default()); 207 assert_eq!(content, markdown); 208 } 209 210 #[test] 211 fn extract_unclosed_yaml_frontmatter() { 212 let markdown = "---\ntheme: dark\n# Slide"; 213 let result = Meta::extract_from_markdown(markdown); 214 assert!(result.is_err()); 215 } 216 217 #[test] 218 fn extract_toml_frontmatter() { 219 let markdown = r#"+++ 220theme = "dark" 221author = "Test" 222+++ 223# First Slide 224Content here"#; 225 226 let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); 227 assert_eq!(meta.theme, "dark"); 228 assert_eq!(meta.author, "Test"); 229 assert!(content.contains("# First Slide")); 230 } 231 232 #[test] 233 fn extract_unclosed_toml_frontmatter() { 234 let markdown = "+++\ntheme = \"dark\"\n# Slide"; 235 let result = Meta::extract_from_markdown(markdown); 236 assert!(result.is_err()); 237 } 238}