specfold_core/markdown/frontmatter/
render.rs1use 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}