use std::{collections::HashMap, path::Path, sync::Arc}; use axum::{response::Html, routing::get, Extension, Router}; use chrono::{DateTime, FixedOffset}; use glob::glob; use hyper::Uri; 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::{handlers, State}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct FrontMatter { pub title: String, pub date: DateTime, } #[derive(Deserialize, Debug)] pub struct TomlFrontMatter { pub title: String, pub date: toml::value::Datetime, } #[derive(Serialize, Clone, Debug)] pub struct Post { pub content: String, pub slug: String, pub absolute_path: String, pub frontmatter: FrontMatter, } #[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 post = load_post(&path.to_string_lossy()).await?; res.insert(post.slug.clone(), post); } Ok(res) } #[instrument] pub async fn load_post(path: &str) -> color_eyre::eyre::Result { let path = path.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('/'); 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 }); let date = toml_date_to_chrono(tomlfm.date)?; let frontmatter = FrontMatter { title: tomlfm.title, date, }; Ok(Post { absolute_path: format!("/posts/{}/", slug), slug: slug.to_string(), content: content.unwrap_or_default(), frontmatter, }) } fn toml_date_to_chrono( toml: toml::value::Datetime, ) -> color_eyre::eyre::Result> { Ok(DateTime::parse_from_rfc3339(&toml.to_string())?) } #[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(state, post))] async fn render_post(state: &State, post: &Post) -> Result { let mut ctx = tera::Context::new(); ctx.insert("title", &post.frontmatter.title); ctx.insert("content", &post.content); state.tera.render("post.html", &ctx).map_err(|e| e.into()) } #[instrument(skip(posts))] pub fn build_router<'a, I>(posts: I) -> Router where I: Iterator, { let mut router = Router::new().route("/", get(index)); for post in posts { let slug = &post.slug; let path = format!("/{slug}/"); info!("adding post route: {path}"); router = router.route(&path, get(view)); } router } #[instrument(skip(state))] pub async fn view( uri: Uri, Extension(state): Extension>, ) -> Result, handlers::Error> { debug!("viewing post: {uri}"); let post = state .posts .get(uri.path().trim_matches('/')) .ok_or(handlers::Error::NotFound)?; let res = render_post(&state, post).await?; Ok(Html(res)) } #[instrument(skip(state))] pub async fn index( Extension(state): Extension>, ) -> Result, handlers::Error> { let mut ctx = tera::Context::new(); let mut posts = state.posts.values().collect::>(); posts.sort_by_key(|p| &p.frontmatter.date); ctx.insert("title", "Posts"); ctx.insert("posts", &posts); let res = state.tera.render("posts_index.html", &ctx)?; Ok(Html(res)) }