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 #[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
292fn 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}