use std::{collections::HashMap, path::Path}; use chrono::{DateTime, FixedOffset}; use glob::glob; use lazy_static::lazy_static; use pulldown_cmark::{html, Options, Parser}; use regex::Regex; use serde_derive::{Deserialize, Serialize}; use tokio::fs; use tracing::{instrument, log::*}; use crate::{AppState, WebsiteError, markdown}; #[derive(Deserialize, Debug, Default)] pub struct TomlFrontMatter { pub title: String, pub date: Option, pub updated: Option, pub draft: Option, pub aliases: Option>, pub tags: Option>, } #[derive(Serialize, Clone, Debug, Default)] pub struct Post { pub title: String, pub date: Option>, pub updated: Option>, pub aliases: Vec, pub tags: Vec, pub content: String, pub slug: String, pub absolute_path: String, } impl Post { pub fn new(slug: String, content: String, fm: TomlFrontMatter) -> Post { Post { absolute_path: format!("/posts/{}/", slug), slug, content, title: fm.title, date: fm .date .map(|d| DateTime::parse_from_rfc3339(&d.to_string()).expect("bad toml datetime")), updated: fm .updated .map(|d| DateTime::parse_from_rfc3339(&d.to_string()).expect("bad toml datetime")), aliases: fm.aliases.unwrap_or_default(), tags: fm.tags.unwrap_or_default(), } } pub fn is_published(&self) -> bool { let now = chrono::offset::Local::now(); if let Some(date) = self.date { return date.timestamp() - now.timestamp() <= 0; } true } } #[instrument] pub async fn load_all() -> color_eyre::eyre::Result> { let mut res = HashMap::::new(); for path in glob("posts/**/*.md")? { let path = path.unwrap(); debug!("found page: {}", path.display()); let path = path.to_string_lossy().replace('\\', "/"); let slug = path .trim_start_matches("posts") .trim_start_matches('/') .trim_start_matches('\\') .trim_end_matches(".html") .trim_end_matches(".md") .trim_end_matches("index") .trim_end_matches('\\') .trim_end_matches('/'); let post = load_post(slug).await?; res.insert(slug.to_string(), post); } Ok(res) } #[instrument] pub async fn load_post(slug: &str) -> color_eyre::eyre::Result { debug!("loading post: {slug}"); let file_path = Path::new("posts").join(slug); let content = if let Ok(content) = fs::read_to_string(file_path.with_extension("md")).await { content } else { fs::read_to_string(file_path.join("index.md")).await? }; let (tomlfm, content) = parse_frontmatter(content)?; let tomlfm = tomlfm.expect("Missing frontmatter"); let content = content.map(|c| { markdown::render_markdown_to_html(&c) }).transpose()?; Ok(Post::new( slug.to_string(), content.unwrap_or_default(), tomlfm, )) } #[instrument(skip(src))] fn parse_frontmatter( src: String, ) -> color_eyre::eyre::Result<(Option, Option)> { lazy_static! { static ref FRONTMATTER_REGEX: Regex = regex::Regex::new( r"^[\s]*\+{3}(\r?\n(?s).*?(?-s))\+{3}[\s]*(?:$|(?:\r?\n((?s).*(?-s))$))" ) .unwrap(); }; Ok(if let Some(captures) = FRONTMATTER_REGEX.captures(&src) { ( Some(toml::from_str(captures.get(1).unwrap().as_str())?), captures.get(2).map(|m| m.as_str().to_owned()), ) } else { (None, Some(src)) }) } #[instrument(skip(state, post))] pub async fn render_post(state: &AppState, post: &Post) -> Result { let mut ctx = tera::Context::new(); ctx.insert("page", &post); ctx.insert("base_url", &state.base_url); state.tera.render("post.html", &ctx).map_err(|e| e.into()) } #[cfg(test)] mod tests { use tera::Tera; use crate::AppState; #[tokio::test] async fn render_all_posts() { let state = AppState { base_url: "localhost:8180".into(), posts: super::load_all().await.unwrap(), tera: Tera::new("templates/**/*").unwrap(), ..Default::default() }; for post in state.posts.values() { super::render_post(&state, post).await.unwrap(); } } }