mdvault_core/frontmatter/
parser.rs1use super::types::{Frontmatter, ParsedDocument, TemplateFrontmatter};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
8pub enum FrontmatterParseError {
9 #[error("invalid YAML frontmatter: {0}")]
10 InvalidYaml(#[from] serde_yaml::Error),
11}
12
13pub fn parse(content: &str) -> Result<ParsedDocument, FrontmatterParseError> {
23 let trimmed = content.trim_start();
24
25 if !trimmed.starts_with("---") {
27 return Ok(ParsedDocument { frontmatter: None, body: content.to_string() });
28 }
29
30 let after_first = &trimmed[3..];
32
33 let after_newline = after_first
35 .strip_prefix('\n')
36 .or_else(|| after_first.strip_prefix("\r\n"))
37 .unwrap_or(after_first);
38
39 if let Some(end_pos) = find_closing_delimiter(after_newline) {
41 let yaml_content = &after_newline[..end_pos];
42
43 let after_closing = &after_newline[end_pos + 3..];
45 let body = after_closing
46 .strip_prefix('\n')
47 .or_else(|| after_closing.strip_prefix("\r\n"))
48 .unwrap_or(after_closing)
49 .to_string();
50
51 let frontmatter: Frontmatter = if yaml_content.trim().is_empty() {
53 Frontmatter::default()
54 } else {
55 serde_yaml::from_str(yaml_content.trim())?
56 };
57
58 Ok(ParsedDocument { frontmatter: Some(frontmatter), body })
59 } else {
60 Ok(ParsedDocument { frontmatter: None, body: content.to_string() })
62 }
63}
64
65fn find_closing_delimiter(content: &str) -> Option<usize> {
67 for (i, line) in content.lines().enumerate() {
69 if line.trim() == "---" {
70 let pos: usize = content
72 .lines()
73 .take(i)
74 .map(|l| l.len() + 1) .sum();
76 return Some(pos);
77 }
78 }
79 None
80}
81
82pub fn parse_template_frontmatter(
86 content: &str,
87) -> Result<(Option<TemplateFrontmatter>, String), FrontmatterParseError> {
88 let parsed = parse(content)?;
89
90 if let Some(fm) = parsed.frontmatter {
91 let yaml_value = serde_yaml::to_value(&fm.fields)?;
93 let template_fm: TemplateFrontmatter = serde_yaml::from_value(yaml_value)?;
94 Ok((Some(template_fm), parsed.body))
95 } else {
96 Ok((None, parsed.body))
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn parse_no_frontmatter() {
106 let content = "# Hello\n\nSome content";
107 let result = parse(content).unwrap();
108 assert!(result.frontmatter.is_none());
109 assert_eq!(result.body, content);
110 }
111
112 #[test]
113 fn parse_simple_frontmatter() {
114 let content = "---\ntitle: Hello\n---\n# Content";
115 let result = parse(content).unwrap();
116 assert!(result.frontmatter.is_some());
117 let fm = result.frontmatter.unwrap();
118 assert_eq!(fm.fields.get("title").and_then(|v| v.as_str()), Some("Hello"));
119 assert_eq!(result.body, "# Content");
120 }
121
122 #[test]
123 fn parse_frontmatter_with_multiple_fields() {
124 let content =
125 "---\ntitle: Test\ndate: 2024-01-15\ntags:\n - rust\n - cli\n---\n\nBody";
126 let result = parse(content).unwrap();
127 assert!(result.frontmatter.is_some());
128 let fm = result.frontmatter.unwrap();
129 assert_eq!(fm.fields.get("title").and_then(|v| v.as_str()), Some("Test"));
130 assert!(fm.fields.contains_key("tags"));
131 assert_eq!(result.body, "\nBody");
132 }
133
134 #[test]
135 fn parse_empty_frontmatter() {
136 let content = "---\n---\n# Content";
137 let result = parse(content).unwrap();
138 assert!(result.frontmatter.is_some());
139 assert!(result.frontmatter.unwrap().fields.is_empty());
140 assert_eq!(result.body, "# Content");
141 }
142
143 #[test]
144 fn parse_template_frontmatter_with_output() {
145 let content = "---\noutput: daily/{{date}}.md\ntags: [daily]\n---\n# Daily";
146 let (fm, body) = parse_template_frontmatter(content).unwrap();
147 assert!(fm.is_some());
148 let fm = fm.unwrap();
149 assert_eq!(fm.output, Some("daily/{{date}}.md".to_string()));
150 assert!(fm.extra.contains_key("tags"));
151 assert_eq!(body, "# Daily");
152 }
153}