A little app to serve my photography from my personal website

blog post tags

ericwood.org 1e85147a 82f3b67f

Waiting for spindle ...
+142 -60
.DS_Store

This is a binary file and will not be displayed.

+87 -35
src/blog.rs
··· 5 5 fs::{self, File, read_to_string}, 6 6 io::{BufRead, BufReader, Write as IoWrite}, 7 7 path::{Path, PathBuf}, 8 + sync::Arc, 8 9 }; 9 10 10 11 use arborium::Highlighter; ··· 17 18 options, parse_document, 18 19 }; 19 20 use regex::Regex; 20 - use serde::Serialize; 21 + use serde::{Deserialize, Deserializer, Serialize}; 21 22 22 23 use crate::{ 23 24 AppState, ··· 26 27 views::{View, blog::BlogShow}, 27 28 }; 28 29 30 + pub struct BlogStore { 31 + by_slug: HashMap<String, Arc<BlogPost>>, 32 + by_tag: HashMap<String, Vec<Arc<BlogPost>>>, 33 + } 34 + 35 + impl BlogStore { 36 + pub fn new(config: &Config) -> anyhow::Result<Self> { 37 + let root_path = Path::new(&config.blog_posts_path); 38 + let mut by_slug = HashMap::new(); 39 + let mut by_tag: HashMap<String, Vec<Arc<BlogPost>>> = HashMap::new(); 40 + 41 + let names: Vec<String> = fs::read_dir(root_path)? 42 + .filter_map(|i| i.ok()) 43 + .filter_map(|entry| { 44 + let file_name = entry.file_name().into_string().unwrap_or("".to_string()); 45 + if !file_name.ends_with("md") { 46 + return None; 47 + } 48 + 49 + Some(file_name) 50 + }) 51 + .collect(); 52 + 53 + for file_name in names { 54 + let mut slug = slug::slugify(&file_name); 55 + slug.replace_last("-md", ""); 56 + let path = root_path.join(file_name).clone(); 57 + 58 + let maybe_post = extract_frontmatter(&path)?; 59 + 60 + if let Some(mut post) = maybe_post { 61 + post.slug = Some(slug.clone()); 62 + post.file_path = path; 63 + post.cache_path = Path::new(&config.cache_path).join("blog").join(&slug); 64 + let post = Arc::new(post); 65 + by_slug.insert(slug, post.clone()); 66 + 67 + for tag in post.tags.iter() { 68 + if let Some(tag_posts) = by_tag.get_mut(tag) { 69 + tag_posts.push(post.clone()); 70 + } else { 71 + by_tag.insert(tag.clone(), vec![post.clone()]); 72 + } 73 + } 74 + } 75 + } 76 + 77 + Ok(Self { by_slug, by_tag }) 78 + } 79 + 80 + pub fn all(&self) -> Vec<Arc<BlogPost>> { 81 + self.by_slug.values().cloned().collect() 82 + } 83 + 84 + pub fn get_by_slug(&self, slug: &str) -> Option<Arc<BlogPost>> { 85 + self.by_slug.get(slug).map(|i| (*i).clone()) 86 + } 87 + 88 + pub fn get_by_tag(&self, tag: &str) -> Vec<Arc<BlogPost>> { 89 + self.by_tag 90 + .get(tag) 91 + .unwrap_or(&Vec::<Arc<BlogPost>>::new()) 92 + .iter() 93 + .map(|i| (*i).clone()) 94 + .collect() 95 + } 96 + } 97 + 29 98 #[derive(serde::Deserialize, serde::Serialize, Clone)] 30 99 pub struct BlogPost { 31 100 pub title: Option<String>, 32 101 pub published_at: Option<DateTime>, 102 + pub slug: Option<String>, 103 + #[serde(deserialize_with = "deserialize_tags")] 104 + pub tags: Vec<String>, 33 105 #[serde(skip_deserializing)] 34 106 pub file_path: PathBuf, 35 107 #[serde(skip_deserializing)] 36 108 pub cache_path: PathBuf, 109 + } 110 + 111 + fn deserialize_tags<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error> 112 + where 113 + D: Deserializer<'de>, 114 + { 115 + let str_sequence = String::deserialize(deserializer).unwrap_or_default(); 116 + Ok(str_sequence 117 + .replace(", ", ",") 118 + .split(',') 119 + .map(|i| i.to_owned()) 120 + .collect()) 37 121 } 38 122 39 123 #[derive(Clone)] ··· 99 183 output.write_str("<code>") 100 184 } 101 185 } 102 - } 103 - 104 - pub fn load_index(config: &Config) -> anyhow::Result<HashMap<String, BlogPost>> { 105 - let root_path = Path::new(&config.blog_posts_path); 106 - let mut map = HashMap::new(); 107 - 108 - let names: Vec<String> = fs::read_dir(root_path)? 109 - .filter_map(|i| i.ok()) 110 - .filter_map(|entry| { 111 - let file_name = entry.file_name().into_string().unwrap_or("".to_string()); 112 - if !file_name.ends_with("md") { 113 - return None; 114 - } 115 - 116 - Some(file_name) 117 - }) 118 - .collect(); 119 - 120 - for file_name in names { 121 - let mut slug = slug::slugify(&file_name); 122 - slug.replace_last("-md", ""); 123 - let path = root_path.join(file_name).clone(); 124 - 125 - let maybe_post = extract_frontmatter(&path)?; 126 - 127 - if let Some(mut post) = maybe_post { 128 - post.file_path = path; 129 - post.cache_path = Path::new(&config.cache_path).join("blog").join(&slug); 130 - map.insert(slug, post); 131 - } 132 - } 133 - Ok(map) 134 186 } 135 187 136 188 fn extract_frontmatter(path: &PathBuf) -> anyhow::Result<Option<BlogPost>> { ··· 316 368 return Ok(()); 317 369 } 318 370 319 - for post in state.blog_slugs.values() { 320 - let view = BlogShow::new(post); 371 + for post in state.blog_store.all() { 372 + let view = BlogShow::new(post.clone()); 321 373 let rendered = view.render(&state.reloader)?; 322 374 323 375 let mut file = File::create(post.cache_path.clone())?;
+5 -5
src/main.rs
··· 7 7 }; 8 8 use dotenvy::dotenv; 9 9 use minijinja_autoreload::AutoReloader; 10 - use std::{collections::HashMap, fs, path::Path, sync::Arc}; 10 + use std::{fs, path::Path, sync::Arc}; 11 11 use tower::ServiceBuilder; 12 12 mod app_error; 13 13 use app_error::AppError; ··· 27 27 use config::Config; 28 28 mod views; 29 29 30 - use crate::blog::BlogPost; 30 + use crate::blog::BlogStore; 31 31 32 32 struct AppState { 33 33 config: Config, 34 34 reloader: AutoReloader, 35 35 photos_db_pool: SqlitePool, 36 - blog_slugs: HashMap<String, BlogPost>, 36 + blog_store: BlogStore, 37 37 } 38 38 39 39 type Response = Result<Html<String>, AppError>; ··· 66 66 67 67 tracing::info!("connected to DB"); 68 68 69 - let blog_slugs = blog::load_index(&config)?; 69 + let blog_store = blog::BlogStore::new(&config)?; 70 70 71 71 let reloader = load_templates_dyn(&config); 72 72 let app = Router::new() ··· 106 106 config, 107 107 reloader, 108 108 photos_db_pool, 109 - blog_slugs, 109 + blog_store, 110 110 }); 111 111 112 112 blog::cache_posts(&app_state)?;
+17 -4
src/routes/blog/index.rs
··· 2 2 AppState, Response, 3 3 views::{View, blog::BlogIndex}, 4 4 }; 5 - use axum::{extract::State, response::Html}; 5 + use axum::{ 6 + extract::{Query, State}, 7 + response::Html, 8 + }; 9 + use serde::Deserialize; 6 10 use std::sync::Arc; 7 11 8 - pub async fn index(State(state): State<Arc<AppState>>) -> Response { 9 - let slugs: Vec<&String> = state.blog_slugs.keys().collect(); 10 - let view = BlogIndex::new(slugs); 12 + #[derive(Deserialize)] 13 + pub struct IndexParams { 14 + pub tag: Option<String>, 15 + } 16 + 17 + pub async fn index(query: Query<IndexParams>, State(state): State<Arc<AppState>>) -> Response { 18 + let posts = if let Some(tag) = query.tag.clone() { 19 + state.blog_store.get_by_tag(&tag) 20 + } else { 21 + state.blog_store.all() 22 + }; 23 + let view = BlogIndex::new(posts); 11 24 let html = view.render(&state.reloader)?; 12 25 13 26 Ok(Html(html))
+4 -1
src/routes/blog/show.rs
··· 7 7 use std::{fs::read_to_string, sync::Arc}; 8 8 9 9 pub async fn show(Path(slug): Path<String>, State(state): State<Arc<AppState>>) -> Response { 10 - let post = state.blog_slugs.get(&slug).ok_or(AppError::NotFound)?; 10 + let post = state 11 + .blog_store 12 + .get_by_slug(&slug) 13 + .ok_or(AppError::NotFound)?; 11 14 12 15 if state.config.is_prod() { 13 16 let html = read_to_string(&post.cache_path)?;
+10 -8
src/views/blog/index/mod.rs
··· 1 + use std::sync::Arc; 2 + 1 3 use minijinja::context; 2 4 use minijinja_autoreload::AutoReloader; 3 5 4 - use crate::{templates::render, views::View}; 6 + use crate::{blog::BlogPost, templates::render, views::View}; 5 7 6 - pub struct BlogIndex<'a> { 7 - slugs: Vec<&'a String>, 8 + pub struct BlogIndex { 9 + posts: Vec<Arc<BlogPost>>, 8 10 } 9 11 10 - impl<'a> BlogIndex<'a> { 11 - pub fn new(slugs: Vec<&'a String>) -> Self { 12 - Self { slugs } 12 + impl BlogIndex { 13 + pub fn new(posts: Vec<Arc<BlogPost>>) -> Self { 14 + Self { posts } 13 15 } 14 16 } 15 17 16 - impl<'a> View for BlogIndex<'a> { 18 + impl View for BlogIndex { 17 19 fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 18 20 let html = render( 19 21 reloader, 20 22 "views/blog/index", 21 23 context! { 22 - slugs => self.slugs 24 + posts => self.posts 23 25 }, 24 26 )?; 25 27
+7 -2
src/views/blog/index/template.jinja
··· 4 4 5 5 {% block body %} 6 6 <ul> 7 - {% for slug in slugs %} 8 - <li><a href="/blog/{{ slug }}">{{ slug }}</a></li> 7 + {% for post in posts %} 8 + <li> 9 + <a href="/blog/{{ post.slug }}">{{ post.title }}</a> 10 + {% for tag in post.tags %} 11 + <a href="/blog?tag={{ tag }}">{{ tag }}</a> 12 + {% endfor %} 13 + </li> 9 14 {% endfor %} 10 15 </ul> 11 16 {% endblock %}
+7 -5
src/views/blog/show/mod.rs
··· 1 + use std::sync::Arc; 2 + 1 3 use minijinja::context; 2 4 use minijinja_autoreload::AutoReloader; 3 5 ··· 7 9 views::View, 8 10 }; 9 11 10 - pub struct BlogShow<'a> { 11 - post: &'a BlogPost, 12 + pub struct BlogShow { 13 + post: Arc<BlogPost>, 12 14 } 13 15 14 - impl<'a> BlogShow<'a> { 15 - pub fn new(post: &'a BlogPost) -> Self { 16 + impl BlogShow { 17 + pub fn new(post: Arc<BlogPost>) -> Self { 16 18 Self { post } 17 19 } 18 20 } 19 21 20 - impl<'a> View for BlogShow<'a> { 22 + impl View for BlogShow { 21 23 fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 22 24 let (body, toc) = render_post(&self.post.file_path)?; 23 25 let has_code = body.contains("<pre class=\"highlighted\">");
+5
src/views/blog/show/template.jinja
··· 14 14 <p> 15 15 <time datetime="{{ post.published_at }}">{{ post.published_at }}</time> 16 16 </p> 17 + <ul> 18 + {% for tag in post.tags %} 19 + <li><a href="/blog?tag={{ tag }}">{{ tag }}</a></li> 20 + {% endfor %} 21 + </ul> 17 22 </div> 18 23 <div class="blog__container"> 19 24 <div class="blog__body">