mdmodels/markdown/
frontmatter.rs

1/*
2 * Copyright (c) 2025 Jan Range
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a copy
5 * of this software and associated documentation files (the "Software"), to deal
6 * in the Software without restriction, including without limitation the rights
7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 * copies of the Software, and to permit persons to whom the Software is
9 * furnished to do so, subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in
12 * all copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 * THE SOFTWARE.
21 *
22 */
23
24use std::{collections::HashMap, error::Error, path::Path};
25
26use gray_matter::{engine::YAML, Matter};
27use serde::{Deserialize, Serialize};
28
29#[cfg(feature = "python")]
30use pyo3::pyclass;
31
32#[cfg(feature = "wasm")]
33use tsify_next::Tsify;
34
35use crate::prelude::DataModel;
36
37/// Represents the front matter data of a markdown file.
38#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
39#[cfg_attr(feature = "python", pyclass(get_all))]
40#[cfg_attr(feature = "wasm", derive(Tsify))]
41#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
42pub struct FrontMatter {
43    /// Identifier field of the model.
44    pub id: Option<String>,
45    /// A boolean field with a default value, renamed from `id-field`.
46    #[serde(default = "default_id_field", rename = "id-field")]
47    pub id_field: bool,
48    /// Optional hashmap of prefixes.
49    pub prefixes: Option<HashMap<String, String>>,
50    /// Optional namespace map.
51    pub nsmap: Option<HashMap<String, String>>,
52    /// A string field with a default value representing the repository URL.
53    #[serde(default = "default_repo")]
54    pub repo: String,
55    /// A string field with a default value representing the prefix.
56    #[serde(default = "default_prefix")]
57    pub prefix: String,
58    /// Import remote or local models.
59    #[serde(default)]
60    pub imports: HashMap<String, ImportType>,
61    /// Allow empty models.
62    #[serde(default = "default_allow_empty", rename = "allow-empty")]
63    pub allow_empty: bool,
64}
65
66impl FrontMatter {
67    pub fn new() -> Self {
68        FrontMatter {
69            id: None,
70            id_field: default_id_field(),
71            prefixes: None,
72            nsmap: None,
73            repo: default_repo(),
74            prefix: default_prefix(),
75            imports: HashMap::new(),
76            allow_empty: false,
77        }
78    }
79
80    /// Returns the value of the `id_field`.
81    ///
82    /// # Returns
83    /// A boolean representing the `id_field`.
84    pub fn id_field(&self) -> bool {
85        self.id_field
86    }
87
88    /// Returns the prefixes as an optional vector of key-value pairs.
89    ///
90    /// # Returns
91    /// An optional vector of tuples containing the prefixes.
92    pub fn prefixes(&self) -> Option<Vec<(String, String)>> {
93        self.prefixes.as_ref().map(|prefixes| {
94            prefixes
95                .iter()
96                .map(|(k, v)| (k.clone(), v.clone()))
97                .collect()
98        })
99    }
100
101    /// Returns a reference to the namespace map.
102    ///
103    /// # Returns
104    /// A reference to an optional hashmap of the namespace map.
105    pub fn nsmap(&self) -> &Option<HashMap<String, String>> {
106        &self.nsmap
107    }
108}
109
110#[derive(Debug, Serialize, Clone, PartialEq)]
111#[cfg_attr(feature = "python", pyclass(get_all))]
112#[cfg_attr(feature = "wasm", derive(Tsify))]
113#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
114/// Represents different types of model imports.
115///
116/// Can be either a remote URL or a local file path.
117pub enum ImportType {
118    /// A remote URL pointing to a model
119    Remote(String),
120    /// A local file path to a model
121    Local(String),
122}
123
124impl<'de> Deserialize<'de> for ImportType {
125    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
126    where
127        D: serde::Deserializer<'de>,
128    {
129        let s = String::deserialize(deserializer)?;
130
131        // Check if string starts with http:// or https://
132        if s.starts_with("http://") || s.starts_with("https://") {
133            Ok(ImportType::Remote(s))
134        } else {
135            Ok(ImportType::Local(s))
136        }
137    }
138}
139
140impl ImportType {
141    /// Fetches and parses the model from either remote or local source.
142    ///
143    /// # Returns
144    /// A Result containing the parsed DataModel or an error.
145    pub fn fetch(&self, dirpath: Option<&Path>) -> Result<DataModel, Box<dyn Error>> {
146        match self {
147            ImportType::Remote(url) => self.fetch_remote_model(url),
148            ImportType::Local(path) => self.fetch_local_model(path, dirpath),
149        }
150    }
151
152    /// Fetches and parses a model from a remote URL.
153    ///
154    /// # Arguments
155    /// * `url` - The URL to fetch the model from
156    ///
157    /// # Returns
158    /// A Result containing the parsed DataModel or an error.
159    fn fetch_remote_model(&self, _: &str) -> Result<DataModel, Box<dyn Error>> {
160        unimplemented!(
161            "Fetching remote models is not supported yet due to incompatibility with WASM"
162        );
163    }
164
165    /// Fetches and parses a model from a local file path.
166    ///
167    /// # Arguments
168    /// * `path` - The file path to read the model from
169    ///
170    /// # Returns
171    /// A Result containing the parsed DataModel or an error.
172    fn fetch_local_model(
173        &self,
174        path: &str,
175        dirpath: Option<&Path>,
176    ) -> Result<DataModel, Box<dyn Error>> {
177        let path = if let Some(dirpath) = dirpath {
178            dirpath.parent().unwrap().join(path).display().to_string()
179        } else {
180            path.to_string()
181        };
182        let data = std::fs::read_to_string(path)?;
183        let model = DataModel::from_markdown_string(&data)?;
184        Ok(model)
185    }
186}
187
188impl Default for FrontMatter {
189    /// Provides default values for `FrontMatter`.
190    ///
191    /// # Returns
192    /// A `FrontMatter` instance with default values.
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198/// Provides the default value for the `id_field`.
199///
200/// # Returns
201/// A boolean with the default value `true`.
202fn default_id_field() -> bool {
203    true
204}
205
206/// Provides the default value for the `prefix`.
207///
208/// # Returns
209/// A string with the default value `"md"`.
210fn default_prefix() -> String {
211    "md".to_string()
212}
213
214/// Provides the default value for the `repo`.
215///
216/// # Returns
217/// A string with the default value `"http://mdmodel.net/"`.
218fn default_repo() -> String {
219    "http://mdmodel.net/".to_string()
220}
221
222/// Provides the default value for the `allow_empty`.
223///
224/// # Returns
225/// A boolean with the default value `false`.
226fn default_allow_empty() -> bool {
227    false
228}
229
230/// Parses the front matter from the given content.
231///
232/// # Arguments
233/// * `content` - A string slice that holds the content to parse.
234///
235/// # Returns
236/// An optional `FrontMatter` if parsing is successful, otherwise `None`.
237pub fn parse_frontmatter(content: &str) -> Option<FrontMatter> {
238    let matter = Matter::<YAML>::new();
239    let result = matter.parse(content);
240
241    match result.data {
242        None => None,
243        Some(data) => {
244            let matter = data
245                .deserialize()
246                .expect("Could not deserialize frontmatter");
247            Some(matter)
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use pretty_assertions::assert_eq;
255    use std::path::Path;
256
257    use super::*;
258
259    /// Tests the `parse_frontmatter` function.
260    #[test]
261    fn test_parse_frontmatter() {
262        // Arrange
263        let path = Path::new("tests/data/model.md");
264        let content = std::fs::read_to_string(path).expect("Could not read file");
265
266        // Act
267        let frontmatter = parse_frontmatter(&content)
268            .expect("Could not parse frontmatter from file. Please check the file content.");
269
270        // Assert
271        assert_eq!(frontmatter.id_field, true);
272        assert_eq!(
273            frontmatter.prefixes.unwrap().get("schema").unwrap(),
274            "http://schema.org/"
275        );
276        assert_eq!(
277            frontmatter.nsmap.unwrap().get("tst").unwrap(),
278            "http://example.com/test/"
279        );
280    }
281}