aboutsummaryrefslogblamecommitdiffstats
path: root/src/blog.rs
blob: 22cee052324ddf1f88670096ce691b89b940ed9d (plain) (tree)

























































































































































































                                                                                 
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
}