Skip to content

Commit

Permalink
feat: implement taxonomies using categories
Browse files Browse the repository at this point in the history
  • Loading branch information
NTBBloodbath committed Mar 7, 2025
1 parent 8b55853 commit f5e42b1
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 7 deletions.
10 changes: 9 additions & 1 deletion src/cmd/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
}

Expand Down
8 changes: 8 additions & 0 deletions src/cmd/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
64 changes: 64 additions & 0 deletions src/cmd/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,19 @@ async fn execute_actions(actions: FileActions, state: Arc<ServerState>) {
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;
}
Expand Down Expand Up @@ -820,6 +833,55 @@ async fn handle_websocket(stream: TcpStream, reload_tx: Arc<broadcast::Sender<()
}
}

async fn handle_category_index(state: &Arc<ServerState>) -> Result<Response<Body>> {
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::<Vec<_>>());

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<ServerState>) -> Result<Response<Body>> {
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
Expand All @@ -840,6 +902,8 @@ async fn handle_request(req: Request<Body>, state: Arc<ServerState>) -> Result<R
"/livereload.js" => 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,
}
Expand Down
23 changes: 23 additions & 0 deletions src/resources/templates/categories.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Categories{% endblock title %}
{% block content %}
<div>
<h1>Categories</h1>
<p><i>All the categories used in posts.</i></p>
<hr />
<ul>
{% 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 %}
<li>
<a href="{{ config.rootUrl }}/categories/{{ category }}">{{ category }}</a>
<span>({{ cat_posts }} post{{ cat_posts | pluralize }})</span>
</li>
{% endfor %}
</ul>
</div>
{% endblock content %}
19 changes: 19 additions & 0 deletions src/resources/templates/category.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Category: {{ category }}{% endblock title %}
{% block content %}
<div>
<h1>Posts in {{ category }}</h1>
<p><i>All the posts with the category "{{ category }}"</i></p>
<hr />
<ul>
{% for post in posts %}
{% if category in post.categories %}
<li>
<a href="{{ post.permalink }}">{{ post.title }}</a>
<time>{{ post.created | date(format="%B %e, %Y") }}</time>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endblock content %}
109 changes: 103 additions & 6 deletions src/shared/mod.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");

Expand All @@ -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());
}
}
}
}
}
}
Expand Down Expand Up @@ -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<String> {
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,
Expand Down Expand Up @@ -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::<Vec<_>>());

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(())
}

0 comments on commit f5e42b1

Please sign in to comment.