use crate::generator; use crate::generator::markdown; use crate::generator::util::slugify::slugify; use crate::generator::util::{from_frontmatter, word_count}; use anyhow::anyhow; use chrono::{DateTime, Datelike, FixedOffset}; use once_cell::sync::Lazy; use regex::Regex; use serde::de::{SeqAccess, Visitor}; use serde::Deserialize; use std::fs; use std::hash::Hash; use std::io::Read; use std::path::PathBuf; pub async fn parse() -> anyhow::Result>> { let posts_path = generator::content_path("posts/"); let mut posts = vec![]; for ent in fs::read_dir(posts_path)? { let ent = ent.unwrap(); let path: PathBuf; if ent.file_type().unwrap().is_dir() { path = find_index(ent.path()).expect("folder posts must have index file"); } else { path = ent.path(); } let post = parse_post(path)?; let prepared = prepare_post(post); posts.push(prepared); } posts.sort_by(|a, b| b.metadata.date.cmp(&a.metadata.date)); Ok(posts) } fn find_index(path: PathBuf) -> Option { let dir = std::fs::read_dir(path).ok()?; dir.map(|e| e.unwrap()) .find(|e| e.path().file_stem().unwrap().eq_ignore_ascii_case("index")) .map(|e| e.path()) } pub fn parse_post(path: PathBuf) -> anyhow::Result> { let mut f = fs::File::open(&path)?; let mut buffer = String::new(); f.read_to_string(&mut buffer)?; Post::new(path, &buffer) } static EXCERPT_REGEX: Lazy = Lazy::new(|| Regex::new("").unwrap()); pub fn prepare_post(post: Post) -> Post { let wc = post.content.word_count(); let mut new_post = post.to_html(); new_post.word_count = Some(wc); if let Some(m) = EXCERPT_REGEX.find(&new_post.content.0) { new_post.excerpt = Some(new_post.content.0[0..m.start()].trim().into()); } new_post } #[derive(Debug)] pub struct Post { pub path: PathBuf, pub metadata: PostMetadata, pub slug: String, pub word_count: Option, pub excerpt: Option, pub content: Content, } impl Post { pub fn new(path: PathBuf, contents: &str) -> anyhow::Result { let (metadata, rest_contents) = match from_frontmatter::<'_, PostMetadata>(contents) { Ok(res) => res, Err(e) => return Err(e), }; let slug = metadata .slug .clone() .unwrap_or_else(|| slugify(&metadata.title)); let ext = path.extension().unwrap().to_str().unwrap(); let content = content_from(ext, rest_contents)?; Ok(Post { path, metadata, slug: slug.to_owned(), word_count: None, excerpt: None, content, }) } } impl Post { fn to_html(self) -> Post { Post { path: self.path, metadata: self.metadata, slug: self.slug, word_count: self.word_count, excerpt: self.excerpt, content: self.content.to_html(), } } pub fn permalink(&self) -> String { format!("/{}/{}/", self.metadata.date.year(), self.slug) } pub fn comments_permalink(&self) -> String { match self.metadata.use_old_permalink_for_comments { Some(true) => { let old = self.metadata.old_permalink.as_ref().unwrap(); assert_eq!(old.len(), 1); return old[0].clone(); } _ => self.permalink(), } } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct PostMetadata { pub title: String, pub html_title: Option, pub tags: Option>, pub date: DateTime, pub short_desc: Option, slug: Option, pub preamble: Option, #[serde(deserialize_with = "deserialize_old_permalink", default)] pub old_permalink: Option>, pub use_old_permalink_for_comments: Option, pub card_image_path: Option, } fn deserialize_old_permalink<'de, D>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { struct StringOrVec; impl<'de> Visitor<'de> for StringOrVec { type Value = Option>; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("string or vec of strings") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { Ok(Some(vec![value.to_owned()])) } fn visit_seq(self, mut seq: A) -> Result where A: SeqAccess<'de>, { let mut vec = Vec::with_capacity(seq.size_hint().unwrap_or(1)); loop { match seq.next_element::() { Ok(Some(s)) => vec.push(s), Ok(None) => break, Err(e) => return Err(e), } } Ok(Some(vec)) } fn visit_none(self) -> Result where E: serde::de::Error, { println!("visit_none"); Ok(None) } } deserializer.deserialize_any(StringOrVec) } #[derive(Debug, Eq)] pub struct Tag { pub name: String, pub slug: String, } impl<'de> Deserialize<'de> for Tag { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let name = String::deserialize(deserializer)?; let slug = slugify(&name); Ok(Tag { name, slug }) } } impl PartialEq for Tag { fn eq(&self, other: &Self) -> bool { self.slug == other.slug } } impl Hash for Tag { fn hash(&self, state: &mut H) { self.slug.hash(state); } } pub trait PostContent: std::fmt::Debug { fn to_html(self) -> HtmlContent; fn word_count(&self) -> u32; } #[derive(Debug)] pub enum AnyContent { Markdown(MarkdownContent), Html(HtmlContent), } impl PostContent for AnyContent { fn to_html(self) -> HtmlContent { match self { AnyContent::Markdown(inner) => inner.to_html(), AnyContent::Html(inner) => inner, } } fn word_count(&self) -> u32 { match self { AnyContent::Markdown(inner) => inner.word_count(), AnyContent::Html(inner) => inner.word_count(), } } } #[derive(Debug)] pub struct MarkdownContent(String); impl PostContent for MarkdownContent { fn to_html(self) -> HtmlContent { let mut buf = vec![]; markdown::render(&self.0, &mut buf); HtmlContent(String::from_utf8(buf).unwrap()) } fn word_count(&self) -> u32 { word_count::markdown(&self.0) } } #[derive(Debug, Clone)] pub struct HtmlContent(String); impl PostContent for HtmlContent { fn to_html(self) -> HtmlContent { self } fn word_count(&self) -> u32 { word_count::html(&self.0) } } impl HtmlContent { pub fn html(&self) -> &str { &self.0 } } fn content_from(extension: &str, content: &str) -> anyhow::Result { match extension { "md" => Ok(AnyContent::Markdown(MarkdownContent(content.to_owned()))), "html" => Ok(AnyContent::Html(HtmlContent(content.to_owned()))), _ => Err(anyhow!("unknown extension {}", extension)), } } #[cfg(test)] mod tests { use chrono::{DateTime, FixedOffset, TimeZone}; use serde::Deserialize; #[derive(Deserialize)] struct DeserializedDate { d: DateTime, } #[test] fn test_deserialize_date() { let deserialized: DeserializedDate = toml::from_str(r#"d = "2017-02-17 14:30:42 -0400""#).unwrap(); let expected = FixedOffset::west(4 * 3600) .ymd(2017, 2, 17) .and_hms(14, 30, 42); assert_eq!(deserialized.d, expected); } #[test] fn test_deserialize_old_permalink() { let none: super::PostMetadata = toml::from_str( r#" title = "Mocking HTTP Requests for iOS App UI Tests" date = "2019-12-22 19:12:42 -0400" "#, ) .unwrap(); assert_eq!(none.old_permalink, None); let single: super::PostMetadata = toml::from_str( r#" title = "Mocking HTTP Requests for iOS App UI Tests" date = "2019-12-22 19:12:42 -0400" old_permalink = "/ios/2019/mock-http-ios-ui-testing/" "#, ) .unwrap(); assert_eq!( single.old_permalink, Some(vec!["/ios/2019/mock-http-ios-ui-testing/".into()]) ); let multi: super::PostMetadata = toml::from_str( r#" title = "Mocking HTTP Requests for iOS App UI Tests" date = "2019-12-22 19:12:42 -0400" old_permalink = ["/ios/2019/mock-http-ios-ui-testing/", "something else"] "#, ) .unwrap(); assert_eq!( multi.old_permalink, Some(vec![ "/ios/2019/mock-http-ios-ui-testing/".into(), "something else".into() ]) ); } }