1use std::path::{Path, PathBuf};
2
3use serde_yaml::{Mapping, Value};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum MarkdownError {
8 #[error("Missing closing frontmatter delimiter '---' in {path}")]
9 UnclosedFrontmatter { path: PathBuf },
10
11 #[error("Frontmatter in {path} must be a YAML mapping")]
12 FrontmatterNotMapping { path: PathBuf },
13
14 #[error("Failed to parse YAML frontmatter in {path}: {source}")]
15 FrontmatterParse {
16 path: PathBuf,
17 #[source]
18 source: serde_yaml::Error,
19 },
20
21 #[error("Failed to serialize YAML frontmatter: {source}")]
22 FrontmatterSerialize {
23 #[source]
24 source: serde_yaml::Error,
25 },
26}
27
28#[derive(Debug, Clone)]
29pub struct MarkdownDocument {
30 pub frontmatter: Mapping,
31 pub body: String,
32}
33
34pub fn parse_markdown(path: &Path, contents: &str) -> Result<MarkdownDocument, MarkdownError> {
35 let mut lines = contents.lines();
36 let Some(first_line) = lines.next() else {
37 return Ok(MarkdownDocument {
38 frontmatter: Mapping::new(),
39 body: String::new(),
40 });
41 };
42
43 if first_line != "---" {
44 return Ok(MarkdownDocument {
45 frontmatter: Mapping::new(),
46 body: contents.to_string(),
47 });
48 }
49
50 let mut yaml = String::new();
51 let mut found_end = false;
52
53 for line in lines.by_ref() {
54 if line == "---" {
55 found_end = true;
56 break;
57 }
58 yaml.push_str(line);
59 yaml.push('\n');
60 }
61
62 if !found_end {
63 return Err(MarkdownError::UnclosedFrontmatter {
64 path: path.to_path_buf(),
65 });
66 }
67
68 let value: Value =
69 serde_yaml::from_str(&yaml).map_err(|source| MarkdownError::FrontmatterParse {
70 path: path.to_path_buf(),
71 source,
72 })?;
73
74 let Value::Mapping(frontmatter) = value else {
75 return Err(MarkdownError::FrontmatterNotMapping {
76 path: path.to_path_buf(),
77 });
78 };
79
80 let body = lines.collect::<Vec<_>>().join("\n");
81 Ok(MarkdownDocument { frontmatter, body })
82}
83
84pub fn render_markdown(doc: &MarkdownDocument) -> Result<String, MarkdownError> {
85 let mut out = String::new();
86 out.push_str("---\n");
87
88 let yaml = serde_yaml::to_string(&Value::Mapping(doc.frontmatter.clone()))
89 .map_err(|source| MarkdownError::FrontmatterSerialize { source })?;
90 let yaml = yaml.strip_prefix("---\n").unwrap_or(&yaml);
91 out.push_str(yaml);
92 if !out.ends_with('\n') {
93 out.push('\n');
94 }
95 out.push_str("---\n\n");
96
97 out.push_str(&doc.body);
98 if !out.ends_with('\n') {
99 out.push('\n');
100 }
101 Ok(out)
102}
103
104#[must_use]
105pub fn get_string(frontmatter: &Mapping, key: &str) -> Option<String> {
106 frontmatter
107 .get(Value::String(key.to_string()))
108 .and_then(|v| match v {
109 Value::String(s) => Some(s.clone()),
110 _ => None,
111 })
112}
113
114pub fn set_string(frontmatter: &mut Mapping, key: &str, value: impl Into<String>) {
115 frontmatter.insert(Value::String(key.to_string()), Value::String(value.into()));
116}
117
118#[must_use]
119pub fn get_string_list(frontmatter: &Mapping, key: &str) -> Vec<String> {
120 let Some(value) = frontmatter.get(Value::String(key.to_string())) else {
121 return Vec::new();
122 };
123
124 match value {
125 Value::Sequence(seq) => seq
126 .iter()
127 .filter_map(|v| match v {
128 Value::String(s) => Some(s.clone()),
129 _ => None,
130 })
131 .collect(),
132 Value::String(s) => vec![s.clone()],
133 _ => Vec::new(),
134 }
135}
136
137pub fn set_string_list(frontmatter: &mut Mapping, key: &str, values: Vec<String>) {
138 let seq = values.into_iter().map(Value::String).collect();
139 frontmatter.insert(Value::String(key.to_string()), Value::Sequence(seq));
140}