Skip to main content

mdpdf_core/
frontmatter.rs

1use std::collections::HashMap;
2
3/// Parsed frontmatter metadata from a markdown document.
4#[derive(Debug, Default)]
5pub struct Frontmatter {
6    pub title: Option<String>,
7    pub subtitle: Option<String>,
8    pub date: Option<String>,
9    pub mode: Option<String>,
10    pub toc: Option<String>,
11    pub extra: HashMap<String, String>,
12}
13
14impl Frontmatter {
15    pub fn is_dense(&self) -> bool {
16        self.mode.as_deref() == Some("dense")
17    }
18
19    pub fn skip_toc(&self) -> bool {
20        self.toc.as_deref() == Some("false")
21    }
22}
23
24/// Parse YAML-ish frontmatter from markdown source.
25///
26/// Returns the parsed frontmatter and the body (everything after the closing `---`).
27/// Handles simple `key: value` and `key: "value"` formats.
28pub fn parse_frontmatter(src: &str) -> (Frontmatter, &str) {
29    let trimmed = src.trim_start_matches('\u{feff}'); // strip BOM
30    if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
31        return (Frontmatter::default(), src);
32    }
33
34    let after_fence = &trimmed[4..];
35    let end = after_fence.find("\n---\n")
36        .or_else(|| after_fence.find("\n---\r\n"))
37        .or_else(|| {
38            if after_fence.ends_with("\n---") {
39                Some(after_fence.len() - 4)
40            } else {
41                None
42            }
43        });
44
45    let end = match end {
46        Some(e) => e,
47        None => return (Frontmatter::default(), src),
48    };
49
50    let yaml_block = &after_fence[..end];
51    let body_start = 4 + end + 5; // "---\n" + yaml + "\n---\n"
52    let body = if body_start <= trimmed.len() {
53        &trimmed[body_start..]
54    } else {
55        ""
56    };
57
58    let mut fm = Frontmatter::default();
59    for line in yaml_block.lines() {
60        let line = line.trim();
61        if line.is_empty() || line.starts_with('#') {
62            continue;
63        }
64        if let Some((key, val)) = parse_kv(line) {
65            match key {
66                "title" => fm.title = Some(val),
67                "subtitle" => fm.subtitle = Some(val),
68                "date" => fm.date = Some(val),
69                "mode" => fm.mode = Some(val),
70                "toc" => fm.toc = Some(val),
71                _ => { fm.extra.insert(key.to_owned(), val); }
72            }
73        }
74    }
75
76    (fm, body)
77}
78
79fn parse_kv(line: &str) -> Option<(&str, String)> {
80    let colon = line.find(':')?;
81    let key = line[..colon].trim();
82    let val = line[colon + 1..].trim();
83
84    // Strip surrounding quotes
85    let val = if (val.starts_with('"') && val.ends_with('"'))
86        || (val.starts_with('\'') && val.ends_with('\''))
87    {
88        val[1..val.len() - 1].to_owned()
89    } else {
90        val.to_owned()
91    };
92
93    Some((key, val))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn parse_basic_frontmatter() {
102        let src = "---\ntitle: \"Hello World\"\nmode: dense\ntoc: false\n---\n# Body";
103        let (fm, body) = parse_frontmatter(src);
104        assert_eq!(fm.title.as_deref(), Some("Hello World"));
105        assert!(fm.is_dense());
106        assert!(fm.skip_toc());
107        assert_eq!(body, "# Body");
108    }
109
110    #[test]
111    fn no_frontmatter() {
112        let src = "# Just a heading\nSome text";
113        let (fm, body) = parse_frontmatter(src);
114        assert!(fm.title.is_none());
115        assert_eq!(body, src);
116    }
117}