typstify_core/
frontmatter.rs1use std::path::Path;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{CoreError, Result};
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Frontmatter {
13 pub title: String,
15
16 #[serde(default)]
18 pub date: Option<DateTime<Utc>>,
19
20 #[serde(default)]
22 pub updated: Option<DateTime<Utc>>,
23
24 #[serde(default)]
26 pub draft: bool,
27
28 #[serde(default)]
30 pub description: Option<String>,
31
32 #[serde(default)]
34 pub tags: Vec<String>,
35
36 #[serde(default)]
38 pub categories: Vec<String>,
39
40 #[serde(default)]
42 pub aliases: Vec<String>,
43
44 #[serde(default)]
46 pub custom_js: Vec<String>,
47
48 #[serde(default)]
50 pub custom_css: Vec<String>,
51
52 #[serde(default)]
54 pub template: Option<String>,
55
56 #[serde(default)]
58 pub weight: i32,
59
60 #[serde(default, flatten)]
62 pub extra: std::collections::HashMap<String, serde_yaml::Value>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum FrontmatterFormat {
68 Yaml,
70 Toml,
72}
73
74impl FrontmatterFormat {
75 pub fn delimiter(&self) -> &'static str {
77 match self {
78 Self::Yaml => "---",
79 Self::Toml => "+++",
80 }
81 }
82}
83
84pub fn split_frontmatter(content: &str) -> Option<(FrontmatterFormat, &str, &str)> {
86 let content = content.trim_start();
87
88 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 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
109pub fn parse_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
111 let Some((format, fm_str, body)) = split_frontmatter(content) else {
112 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
128pub 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; 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 body_start += line.len() + 1;
158 } else {
159 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 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}