cobalt_config/
frontmatter.rs

1use std::fmt;
2
3use super::*;
4
5#[derive(Debug, Eq, PartialEq, Default, Clone, serde::Serialize, serde::Deserialize)]
6#[serde(default)]
7#[serde(rename_all = "snake_case")]
8#[cfg_attr(feature = "unstable", serde(deny_unknown_fields))]
9#[cfg_attr(not(feature = "unstable"), non_exhaustive)]
10pub struct Frontmatter {
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub permalink: Option<Permalink>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub slug: Option<liquid_core::model::KString>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub title: Option<liquid_core::model::KString>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub description: Option<liquid_core::model::KString>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub excerpt: Option<liquid_core::model::KString>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub categories: Option<Vec<liquid_core::model::KString>>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub tags: Option<Vec<liquid_core::model::KString>>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub excerpt_separator: Option<liquid_core::model::KString>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub published_date: Option<DateTime>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub format: Option<SourceFormat>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub templated: Option<bool>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub layout: Option<liquid_core::model::KString>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub is_draft: Option<bool>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub weight: Option<i32>,
39    #[serde(skip_serializing_if = "liquid_core::Object::is_empty")]
40    pub data: liquid_core::Object,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub pagination: Option<Pagination>,
43    // Controlled by where the file is found.  We might allow control over the type at a later
44    // point but we need to first define those semantics.
45    #[serde(skip)]
46    pub collection: Option<liquid_core::model::KString>,
47}
48
49impl Frontmatter {
50    pub fn empty() -> Self {
51        Self::default()
52    }
53
54    pub fn merge_path(mut self, relpath: &relative_path::RelativePath) -> Self {
55        if let Some(name) = relpath.file_name() {
56            let mut split_name = path::split_ext(name);
57
58            #[cfg(feature = "preview_unstable")]
59            if split_name.1 == Some("liquid") {
60                self.templated.get_or_insert(true);
61                split_name = path::split_ext(split_name.0);
62            } else {
63                self.templated.get_or_insert(false);
64            }
65
66            let format = match split_name.1 {
67                Some("md") => SourceFormat::Markdown,
68                _ => SourceFormat::Raw,
69            };
70            self.format.get_or_insert(format);
71
72            while split_name.1.is_some() {
73                split_name = path::split_ext(split_name.0);
74            }
75
76            if self.published_date.is_none() || self.slug.is_none() {
77                let file_stem = split_name.0;
78                let (file_date, file_stem) = path::parse_file_stem(file_stem);
79                if self.published_date.is_none() {
80                    self.published_date = file_date;
81                }
82                if self.slug.is_none() {
83                    let slug = path::slugify(file_stem);
84                    if self.title.is_none() {
85                        self.title = Some(path::titleize_slug(&slug));
86                    }
87                    self.slug = Some(slug);
88                }
89            }
90        }
91
92        self
93    }
94
95    pub fn merge(self, other: &Self) -> Self {
96        let Self {
97            permalink,
98            slug,
99            title,
100            description,
101            excerpt,
102            categories,
103            tags,
104            excerpt_separator,
105            published_date,
106            format,
107            templated,
108            layout,
109            is_draft,
110            weight,
111            collection,
112            data,
113            pagination,
114        } = self;
115        Self {
116            permalink: permalink.or_else(|| other.permalink.clone()),
117            slug: slug.or_else(|| other.slug.clone()),
118            title: title.or_else(|| other.title.clone()),
119            description: description.or_else(|| other.description.clone()),
120            excerpt: excerpt.or_else(|| other.excerpt.clone()),
121            categories: categories.or_else(|| other.categories.clone()),
122            tags: tags.or_else(|| other.tags.clone()),
123            excerpt_separator: excerpt_separator.or_else(|| other.excerpt_separator.clone()),
124            published_date: published_date.or(other.published_date),
125            format: format.or(other.format),
126            templated: templated.or(other.templated),
127            layout: layout.or_else(|| other.layout.clone()),
128            is_draft: is_draft.or(other.is_draft),
129            weight: weight.or(other.weight),
130            collection: collection.or_else(|| other.collection.clone()),
131            data: merge_objects(data, &other.data),
132            pagination: merge_pagination(pagination, &other.pagination),
133        }
134    }
135}
136
137impl fmt::Display for Frontmatter {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        let converted = serde_yaml::to_string(self).expect("should always be valid");
140        let subset = converted
141            .strip_prefix("---")
142            .unwrap_or(converted.as_str())
143            .trim();
144        let converted = if subset == "{}" { "" } else { subset };
145        if converted.is_empty() {
146            Ok(())
147        } else {
148            write!(f, "{converted}")
149        }
150    }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
154#[serde(untagged)]
155#[cfg_attr(feature = "unstable", serde(deny_unknown_fields))]
156#[cfg_attr(not(feature = "unstable"), non_exhaustive)]
157pub enum Permalink {
158    Alias(PermalinkAlias),
159    Explicit(ExplicitPermalink),
160}
161
162impl Permalink {
163    pub fn as_str(&self) -> &str {
164        match self {
165            Permalink::Alias(PermalinkAlias::Path) => "/{{parent}}/{{name}}{{ext}}",
166            Permalink::Explicit(path) => path.as_str(),
167        }
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
172#[serde(rename_all = "snake_case")]
173#[serde(deny_unknown_fields)]
174#[cfg_attr(not(feature = "unstable"), non_exhaustive)]
175pub enum PermalinkAlias {
176    Path,
177}
178
179impl std::ops::Deref for Permalink {
180    type Target = str;
181
182    #[inline]
183    fn deref(&self) -> &str {
184        self.as_str()
185    }
186}
187
188impl AsRef<str> for Permalink {
189    #[inline]
190    fn as_ref(&self) -> &str {
191        self.as_str()
192    }
193}
194
195impl Default for Permalink {
196    fn default() -> Self {
197        Permalink::Alias(PermalinkAlias::Path)
198    }
199}
200
201impl fmt::Display for Permalink {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.as_str())
204    }
205}
206
207#[derive(
208    Default,
209    Clone,
210    Debug,
211    PartialEq,
212    Eq,
213    PartialOrd,
214    Ord,
215    Hash,
216    serde::Serialize,
217    serde::Deserialize,
218)]
219#[repr(transparent)]
220#[serde(try_from = "String")]
221pub struct ExplicitPermalink(liquid_core::model::KString);
222
223impl ExplicitPermalink {
224    pub fn from_unchecked(value: &str) -> Self {
225        Self(liquid_core::model::KString::from_ref(value))
226    }
227
228    pub fn as_str(&self) -> &str {
229        self.0.as_str()
230    }
231}
232
233impl fmt::Display for ExplicitPermalink {
234    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
235        self.0.fmt(fmt)
236    }
237}
238
239impl TryFrom<&str> for ExplicitPermalink {
240    type Error = &'static str;
241
242    fn try_from(value: &str) -> Result<Self, Self::Error> {
243        if !value.starts_with('/') {
244            Err("Permalinks must be absolute paths")
245        } else {
246            let path = Self(liquid_core::model::KString::from_ref(value));
247            Ok(path)
248        }
249    }
250}
251
252impl TryFrom<String> for ExplicitPermalink {
253    type Error = &'static str;
254
255    fn try_from(value: String) -> Result<Self, Self::Error> {
256        let value = value.as_str();
257        Self::try_from(value)
258    }
259}
260
261impl std::ops::Deref for ExplicitPermalink {
262    type Target = str;
263
264    #[inline]
265    fn deref(&self) -> &str {
266        self.as_str()
267    }
268}
269
270impl AsRef<str> for ExplicitPermalink {
271    #[inline]
272    fn as_ref(&self) -> &str {
273        self.as_str()
274    }
275}
276
277#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone, serde::Serialize, serde::Deserialize)]
278#[cfg_attr(feature = "preview_unstable", serde(rename_all = "snake_case"))]
279#[cfg_attr(feature = "unstable", serde(deny_unknown_fields))]
280#[cfg_attr(not(feature = "unstable"), non_exhaustive)]
281#[derive(Default)]
282pub enum SourceFormat {
283    #[default]
284    Raw,
285    Markdown,
286    #[cfg(not(feature = "unstable"))]
287    #[doc(hidden)]
288    #[serde(other)]
289    Unknown,
290}
291
292/// Shallow merge of `liquid_core::Object`'s
293fn merge_objects(
294    mut primary: liquid_core::Object,
295    secondary: &liquid_core::Object,
296) -> liquid_core::Object {
297    for (key, value) in secondary {
298        primary
299            .entry(key.to_owned())
300            .or_insert_with(|| value.clone());
301    }
302    primary
303}
304
305fn merge_pagination(
306    primary: Option<Pagination>,
307    secondary: &Option<Pagination>,
308) -> Option<Pagination> {
309    if let Some(primary) = primary {
310        if let Some(secondary) = secondary {
311            Some(primary.merge(secondary))
312        } else {
313            Some(primary)
314        }
315    } else {
316        secondary.clone()
317    }
318}
319
320#[cfg(test)]
321mod test {
322    use super::*;
323
324    #[test]
325    fn display_empty() {
326        let front = Frontmatter::empty();
327        assert_eq!(&front.to_string(), "");
328    }
329
330    #[test]
331    fn display_slug() {
332        let front = Frontmatter {
333            slug: Some("foo".into()),
334            ..Default::default()
335        };
336        assert_eq!(&front.to_string(), "slug: foo");
337    }
338
339    #[test]
340    fn display_permalink_alias() {
341        let front = Frontmatter {
342            permalink: Some(Permalink::Alias(PermalinkAlias::Path)),
343            ..Default::default()
344        };
345        assert_eq!(&front.to_string(), "permalink: path");
346    }
347
348    #[test]
349    fn display_permalink_explicit() {
350        let front = Frontmatter {
351            permalink: Some(Permalink::Explicit(ExplicitPermalink::from_unchecked(
352                "foo",
353            ))),
354            ..Default::default()
355        };
356        assert_eq!(&front.to_string(), "permalink: foo");
357    }
358}