Skip to main content

kardo_core/parser/
frontmatter.rs

1//! YAML frontmatter extraction.
2//!
3//! Parses simple `key: value` YAML frontmatter between `---` markers.
4
5use std::collections::HashMap;
6
7/// Extract YAML frontmatter from Markdown content.
8///
9/// Expects frontmatter between `---` markers at the start of the file.
10/// Returns `None` if no valid frontmatter is found.
11/// Supports simple `key: value` pairs (no nested structures).
12pub fn parse_frontmatter(content: &str) -> Option<HashMap<String, String>> {
13    // Must start with ---
14    if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
15        return None;
16    }
17
18    // Find content after opening ---
19    let after_first = if let Some(stripped) = content.strip_prefix("---\r\n") {
20        stripped
21    } else {
22        &content[4..]
23    };
24
25    // Find closing ---
26    let end_pos = after_first.find("\n---")?;
27    let frontmatter_str = &after_first[..end_pos];
28
29    let mut map = HashMap::new();
30
31    for line in frontmatter_str.lines() {
32        let line = line.trim();
33        if line.is_empty() || line.starts_with('#') {
34            continue;
35        }
36
37        if let Some((key, value)) = line.split_once(':') {
38            let key = key.trim().to_string();
39            let value = value.trim();
40
41            // Strip surrounding quotes if present
42            let value = if (value.starts_with('"') && value.ends_with('"'))
43                || (value.starts_with('\'') && value.ends_with('\''))
44            {
45                value[1..value.len() - 1].to_string()
46            } else {
47                value.to_string()
48            };
49
50            if !key.is_empty() {
51                map.insert(key, value);
52            }
53        }
54    }
55
56    if map.is_empty() {
57        None
58    } else {
59        Some(map)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn test_simple_frontmatter() {
69        let content = "---\ntitle: Hello World\nauthor: Alice\n---\n\n# Content\n";
70        let fm = parse_frontmatter(content).unwrap();
71        assert_eq!(fm.get("title").unwrap(), "Hello World");
72        assert_eq!(fm.get("author").unwrap(), "Alice");
73    }
74
75    #[test]
76    fn test_quoted_values() {
77        let content = "---\ntitle: \"My Doc\"\nstatus: 'draft'\n---\n\nBody\n";
78        let fm = parse_frontmatter(content).unwrap();
79        assert_eq!(fm.get("title").unwrap(), "My Doc");
80        assert_eq!(fm.get("status").unwrap(), "draft");
81    }
82
83    #[test]
84    fn test_no_frontmatter() {
85        let content = "# Just a heading\n\nSome text.\n";
86        assert!(parse_frontmatter(content).is_none());
87    }
88
89    #[test]
90    fn test_empty_frontmatter() {
91        let content = "---\n---\n\nContent.\n";
92        assert!(parse_frontmatter(content).is_none());
93    }
94
95    #[test]
96    fn test_frontmatter_with_comments() {
97        let content = "---\ntitle: Test\n# This is a comment\nauthor: Bob\n---\n\n";
98        let fm = parse_frontmatter(content).unwrap();
99        assert_eq!(fm.len(), 2);
100        assert_eq!(fm.get("title").unwrap(), "Test");
101        assert_eq!(fm.get("author").unwrap(), "Bob");
102    }
103
104    #[test]
105    fn test_no_closing_marker() {
106        let content = "---\ntitle: Missing close\n";
107        assert!(parse_frontmatter(content).is_none());
108    }
109
110    #[test]
111    fn test_value_with_colon() {
112        let content = "---\nurl: https://example.com\n---\n\nBody\n";
113        let fm = parse_frontmatter(content).unwrap();
114        assert_eq!(fm.get("url").unwrap(), "https://example.com");
115    }
116}