Skip to main content

specfold_core/markdown/frontmatter/
yaml.rs

1//! Parses YAML frontmatter into JSON values, so the rest of the code handles one value model.
2
3use serde_json::{Map, Value};
4use yaml_rust2::{Yaml, YamlLoader};
5
6#[derive(Debug)]
7pub enum Parsed {
8    None,
9    Map(Map<String, Value>),
10    Invalid(String),
11}
12
13pub fn parse(raw: Option<&str>) -> Parsed {
14    let Some(raw) = raw else { return Parsed::None };
15    if raw.trim().is_empty() {
16        return Parsed::Map(Map::new());
17    }
18    match YamlLoader::load_from_str(raw) {
19        Ok(docs) => match docs.into_iter().next() {
20            Some(y @ Yaml::Hash(_)) => match to_json(&y) {
21                Value::Object(m) => Parsed::Map(m),
22                _ => Parsed::Invalid("frontmatter is not a mapping".into()),
23            },
24            Some(Yaml::Null) | None => Parsed::Map(Map::new()),
25            Some(_) => Parsed::Invalid("frontmatter is not a mapping".into()),
26        },
27        Err(e) => Parsed::Invalid(e.to_string()),
28    }
29}
30
31pub fn to_json(y: &Yaml) -> Value {
32    match y {
33        Yaml::Real(s) => s
34            .parse::<f64>()
35            .ok()
36            .and_then(serde_json::Number::from_f64)
37            .map(Value::Number)
38            .unwrap_or_else(|| Value::String(s.clone())),
39        Yaml::Integer(i) => Value::Number((*i).into()),
40        Yaml::String(s) => Value::String(s.clone()),
41        Yaml::Boolean(b) => Value::Bool(*b),
42        Yaml::Array(a) => Value::Array(a.iter().map(to_json).collect()),
43        Yaml::Hash(h) => Value::Object(
44            h.iter()
45                .filter_map(|(k, v)| {
46                    let key = match k {
47                        Yaml::String(s) | Yaml::Real(s) => s.clone(),
48                        Yaml::Integer(i) => i.to_string(),
49                        Yaml::Boolean(b) => b.to_string(),
50                        _ => return None,
51                    };
52                    Some((key, to_json(v)))
53                })
54                .collect(),
55        ),
56        Yaml::Null | Yaml::BadValue | Yaml::Alias(_) => Value::Null,
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn nested_and_invalid() {
66        let Parsed::Map(m) = parse(Some("id: a\nrel:\n  governs: [\"services/shop/**\"]\n")) else { panic!() };
67        assert_eq!(m["rel"]["governs"][0], "services/shop/**");
68        assert!(matches!(parse(Some("a: [unclosed\n")), Parsed::Invalid(_)));
69        assert!(matches!(parse(Some("- a\n- b\n")), Parsed::Invalid(_)));
70    }
71}