use std::{collections::HashMap, path::Path, sync::Arc}; use axum::{response::Html, routing::get, Extension, Router, extract}; 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::{State, WebsiteError}; #[derive(Deserialize, Debug, Default)] pub struct TomlFrontMatter { pub title: String, pub date: Option, pub draft: Option, pub aliases: Option>, pub tags: Option>, } #[derive(Serialize, Clone, Debug)] pub struct Post { pub title: String, pub date: 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")), aliases: fm.aliases.unwrap_or_default(), tags: fm.tags.unwrap_or_default(), } } } #[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| { let options = Options::all(); let mut content_html = String::new(); let parser = Parser::new_ext(&c, options); html::push_html(&mut content_html, parser); content_html }); Ok(Post::new(slug.to_string(), content.unwrap_or_default(), tomlfm)) } #[instrument] 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(tera, post))] async fn render_post(tera: &tera::Tera, post: &Post) -> Result { let mut ctx = tera::Context::new(); ctx.insert("page", &post); tera.render("post.html", &ctx).map_err(|e| e.into()) } pub fn router() -> Router { Router::new() .route("/", get(index)) .route("/:slug/", get(view)) .fallback_service(tower_http::services::ServeDir::new("./posts")) } #[instrument(skip(state))] pub async fn view( extract::Path(slug): extract::Path, Extension(state): Extension>, ) -> Result, WebsiteError> { debug!("viewing post: {slug}"); let post = state .posts .get(&slug) .ok_or(WebsiteError::NotFound)?; let res = render_post(&state.tera, post).await?; Ok(Html(res)) } #[instrument(skip(state))] pub async fn index( Extension(state): Extension>, ) -> Result, WebsiteError> { let mut ctx = tera::Context::new(); let mut posts = state.posts.values().collect::>(); posts.sort_by_key(|p| &p.date); posts.reverse(); ctx.insert("page.title", "Posts"); ctx.insert("posts", &posts); let res = match state.tera.render("posts_index.html", &ctx) { Ok(r) => r, Err(e) => { error!("failed to render posts index: {}", e); return Err(e.into()); } }; Ok(Html(res)) } #[cfg(test)] mod tests { use tera::Tera; #[tokio::test] async fn render_all() { let tera = Tera::new("templates/**/*").unwrap(); let posts = super::load_all().await.unwrap(); for (_slug, post) in posts { super::render_post(&tera, &post).await.unwrap(); } } }