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