typstify_core/
frontmatter.rs

1//! Frontmatter parsing for content files.
2
3use std::path::Path;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{CoreError, Result};
9
10/// Frontmatter metadata for content files.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Frontmatter {
13    /// Page title (required).
14    pub title: String,
15
16    /// Publication date.
17    #[serde(default)]
18    pub date: Option<DateTime<Utc>>,
19
20    /// Last updated date.
21    #[serde(default)]
22    pub updated: Option<DateTime<Utc>>,
23
24    /// Whether this is a draft.
25    #[serde(default)]
26    pub draft: bool,
27
28    /// Page description for meta tags and summaries.
29    #[serde(default)]
30    pub description: Option<String>,
31
32    /// Tags for the page.
33    #[serde(default)]
34    pub tags: Vec<String>,
35
36    /// Categories for the page.
37    #[serde(default)]
38    pub categories: Vec<String>,
39
40    /// URL aliases for redirects.
41    #[serde(default)]
42    pub aliases: Vec<String>,
43
44    /// Custom JavaScript files to include.
45    #[serde(default)]
46    pub custom_js: Vec<String>,
47
48    /// Custom CSS files to include.
49    #[serde(default)]
50    pub custom_css: Vec<String>,
51
52    /// Template to use for rendering.
53    #[serde(default)]
54    pub template: Option<String>,
55
56    /// Sort weight for ordering.
57    #[serde(default)]
58    pub weight: i32,
59
60    /// Custom extra fields (for extensibility).
61    #[serde(default, flatten)]
62    pub extra: std::collections::HashMap<String, serde_yaml::Value>,
63}
64
65/// Delimiter types for frontmatter.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum FrontmatterFormat {
68    /// YAML frontmatter delimited by `---`.
69    Yaml,
70    /// TOML frontmatter delimited by `+++`.
71    Toml,
72}
73
74impl FrontmatterFormat {
75    /// Get the delimiter string for this format.
76    pub fn delimiter(&self) -> &'static str {
77        match self {
78            Self::Yaml => "---",
79            Self::Toml => "+++",
80        }
81    }
82}
83
84/// Split content into frontmatter and body.
85pub fn split_frontmatter(content: &str) -> Option<(FrontmatterFormat, &str, &str)> {
86    let content = content.trim_start();
87
88    // Detect format based on opening delimiter
89    let format = if content.starts_with("---") {
90        FrontmatterFormat::Yaml
91    } else if content.starts_with("+++") {
92        FrontmatterFormat::Toml
93    } else {
94        return None;
95    };
96
97    let delimiter = format.delimiter();
98
99    // Find the closing delimiter
100    let after_first = &content[delimiter.len()..];
101    let closing_pos = after_first.find(delimiter)?;
102
103    let frontmatter = after_first[..closing_pos].trim();
104    let body = after_first[closing_pos + delimiter.len()..].trim_start();
105
106    Some((format, frontmatter, body))
107}
108
109/// Parse frontmatter from a string.
110pub fn parse_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
111    let Some((format, fm_str, body)) = split_frontmatter(content) else {
112        // No frontmatter found, return default with full content
113        return Ok((Frontmatter::default(), content.to_string()));
114    };
115
116    let frontmatter: Frontmatter = match format {
117        FrontmatterFormat::Yaml => {
118            serde_yaml::from_str(fm_str).map_err(|e| CoreError::frontmatter(path, e.to_string()))?
119        }
120        FrontmatterFormat::Toml => {
121            toml::from_str(fm_str).map_err(|e| CoreError::frontmatter(path, e.to_string()))?
122        }
123    };
124
125    Ok((frontmatter, body.to_string()))
126}
127
128/// Parse frontmatter from Typst-style comments.
129///
130/// Typst frontmatter uses comments at the start of the file:
131/// ```typst
132/// // typstify:frontmatter
133/// // title: "My Document"
134/// // date: 2024-01-14
135/// // tags: [rust, typst]
136/// ```
137pub fn parse_typst_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
138    let mut fm_lines = Vec::new();
139    let mut body_start = 0;
140    let mut in_frontmatter = false;
141
142    for line in content.lines() {
143        let trimmed = line.trim();
144
145        if trimmed == "// typstify:frontmatter" {
146            in_frontmatter = true;
147            body_start += line.len() + 1; // +1 for newline
148            continue;
149        }
150
151        if in_frontmatter {
152            if let Some(stripped) = trimmed.strip_prefix("// ") {
153                fm_lines.push(stripped);
154                body_start += line.len() + 1;
155            } else if trimmed.starts_with("//") && trimmed.len() == 2 {
156                // Empty comment line
157                body_start += line.len() + 1;
158            } else {
159                // End of frontmatter
160                break;
161            }
162        } else {
163            break;
164        }
165    }
166
167    if fm_lines.is_empty() {
168        return Ok((Frontmatter::default(), content.to_string()));
169    }
170
171    let fm_str = fm_lines.join("\n");
172    let frontmatter: Frontmatter =
173        serde_yaml::from_str(&fm_str).map_err(|e| CoreError::frontmatter(path, e.to_string()))?;
174
175    let body = if body_start < content.len() {
176        content[body_start..].trim_start().to_string()
177    } else {
178        String::new()
179    };
180
181    Ok((frontmatter, body))
182}
183
184impl Frontmatter {
185    /// Validate required fields.
186    pub fn validate(&self, path: &Path) -> Result<()> {
187        if self.title.is_empty() {
188            return Err(CoreError::frontmatter(path, "title is required"));
189        }
190        Ok(())
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_split_yaml_frontmatter() {
200        let content = r#"---
201title: "Hello World"
202date: 2024-01-14
203---
204
205This is the body content."#;
206
207        let (format, fm, body) = split_frontmatter(content).expect("split");
208        assert_eq!(format, FrontmatterFormat::Yaml);
209        assert!(fm.contains("title:"));
210        assert!(body.starts_with("This is the body"));
211    }
212
213    #[test]
214    fn test_split_toml_frontmatter() {
215        let content = r#"+++
216title = "Hello World"
217date = 2024-01-14
218+++
219
220This is the body content."#;
221
222        let (format, fm, body) = split_frontmatter(content).expect("split");
223        assert_eq!(format, FrontmatterFormat::Toml);
224        assert!(fm.contains("title ="));
225        assert!(body.starts_with("This is the body"));
226    }
227
228    #[test]
229    fn test_no_frontmatter() {
230        let content = "Just some content without frontmatter.";
231        assert!(split_frontmatter(content).is_none());
232    }
233
234    #[test]
235    fn test_parse_yaml_frontmatter() {
236        let content = r#"---
237title: "Test Post"
238date: 2024-01-14T10:00:00Z
239draft: false
240tags:
241  - rust
242  - test
243---
244
245Content here."#;
246
247        let (fm, body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
248
249        assert_eq!(fm.title, "Test Post");
250        assert!(fm.date.is_some());
251        assert!(!fm.draft);
252        assert_eq!(fm.tags, vec!["rust", "test"]);
253        assert_eq!(body, "Content here.");
254    }
255
256    #[test]
257    fn test_parse_toml_frontmatter() {
258        let content = r#"+++
259title = "Test Post"
260draft = true
261tags = ["rust", "test"]
262+++
263
264Content here."#;
265
266        let (fm, body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
267
268        assert_eq!(fm.title, "Test Post");
269        assert!(fm.draft);
270        assert_eq!(fm.tags, vec!["rust", "test"]);
271        assert_eq!(body, "Content here.");
272    }
273
274    #[test]
275    fn test_parse_typst_frontmatter() {
276        let content = r#"// typstify:frontmatter
277// title: "My Typst Document"
278// date: "2024-01-14T00:00:00Z"
279// tags: [typst, docs]
280
281= Heading
282
283Some typst content."#;
284
285        let (fm, body) = parse_typst_frontmatter(content, Path::new("test.typ")).expect("parse");
286
287        assert_eq!(fm.title, "My Typst Document");
288        assert_eq!(fm.tags, vec!["typst", "docs"]);
289        assert!(body.starts_with("= Heading"));
290    }
291
292    #[test]
293    fn test_frontmatter_with_extra_fields() {
294        let content = r#"---
295title: "Test"
296custom_field: "custom value"
297---
298
299Body"#;
300
301        let (fm, _body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
302
303        assert_eq!(fm.title, "Test");
304        assert!(fm.extra.contains_key("custom_field"));
305    }
306
307    #[test]
308    fn test_frontmatter_defaults() {
309        let content = r#"---
310title: "Minimal"
311---
312
313Body"#;
314
315        let (fm, _body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
316
317        assert_eq!(fm.title, "Minimal");
318        assert!(!fm.draft);
319        assert!(fm.tags.is_empty());
320        assert!(fm.date.is_none());
321    }
322
323    #[test]
324    fn test_validate_missing_title() {
325        let fm = Frontmatter::default();
326        let result = fm.validate(Path::new("test.md"));
327        assert!(result.is_err());
328        assert!(result.unwrap_err().to_string().contains("title"));
329    }
330}