diff --git a/src/cmd/build.rs b/src/cmd/build.rs index a9df6be..dba3e2e 100644 --- a/src/cmd/build.rs +++ b/src/cmd/build.rs @@ -668,11 +668,19 @@ pub async fn build(minify: bool) -> Result<()> { ) .await?; + // Generate category pages + let posts = shared::collect_all_posts_metadata(&paths.build, &site_config.root_url).await?; + shared::generate_category_pages( + &tera, + &paths.public, + &posts, + &site_config + ).await?; + // Generate RSS feed after building content if enabled if site_config.rss.clone().is_some_and(|rss| rss.enable) { debug!("Generating RSS feed"); let rss_path = paths.public.join("rss.xml"); - let posts = shared::collect_all_posts_metadata(&paths.build, &site_config.root_url).await?; generate_rss_feed(&tera, &site_config, &posts, &rss_path).await?; } diff --git a/src/cmd/init.rs b/src/cmd/init.rs index f11534a..4f134d2 100644 --- a/src/cmd/init.rs +++ b/src/cmd/init.rs @@ -88,6 +88,14 @@ async fn create_html_templates(root: &str) -> Result<()> { "default", include_str!("../resources/templates/default.html"), ), + ( + "category", + include_str!("../resources/templates/category.html"), + ), + ( + "categories", + include_str!("../resources/templates/categories.html"), + ), ]); let templates_dir = PathBuf::from(root).join("templates"); diff --git a/src/cmd/serve.rs b/src/cmd/serve.rs index 63bc7ef..14fd6dc 100644 --- a/src/cmd/serve.rs +++ b/src/cmd/serve.rs @@ -399,6 +399,19 @@ async fn execute_actions(actions: FileActions, state: Arc) { if !actions.rebuild_paths.is_empty() || !actions.cleanup_paths.is_empty() { match shared::collect_all_posts_metadata(&state.paths.build, &state.routes_url).await { Ok(new_posts) => { + // Update categories + let tera = state.tera.read().await; + if let Err(e) = shared::generate_category_pages( + &tera, + &state.paths.build, + &new_posts, + &state.config, + ) + .await + { + error!("Failed to regenerate categories: {}", e); + } + let mut posts_lock = state.posts.write().await; *posts_lock = new_posts; } @@ -820,6 +833,55 @@ async fn handle_websocket(stream: TcpStream, reload_tx: Arc) -> Result> { + let categories = shared::collect_all_posts_categories(&state.posts.read().await).await; + let posts = state.posts.read().await.clone(); + let mut context = Context::new(); + context.insert("config", &state.config); + context.insert("posts", &posts); + context.insert("categories", &categories.into_iter().collect::>()); + + let tera = state.tera.read().await; + let body = tera + .render("categories.html", &context) + .map_err(|e| eyre!("Template error: {}", e))?; + + Ok(Response::builder() + .header(CONTENT_TYPE, "text/html; charset=utf-8") + .status(StatusCode::OK) + .body(Body::from(body))?) +} + +async fn handle_category(path: &str, state: &Arc) -> Result> { + let category = path.trim_start_matches("/categories/"); + let posts = state.posts.read().await.clone(); + + let category_posts: Vec<_> = posts + .into_iter() + .filter(|post| { + post.get("categories") + .and_then(|c| c.as_array()) + .map(|cats| cats.iter().any(|c| c.as_str() == Some(category))) + .unwrap_or(false) + }) + .collect(); + + let mut context = Context::new(); + context.insert("config", &state.config); + context.insert("category", &category); + context.insert("posts", &category_posts); + + let tera = state.tera.read().await; + let body = tera + .render("category.html", &context) + .map_err(|e| eyre!("Template error: {}", e))?; + + Ok(Response::builder() + .header(CONTENT_TYPE, "text/html; charset=utf-8") + .status(StatusCode::OK) + .body(Body::from(body))?) +} + /// Handles HTTP requests and routes them to the appropriate handler. /// /// This function processes incoming HTTP requests and routes them to the appropriate @@ -840,6 +902,8 @@ async fn handle_request(req: Request, state: Arc) -> Result Ok(Response::builder() .header(CONTENT_TYPE, "text/javascript") .body(LIVE_RELOAD_SCRIPT.into())?), + "/categories" => handle_category_index(&state).await, + path if path.starts_with("/categories/") => handle_category(path, &state).await, path if path.starts_with("/assets/") => handle_asset(path, &state.paths).await, _ => handle_content(request_path, state).await, } diff --git a/src/resources/templates/categories.html b/src/resources/templates/categories.html new file mode 100644 index 0000000..78badea --- /dev/null +++ b/src/resources/templates/categories.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Categories{% endblock title %} +{% block content %} +
+

Categories

+

All the categories used in posts.

+
+
    + {% for category in categories | sort %} + {%- set_global cat_posts = 0 -%} + {% for post in posts %} + {% if category in post.categories %} + {%- set_global cat_posts = cat_posts + 1 -%} + {% endif %} + {% endfor %} +
  • + {{ category }} + ({{ cat_posts }} post{{ cat_posts | pluralize }}) +
  • + {% endfor %} +
+
+{% endblock content %} diff --git a/src/resources/templates/category.html b/src/resources/templates/category.html new file mode 100644 index 0000000..c866c32 --- /dev/null +++ b/src/resources/templates/category.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Category: {{ category }}{% endblock title %} +{% block content %} +
+

Posts in {{ category }}

+

All the posts with the category "{{ category }}"

+
+
    + {% for post in posts %} + {% if category in post.categories %} +
  • + {{ post.title }} + +
  • + {% endif %} + {% endfor %} +
+
+{% endblock content %} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 27a3edf..7209b3c 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,11 +1,13 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Instant; use eyre::{bail, eyre, Result}; -use tera::Tera; +use tera::{Context, Tera}; use tracing::{error, info}; use walkdir::WalkDir; +use crate::config::SiteConfig; use crate::converter; use crate::schema::{format_errors, validate_metadata, ContentSchema}; @@ -99,10 +101,8 @@ pub async fn convert_document( let (norg_html, toc) = converter::html::convert(norg_document.clone(), root_url); // Convert metadata - let norg_meta = converter::meta::convert( - &norg_document, - Some(converter::html::toc_to_toml(&toc)) - )?; + let norg_meta = + converter::meta::convert(&norg_document, Some(converter::html::toc_to_toml(&toc)))?; let meta_toml = toml::to_string_pretty(&norg_meta)?; // Check if the current document is a draft post and also whether we should finish the conversion @@ -166,7 +166,8 @@ pub async fn cleanup_orphaned_build_files(content_dir: &Path) -> Result<()> { let relative_path = path.strip_prefix(build_dir)?; let norg_path = content_dir.join(relative_path).with_extension("norg"); - if !norg_path.exists() { + // Delete orphaned content files + if !norg_path.exists() && !relative_path.starts_with("categories/") { // Delete HTML and meta files let meta_path = path.with_extension("meta.toml"); @@ -177,6 +178,32 @@ pub async fn cleanup_orphaned_build_files(content_dir: &Path) -> Result<()> { info!("Cleaned orphaned build file: {}", path.display()); } + + // Clean empty category directories + let categories_dir = build_dir.join("categories"); + if categories_dir.exists() { + let mut dirs_to_check = vec![categories_dir]; + + while let Some(dir) = dirs_to_check.pop() { + let mut entries = tokio::fs::read_dir(&dir).await?; + let mut has_content = false; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + dirs_to_check.push(path); + has_content = true; + } else { + has_content = true; + } + } + + if !has_content { + tokio::fs::remove_dir(&dir).await?; + info!("Cleaned empty category directory: {}", dir.display()); + } + } + } } } } @@ -356,6 +383,23 @@ pub async fn validate_content_metadata( Ok(String::new()) } +/// Collects all unique categories from post metadata +pub async fn collect_all_posts_categories(posts: &[toml::Value]) -> HashSet { + let mut categories = HashSet::new(); + + for post in posts { + if let Some(cats) = post.get("categories").and_then(|v| v.as_array()) { + for cat in cats { + if let Some(cat_str) = cat.as_str() { + categories.insert(cat_str.to_lowercase()); + } + } + } + } + + categories +} + pub async fn collect_all_posts_metadata( build_dir: &Path, routes_url: &str, @@ -406,3 +450,56 @@ pub async fn collect_all_posts_metadata( Ok(posts) } + +/// Generates category listing pages +pub async fn generate_category_pages( + tera: &Tera, + public_dir: &Path, + posts: &[toml::Value], + config: &SiteConfig, +) -> Result<()> { + let categories = collect_all_posts_categories(posts).await; + let categories_dir = public_dir.join("categories"); + + // Generate main categories index + let mut context = Context::new(); + context.insert("config", config); + context.insert("posts", &posts); + context.insert("categories", &categories.iter().collect::>()); + + let content = tera + .render("categories.html", &context) + .map_err(|e| eyre!("Failed to render categories index: {}", e))?; + + tokio::fs::create_dir_all(&categories_dir).await?; + tokio::fs::write(categories_dir.join("index.html"), content).await?; + + // Generate individual category pages + for category in categories { + let cat_posts: Vec<_> = posts + .iter() + .filter(|post| { + post.get("categories") + .and_then(|c| c.as_array()) + .map(|cats| cats.iter().any(|c| c.as_str() == Some(category.as_str()))) + .unwrap_or(false) + }) + .collect(); + + let mut context = Context::new(); + context.insert("config", config); + context.insert("category", &category); + context.insert("posts", &cat_posts); + + let cat_dir = categories_dir.join(&category); + tokio::fs::create_dir_all(&cat_dir).await?; + + let content = tera + .render("category.html", &context) + .map_err(|e| eyre!("Failed to render category page: {}", e))?; + + tokio::fs::write(cat_dir.join("index.html"), content).await?; + } + + Ok(()) +}