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
7pub 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
19pub 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
28fn 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}