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<Utc>,
updated_at: Option<DateTime<Utc>>,
tags: Vec<String>,
content: String,
}
#[derive(Deserialize)]
pub struct BlogpostMetadata {
title: String,
published_at: String,
updated_at: Option<String>,
tags: Vec<String>,
}
impl Post for Blogpost {
fn id(&self) -> &str {
&self.file_name
}
fn subject(&self) -> Option<&str> {
Some(&self.title)
}
fn published_at(&self) -> &DateTime<Utc> {
&self.published_at
}
fn updated_at(&self) -> &DateTime<Utc> {
if let Some(updated_at) = &self.updated_at {
updated_at
} else {
&self.published_at
}
}
fn tags(&self) -> &Vec<String> {
&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<Path>) -> Result<Self> {
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::<DateTime<Utc>>()?)
} 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::<DateTime<Utc>>()?,
updated_at,
tags: metadata.tags,
content,
})
}
// async fn render(&mut self) -> Result<String> {
// 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<Node> for BlogpostMetadata {
type Error = BlossomError;
fn try_from(tree: Node) -> std::prelude::v1::Result<Self, Self::Error> {
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<Vec<Blogpost>> {
let mut blogposts: Vec<Blogpost> = 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<Blogpost> {
let path = Path::new(ARTICLES_DIR)
.join(Path::new(file_name))
.with_extension("md");
println!("{:?}", path);
Blogpost::from_path(path).await
}