Skip to main content

specfold_core/markdown/frontmatter/
render.rs

1//! Writes frontmatter in a stable, readable YAML form. Only the shapes specfold writes are
2//! supported: scalars, lists of scalars, lists of flat maps, and nested maps.
3
4use serde_json::{Map, Value};
5
6pub fn render(map: &Map<String, Value>) -> String {
7    let mut out = String::from("---\n");
8    for (k, v) in map {
9        entry(&mut out, k, v, 0);
10    }
11    out.push_str("---\n");
12    out
13}
14
15fn entry(out: &mut String, key: &str, v: &Value, indent: usize) {
16    let pad = " ".repeat(indent);
17    match v {
18        Value::Object(m) if !m.is_empty() => {
19            out.push_str(&format!("{pad}{key}:\n"));
20            for (k2, v2) in m {
21                entry(out, k2, v2, indent + 2);
22            }
23        }
24        Value::Array(a) if a.iter().any(Value::is_object) => {
25            out.push_str(&format!("{pad}{key}:\n"));
26            for item in a {
27                out.push_str(&format!("{pad}  - {}\n", inline(item)));
28            }
29        }
30        _ => out.push_str(&format!("{pad}{key}: {}\n", inline(v))),
31    }
32}
33
34fn inline(v: &Value) -> String {
35    match v {
36        Value::Null => "null".into(),
37        Value::Bool(b) => b.to_string(),
38        Value::Number(n) => n.to_string(),
39        Value::String(s) => scalar(s),
40        Value::Array(a) => format!("[{}]", a.iter().map(inline).collect::<Vec<_>>().join(", ")),
41        Value::Object(m) => {
42            let parts: Vec<String> = m.iter().map(|(k, v)| format!("{k}: {}", inline(v))).collect();
43            format!("{{{}}}", parts.join(", "))
44        }
45    }
46}
47
48const RISKY_START: &[char] =
49    &[' ', '-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`'];
50
51fn scalar(s: &str) -> String {
52    let plain = !s.is_empty()
53        && !s.starts_with(RISKY_START)
54        && !s.contains(": ")
55        && !s.contains(" #")
56        && !s.contains([',', '[', ']', '{', '}', '\n', '\t'])
57        && !s.ends_with([' ', ':'])
58        && !matches!(s.to_ascii_lowercase().as_str(), "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~")
59        && s.parse::<f64>().is_err();
60    if plain { s.to_string() } else { serde_json::to_string(s).unwrap_or_default() }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::markdown::frontmatter::{Parsed, parse, split};
67
68    #[test]
69    fn round_trip() {
70        let m: Map<String, Value> = serde_json::from_value(serde_json::json!({
71            "id": "plan:shop/20260101000000",
72            "title": "Ship: the cart, fast",
73            "created": "2026-01-01",
74            "tags": ["a", "b"],
75            "rel": {"governs": ["services/shop/**"]},
76            "files": [{"path": "assets/x.png", "bytes": 3}]
77        }))
78        .unwrap();
79        let text = format!("{}body\n", render(&m));
80        let Parsed::Map(back) = parse(split(&text).raw) else { panic!("{text}") };
81        assert_eq!(back, m);
82    }
83}