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}