use std::{collections::HashMap, path::Path}; use anyhow::Result; use cached::once_cell::sync::Lazy; use chrono::{DateTime, FixedOffset}; use glob::glob; use regex::Regex; use serde_derive::{Deserialize, Serialize}; use tokio::fs; use tracing::{ instrument, log::{debug, warn}, }; use crate::{helpers, markdown, AppState, WebsiteError}; static FRONTMATTER_REGEX: Lazy = Lazy::new(|| { Regex::new(r"^[\s]*\+{3}(\r?\n(?s).*?(?-s))\+{3}[\s]*(?:$|(?:\r?\n((?s).*(?-s))$))").unwrap() }); #[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 draft: bool, 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, draft: fm.draft.unwrap_or(false), 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 } pub fn last_modified(&self) -> Option> { self.updated.or(self.date) } } #[instrument(skip(state))] pub async fn load_all(state: &AppState) -> 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(state, slug).await?; res.insert(slug.to_string(), post); } Ok(res) } #[instrument(skip(state))] pub async fn load_post(state: &AppState, slug: &str) -> 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 base_uri = helpers::uri_with_path(&state.base_url, &format!("/posts/{slug}/")); let content = content.map(|c| markdown::render_markdown_to_html(Some(&base_uri), &c)); Ok(Post::new( slug.to_string(), content.unwrap_or_default(), tomlfm, )) } #[instrument(skip(src))] fn parse_frontmatter(src: String) -> Result<(Option, Option)> { 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.to_string()); state .tera .render("post.html", &ctx) .map_err(std::convert::Into::into) } #[cfg(test)] mod tests { use tera::Tera; use crate::AppState; #[tokio::test] async fn render_all_posts() { let mut state = AppState { base_url: "http://localhost:8180".parse().unwrap(), tera: Tera::new("templates/**/*").unwrap(), ..Default::default() }; state.posts = super::load_all(&state).await.unwrap(); for post in state.posts.values() { super::render_post(&state, post).await.unwrap(); } } }