use std::path::Path; use markdown::{mdast::Node, CompileOptions, Constructs, Options, ParseOptions}; use serde::Deserialize; use tokio::{fs, io::AsyncReadExt}; use chrono::{DateTime, Utc}; use crate::{ error::BlossomError, posts::{Post, PostType}, Result, }; static ARTICLES_DIR: &str = "./articles"; #[derive(Clone)] pub struct Blogpost { file_name: String, title: String, published_at: DateTime, updated_at: Option>, tags: Vec, content: String, } #[derive(Deserialize)] pub struct BlogpostMetadata { title: String, published_at: String, updated_at: Option, tags: Vec, } impl Post for Blogpost { fn id(&self) -> &str { &self.file_name } fn subject(&self) -> Option<&str> { Some(&self.title) } fn published_at(&self) -> &DateTime { &self.published_at } fn updated_at(&self) -> &DateTime { if let Some(updated_at) = &self.updated_at { updated_at } else { &self.published_at } } fn tags(&self) -> &Vec { &self.tags } fn lang(&self) -> &str { "en" } fn post_type(&self) -> PostType { PostType::Article } fn content(&self) -> &str { &self.content } } impl Blogpost { pub fn file_name(&self) -> &str { &self.file_name } async fn from_path(path: impl AsRef) -> Result { let mut file = fs::File::open(&path).await?; let mut buf = String::new(); file.read_to_string(&mut buf).await?; let parse_options = ParseOptions { constructs: Constructs { frontmatter: true, ..Constructs::gfm() }, ..ParseOptions::default() }; let metadata: BlogpostMetadata = markdown::to_mdast(&buf, &parse_options) .unwrap() .try_into()?; let file_name = path .as_ref() .file_name() .ok_or(BlossomError::NotAFile)? .to_string_lossy(); let file_name = file_name[..file_name.len() - 3].to_owned(); let updated_at = if let Some(updated_at) = metadata.updated_at { Some(updated_at.parse::>()?) } else { None }; // TODO: cache render only when needed let options = Options { parse: parse_options, compile: CompileOptions { gfm_task_list_item_checkable: true, allow_dangerous_html: true, ..CompileOptions::default() }, }; let content = markdown::to_html_with_options(&buf, &options).unwrap(); Ok(Self { file_name, title: metadata.title, published_at: metadata.published_at.parse::>()?, updated_at, tags: metadata.tags, content, }) } // async fn render(&mut self) -> Result { // let path = Path::new(ARTICLES_DIR) // .join(&self.file_name) // .with_extension("md"); // // TODO: remove unwraps when file read failure // let mut file = fs::File::open(&path).await.unwrap(); // let mut buf = String::new(); // file.read_to_string(&mut buf).await.unwrap(); // let parse_options = ParseOptions { // constructs: Constructs { // frontmatter: true, // ..Constructs::gfm() // }, // ..ParseOptions::default() // }; // let options = Options { // parse: parse_options, // compile: CompileOptions { // gfm_task_list_item_checkable: true, // allow_dangerous_html: true, // ..CompileOptions::default() // }, // }; // let content = markdown::to_html_with_options(&buf, &options).unwrap(); // Ok(content) // } } impl TryFrom for BlogpostMetadata { type Error = BlossomError; fn try_from(tree: Node) -> std::prelude::v1::Result { let children = tree.children(); if let Some(children) = children { if let Some(toml) = children.into_iter().find_map(|el| match el { Node::Toml(toml) => Some(&toml.value), _ => None, }) { return Ok(toml::from_str(toml)?); }; } Err(BlossomError::NoMetadata) } } pub async fn get_blogposts() -> Result> { let mut blogposts: Vec = Vec::new(); let mut articles_dir = fs::read_dir(ARTICLES_DIR).await?; while let Some(entry) = articles_dir.next_entry().await? { let blogpost = Blogpost::from_path(entry.path()).await?; blogposts.push(blogpost); } blogposts.sort_by_key(|post| post.published_at); blogposts.reverse(); Ok(blogposts) } pub async fn get_blogpost(file_name: &str) -> Result { let path = Path::new(ARTICLES_DIR) .join(Path::new(file_name)) .with_extension("md"); println!("{:?}", path); Blogpost::from_path(path).await }