cobalt_config/
path.rs

1use deunicode;
2use itertools::Itertools;
3
4static SLUG_INVALID_CHARS: std::sync::LazyLock<regex::Regex> =
5    std::sync::LazyLock::new(|| regex::Regex::new(r"([^a-zA-Z0-9]+)").unwrap());
6
7/// Create a slug for a given file.  Correlates to Jekyll's :slug path tag
8pub fn slugify<S: AsRef<str>>(name: S) -> liquid_core::model::KString {
9    slugify_str(name.as_ref())
10}
11
12fn slugify_str(name: &str) -> liquid_core::model::KString {
13    let name = deunicode::deunicode_with_tofu(name, "-");
14    let slug = SLUG_INVALID_CHARS.replace_all(&name, "-");
15    let slug = slug.trim_matches('-').to_lowercase();
16    slug.into()
17}
18
19/// Format a user-visible title out of a slug.  Correlates to Jekyll's "title" attribute
20pub fn titleize_slug<S: AsRef<str>>(slug: S) -> liquid_core::model::KString {
21    titleize_slug_str(slug.as_ref())
22}
23
24fn titleize_slug_str(slug: &str) -> liquid_core::model::KString {
25    slug.split('-').map(title_case).join(" ").into()
26}
27
28/// Title-case a single word
29fn title_case(s: &str) -> liquid_core::model::KString {
30    let mut c = s.chars();
31    let title = match c.next() {
32        None => String::new(),
33        Some(f) => f
34            .to_uppercase()
35            .chain(c.flat_map(|t| t.to_lowercase()))
36            .collect(),
37    };
38    title.into()
39}
40
41#[derive(
42    Default,
43    Clone,
44    Debug,
45    PartialEq,
46    Eq,
47    PartialOrd,
48    Ord,
49    Hash,
50    serde::Serialize,
51    serde::Deserialize,
52)]
53#[repr(transparent)]
54#[serde(try_from = "String")]
55pub struct RelPath(relative_path::RelativePathBuf);
56
57impl RelPath {
58    pub fn new() -> Self {
59        let path = relative_path::RelativePathBuf::new();
60        Self(path)
61    }
62
63    pub fn from_path(value: impl AsRef<std::path::Path>) -> Option<Self> {
64        let value = value.as_ref();
65        let path: Option<relative_path::RelativePathBuf> =
66            value.components().map(|c| c.as_os_str().to_str()).collect();
67        let path = path?.normalize();
68        Some(Self(path))
69    }
70
71    pub fn from_unchecked(value: impl AsRef<std::path::Path>) -> Self {
72        Self::from_path(value).unwrap()
73    }
74
75    pub fn as_str(&self) -> &str {
76        self.0.as_str()
77    }
78
79    pub fn as_path(&self) -> &std::path::Path {
80        std::path::Path::new(self.as_str())
81    }
82
83    pub fn into_inner(self) -> relative_path::RelativePathBuf {
84        self.0
85    }
86}
87
88impl PartialEq<str> for RelPath {
89    #[inline]
90    fn eq(&self, other: &str) -> bool {
91        *self == RelPath::from_unchecked(other)
92    }
93}
94
95impl<'s> PartialEq<&'s str> for RelPath {
96    #[inline]
97    fn eq(&self, other: &&'s str) -> bool {
98        *self == RelPath::from_unchecked(*other)
99    }
100}
101
102impl std::fmt::Display for RelPath {
103    fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        self.0.fmt(fmt)
105    }
106}
107
108impl TryFrom<&str> for RelPath {
109    type Error = &'static str;
110
111    fn try_from(value: &str) -> Result<Self, Self::Error> {
112        let path = std::path::Path::new(value);
113        if path.is_absolute() || path.has_root() {
114            Err("Absolute paths are not supported")
115        } else {
116            Ok(Self::from_unchecked(value))
117        }
118    }
119}
120
121impl TryFrom<String> for RelPath {
122    type Error = &'static str;
123
124    fn try_from(value: String) -> Result<Self, Self::Error> {
125        let value = value.as_str();
126        Self::try_from(value)
127    }
128}
129
130impl std::ops::Deref for RelPath {
131    type Target = relative_path::RelativePath;
132
133    #[inline]
134    fn deref(&self) -> &Self::Target {
135        self.as_ref()
136    }
137}
138
139impl AsRef<str> for RelPath {
140    #[inline]
141    fn as_ref(&self) -> &str {
142        self.as_str()
143    }
144}
145
146impl AsRef<std::path::Path> for RelPath {
147    #[inline]
148    fn as_ref(&self) -> &std::path::Path {
149        self.as_path()
150    }
151}
152
153impl AsRef<relative_path::RelativePath> for RelPath {
154    #[inline]
155    fn as_ref(&self) -> &relative_path::RelativePath {
156        &self.0
157    }
158}
159
160#[cfg(test)]
161mod test_slug {
162    use super::*;
163
164    #[test]
165    fn test_slugify() {
166        let actual = slugify("___filE-worldD-__09___");
167        assert_eq!(actual, "file-worldd-09");
168    }
169
170    #[test]
171    fn test_slugify_unicode() {
172        let actual = slugify("__Æneid__北亰-worldD-__09___");
173        assert_eq!(actual, "aeneid-bei-jing-worldd-09");
174    }
175
176    #[test]
177    fn test_titleize_slug() {
178        let actual = titleize_slug("tItLeIzE-sLuG");
179        assert_eq!(actual, "Titleize Slug");
180    }
181}
182
183pub fn split_ext(name: &str) -> (&str, Option<&str>) {
184    name.rsplit_once('.')
185        .map(|(n, e)| (n, Some(e)))
186        .unwrap_or_else(|| (name, None))
187}
188
189static DATE_PREFIX_REF: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
190    regex::Regex::new(r"^(\d{4})-(\d{1,2})-(\d{1,2})[- ](.*)$").unwrap()
191});
192
193pub fn parse_file_stem(stem: &str) -> (Option<crate::DateTime>, liquid_core::model::KString) {
194    let parts = DATE_PREFIX_REF.captures(stem).map(|caps| {
195        let year: i32 = caps
196            .get(1)
197            .expect("unconditional capture")
198            .as_str()
199            .parse()
200            .expect("regex gets back an integer");
201        let month: u8 = caps
202            .get(2)
203            .expect("unconditional capture")
204            .as_str()
205            .parse()
206            .expect("regex gets back an integer");
207        let day: u8 = caps
208            .get(3)
209            .expect("unconditional capture")
210            .as_str()
211            .parse()
212            .expect("regex gets back an integer");
213        let published = crate::DateTime::from_ymd(year, month, day);
214        (
215            Some(published),
216            liquid_core::model::KString::from_ref(
217                caps.get(4).expect("unconditional capture").as_str(),
218            ),
219        )
220    });
221
222    parts.unwrap_or_else(|| (None, liquid_core::model::KString::from_ref(stem)))
223}
224
225#[cfg(test)]
226mod test_stem {
227    use super::*;
228
229    #[test]
230    fn parse_file_stem_empty() {
231        assert_eq!(parse_file_stem(""), (None, "".into()));
232    }
233
234    #[test]
235    fn parse_file_stem_none() {
236        assert_eq!(
237            parse_file_stem("First Blog Post"),
238            (None, "First Blog Post".into())
239        );
240    }
241
242    #[test]
243    #[should_panic]
244    fn parse_file_stem_out_of_range_month() {
245        assert_eq!(
246            parse_file_stem("2017-30-5 First Blog Post"),
247            (None, "2017-30-5 First Blog Post".into())
248        );
249    }
250
251    #[test]
252    #[should_panic]
253    fn parse_file_stem_out_of_range_day() {
254        assert_eq!(
255            parse_file_stem("2017-3-50 First Blog Post"),
256            (None, "2017-3-50 First Blog Post".into())
257        );
258    }
259
260    #[test]
261    fn parse_file_stem_single_digit() {
262        assert_eq!(
263            parse_file_stem("2017-3-5 First Blog Post"),
264            (
265                Some(crate::DateTime::from_ymd(2017, 3, 5)),
266                "First Blog Post".into()
267            )
268        );
269    }
270
271    #[test]
272    fn parse_file_stem_double_digit() {
273        assert_eq!(
274            parse_file_stem("2017-12-25 First Blog Post"),
275            (
276                Some(crate::DateTime::from_ymd(2017, 12, 25)),
277                "First Blog Post".into()
278            )
279        );
280    }
281
282    #[test]
283    fn parse_file_stem_double_digit_leading_zero() {
284        assert_eq!(
285            parse_file_stem("2017-03-05 First Blog Post"),
286            (
287                Some(crate::DateTime::from_ymd(2017, 3, 5)),
288                "First Blog Post".into()
289            )
290        );
291    }
292
293    #[test]
294    fn parse_file_stem_dashed() {
295        assert_eq!(
296            parse_file_stem("2017-3-5-First-Blog-Post"),
297            (
298                Some(crate::DateTime::from_ymd(2017, 3, 5)),
299                "First-Blog-Post".into()
300            )
301        );
302    }
303}
304
305#[cfg(test)]
306mod test_rel_path {
307    use super::*;
308    use std::convert::TryFrom;
309
310    #[test]
311    fn test_try_from_cwd_is_empty() {
312        assert_eq!(RelPath::new().as_str(), "");
313        assert_eq!(RelPath::default().as_str(), "");
314        assert_eq!(RelPath::try_from(".").unwrap().as_str(), "");
315        assert_eq!(RelPath::try_from("./").unwrap().as_str(), "");
316    }
317
318    #[test]
319    fn test_try_from_relpath_works() {
320        assert_eq!(RelPath::try_from("./foo/bar").unwrap().as_str(), "foo/bar");
321        assert_eq!(RelPath::try_from("foo/bar").unwrap().as_str(), "foo/bar");
322    }
323
324    #[test]
325    fn test_try_from_abspath_fails() {
326        let case = RelPath::try_from("/foo/bar");
327        println!("{case:?}");
328        assert!(case.is_err());
329    }
330}