use std::path::Path; use cached::proc_macro::cached; use color_eyre::eyre::Result; 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::log::*; use crate::State; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct FrontMatter { pub title: Option, } #[derive(Serialize, Clone, Debug)] pub struct Post { pub content: String, pub slug: String, pub absolute_path: String, pub frontmatter: Option, } pub fn load_all() -> Result> { Ok(glob("posts/**/*.md")? .map(|p| { let path = p.unwrap(); info!("found page: {}", path.display()); let filename = path.file_name().unwrap(); let slug = if filename.eq_ignore_ascii_case("index.md") { path.parent().unwrap().to_string_lossy() } else { path.to_string_lossy() } .trim_start_matches("posts") .trim_start_matches(std::path::MAIN_SEPARATOR) .trim_end_matches(".md") .trim_end_matches(std::path::MAIN_SEPARATOR) .replace('\\', "/"); info!("slug: {slug}"); let raw = std::fs::read_to_string(&path).unwrap(); let (frontmatter, content) = parse_frontmatter(raw); 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 }); Post { absolute_path: format!("/posts/{slug}/"), slug, content: content.unwrap_or_default(), frontmatter, } }) .collect()) } #[cached(time = 60, key = "String", convert = r#"{ String::from(path) }"#)] pub async fn load_post(path: &str) -> Option { let path = path .trim_end_matches('/') .trim_end_matches(".md") .trim_end_matches(".html"); info!("loading post: {path}"); let path = if path.starts_with("posts/") { Path::new(path).to_owned() } else { Path::new("posts").join(path) }; let content = if let Ok(content) = fs::read_to_string(path.with_extension("md")).await { content } else if let Ok(content) = fs::read_to_string(path.join("index.md")).await { content } else { return None; }; let (frontmatter, content) = parse_frontmatter(content); 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 }); Some(Post { absolute_path: format!("/{}/", path.to_string_lossy()), slug: path.to_string_lossy().into(), content: content.unwrap_or_default(), frontmatter, }) } fn parse_frontmatter(src: String) -> (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(); }; if let Some(fm) = FRONTMATTER_REGEX.captures(&src) { ( fm.get(1) .and_then(|m| toml::from_str(m.as_str()).expect("invalid toml")), fm.get(2).map(|m| m.as_str().to_owned()), ) } else { (None, Some(src)) } } #[derive(Serialize)] struct Postcontext { content: String, } #[cached(time = 60, key = "String", convert = r"{ post.absolute_path.clone() }")] pub async fn render_post(state: &State, post: &Post) -> Option { info!("rendering post: {}", post.absolute_path); let ctx = tera::Context::from_serialize(Postcontext { content: post.content.clone(), }) .ok()?; let res = match state.tera.render("post.html", &ctx) { Ok(res) => res, Err(e) => { error!("Failed rendering post: {}", e); return None; } }; Some(res) }