Skip to main content

ix_core/
markdown.rs

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}