aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/modules.rs4
-rw-r--r--src/modules/blog/config.rs3
-rw-r--r--src/modules/blog/module.rs282
-rw-r--r--src/notion.rs28
4 files changed, 280 insertions, 37 deletions
diff --git a/src/modules.rs b/src/modules.rs
index 24ee231..c493364 100644
--- a/src/modules.rs
+++ b/src/modules.rs
@@ -1,5 +1,5 @@
amenadiel::modules!(
- uptime, directory, search, remarks, blog, random, r#static, router,
- contact, api, stocks, // cryptocurrency,
+ uptime, directory, search, remarks, blog, random, r#static, router, contact,
+ api, stocks, // cryptocurrency,
web, finger, index
);
diff --git a/src/modules/blog/config.rs b/src/modules/blog/config.rs
index 37dbb9d..f1cf99f 100644
--- a/src/modules/blog/config.rs
+++ b/src/modules/blog/config.rs
@@ -1,6 +1,7 @@
#[derive(Clone)]
pub struct BlogCategory {
pub title: String,
+ pub slugs: Vec<String>,
pub description: Option<String>,
pub priority: u8,
pub notion_id: String,
@@ -9,6 +10,8 @@ pub struct BlogCategory {
#[derive(Clone)]
pub struct BlogPost {
pub title: String,
+ pub slugs: Vec<String>,
+ pub notion_id: String,
pub description: Option<String>,
pub author: Option<String>,
pub created_raw: Option<String>,
diff --git a/src/modules/blog/module.rs b/src/modules/blog/module.rs
index e3a78ef..160f28c 100644
--- a/src/modules/blog/module.rs
+++ b/src/modules/blog/module.rs
@@ -36,11 +36,11 @@ pub fn module(router: &mut windmark::router::Router) {
let listing = categories
.iter()
.map(|category| {
- let slug = slugify(&category.title);
+ let permalink = canonical_blog_permalink(category);
format!(
"=> /blog/{} {}{}",
- slug,
+ permalink,
category.title,
category
.description
@@ -70,9 +70,8 @@ pub fn module(router: &mut windmark::router::Router) {
let blog_slug = raw_blog_name;
let categories = BLOG_CATEGORIES.read().unwrap();
let posts = BLOG_POSTS.read().unwrap();
- let matched_category = categories
- .iter()
- .find(|category| slugify(&category.title) == blog_slug);
+ let matched_category =
+ find_category_by_identifier(&categories, &blog_slug);
let Some(category) = matched_category else {
return windmark::response::Response::not_found(
"This blog could not be found.",
@@ -80,8 +79,9 @@ pub fn module(router: &mut windmark::router::Router) {
};
let category_posts = visible_posts_for_blog(&posts, &category.notion_id);
+ let blog_permalink = canonical_blog_permalink(category);
success(
- &render_blog_page(category, blog_slug.as_str(), &category_posts),
+ &render_blog_page(category, &blog_permalink, &category_posts),
&context,
)
},
@@ -97,16 +97,18 @@ pub fn module(router: &mut windmark::router::Router) {
context.parameters.get("post_title").cloned().unwrap_or_default();
let categories = BLOG_CATEGORIES.read().unwrap();
let posts = BLOG_POSTS.read().unwrap();
- let matched_category = categories
- .iter()
- .find(|category| slugify(&category.title) == blog_slug);
+ let matched_category =
+ find_category_by_identifier(&categories, &blog_slug);
let Some(category) = matched_category else {
return windmark::response::Response::not_found(
"This blog could not be found.",
);
};
- let matched_post =
- find_visible_post_by_slug(&posts, &category.notion_id, &post_slug);
+ let matched_post = find_visible_post_by_identifier(
+ &posts,
+ &category.notion_id,
+ &post_slug,
+ );
let Some(post) = matched_post else {
return windmark::response::Response::not_found(
"This post could not be found.",
@@ -197,6 +199,68 @@ fn construct_header(post: &BlogPost) -> String {
fn slugify(text: &str) -> String { text.replace(' ', "_").to_lowercase() }
+fn normalize_permalink_identifier(identifier: &str) -> String {
+ identifier.replace('-', "").to_lowercase()
+}
+
+fn normalize_slug_identifier(identifier: &str) -> String {
+ identifier.trim().to_lowercase()
+}
+
+fn notion_permalink_identifier(notion_id: &str) -> String {
+ normalize_permalink_identifier(notion_id)
+}
+
+fn is_notion_identifier_match(identifier: &str, notion_id: &str) -> bool {
+ normalize_permalink_identifier(identifier)
+ == normalize_permalink_identifier(notion_id)
+}
+
+fn parse_slug_list(raw_slugs: &str) -> Vec<String> {
+ let mut parsed_slugs = Vec::new();
+
+ for raw_slug in raw_slugs.split(',') {
+ let normalized_slug = normalize_slug_identifier(raw_slug);
+
+ if normalized_slug.is_empty() || parsed_slugs.contains(&normalized_slug) {
+ continue;
+ }
+
+ parsed_slugs.push(normalized_slug);
+ }
+
+ parsed_slugs
+}
+
+fn canonical_blog_permalink(category: &BlogCategory) -> String {
+ category
+ .slugs
+ .first()
+ .cloned()
+ .unwrap_or_else(|| slugify(&category.title))
+}
+
+fn canonical_post_permalink(post: &BlogPost) -> String {
+ post
+ .slugs
+ .first()
+ .cloned()
+ .unwrap_or_else(|| slugify(&post.title))
+}
+
+fn find_category_by_identifier<'a>(
+ categories: &'a [BlogCategory],
+ identifier: &str,
+) -> Option<&'a BlogCategory> {
+ let normalized_identifier = normalize_slug_identifier(identifier);
+
+ categories.iter().find(|category| {
+ slugify(&category.title) == normalized_identifier
+ || category.slugs.iter().any(|slug| slug == &normalized_identifier)
+ || is_notion_identifier_match(&normalized_identifier, &category.notion_id)
+ })
+}
+
fn render_blog_page(
category: &BlogCategory,
blog_slug: &str,
@@ -205,12 +269,12 @@ fn render_blog_page(
let post_listing = category_posts
.iter()
.map(|post| {
- let post_slug = slugify(&post.title);
+ let post_permalink = canonical_post_permalink(post);
format!(
"=> /blog/{}/{} {}{}",
blog_slug,
- post_slug,
+ post_permalink,
post.title,
post
.description
@@ -280,30 +344,36 @@ fn visible_posts_for_blog<'a>(
visible_posts
}
-fn find_visible_post_by_slug<'a>(
+fn find_visible_post_by_identifier<'a>(
posts: &'a [BlogPost],
blog_identifier: &str,
- post_slug: &str,
+ post_identifier: &str,
) -> Option<&'a BlogPost> {
+ let normalized_identifier = normalize_slug_identifier(post_identifier);
+
visible_posts_for_blog(posts, blog_identifier)
.into_iter()
- .find(|post| slugify(&post.title) == post_slug)
+ .find(|post| {
+ slugify(&post.title) == normalized_identifier
+ || post.slugs.iter().any(|slug| slug == &normalized_identifier)
+ || is_notion_identifier_match(&normalized_identifier, &post.notion_id)
+ })
}
fn build_rss_feed(blog_slug: &str) -> windmark::response::Response {
let categories = BLOG_CATEGORIES.read().unwrap();
let posts = BLOG_POSTS.read().unwrap();
- let matched_category =
- categories.iter().find(|category| slugify(&category.title) == blog_slug);
+ let matched_category = find_category_by_identifier(&categories, blog_slug);
let Some(category) = matched_category else {
return windmark::response::Response::not_found(
"This blog could not be found.",
);
};
let mut xml = XmlWriter::builder();
+ let blog_permalink = canonical_blog_permalink(category);
xml.add_field("title", &category.title);
- xml.add_field("link", &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}"));
+ xml.add_field("link", &format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}"));
if let Some(ref description_text) = category.description {
xml.add_field("description", description_text);
@@ -311,22 +381,22 @@ fn build_rss_feed(blog_slug: &str) -> windmark::response::Response {
xml.add_field("generator", "locus");
xml.add_field("lastBuildDate", &chrono::Local::now().to_rfc2822());
- xml.add_link(&format!("{ROOT_GEMINI_URL}/blog/{blog_slug}.xml"));
+ xml.add_link(&format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}.xml"));
for post in visible_posts_for_blog(&posts, &category.notion_id) {
- let post_slug = slugify(&post.title);
+ let post_permalink = canonical_post_permalink(post);
xml.add_item(&{
let mut builder = XmlItem::builder();
builder.add_field(
"link",
- &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}/{post_slug}"),
+ &format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}/{post_permalink}"),
);
builder.add_field("description", &post.content);
builder.add_field(
"guid",
- &format!("{ROOT_GEMINI_URL}/blog/{blog_slug}/{post_slug}"),
+ &format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}/{post_permalink}"),
);
builder.add_field("title", &post.title);
@@ -363,7 +433,25 @@ fn build_global_posts(
Post::new(
format!("{}, {}", blog_title, post.title),
- format!("/blog/{}/{}", slugify(&blog_title), slugify(&post.title)),
+ categories
+ .iter()
+ .find(|category| category.notion_id == post.blog_id)
+ .map_or_else(
+ || {
+ format!(
+ "/blog/{}/{}",
+ notion_permalink_identifier(&post.blog_id),
+ canonical_post_permalink(post)
+ )
+ },
+ |category| {
+ format!(
+ "/blog/{}/{}",
+ canonical_blog_permalink(category),
+ canonical_post_permalink(post)
+ )
+ },
+ ),
)
})
.collect()
@@ -385,9 +473,11 @@ fn fetch_from_notion() {
.map(|page| {
let description_text =
notion::extract_rich_text(&page.properties, "Description");
+ let slugs_text = notion::extract_rich_text(&page.properties, "Slugs");
BlogCategory {
title: notion::extract_title(&page.properties, "Title"),
+ slugs: parse_slug_list(&slugs_text),
description: if description_text.is_empty() {
None
} else {
@@ -421,9 +511,12 @@ fn fetch_from_notion() {
let description_text =
notion::extract_rich_text(&page.properties, "Description");
let author_text = notion::extract_rich_text(&page.properties, "Author");
+ let slugs_text = notion::extract_rich_text(&page.properties, "Slugs");
BlogPost {
title: notion::extract_title(&page.properties, "Title"),
+ slugs: parse_slug_list(&slugs_text),
+ notion_id: page.id.clone(),
description: if description_text.is_empty() {
None
} else {
@@ -487,8 +580,8 @@ pub fn refresh_loop() {
mod tests {
use super::{
begin_refresh, build_global_posts, build_rss_feed,
- find_visible_post_by_slug, finish_refresh, render_blog_page,
- should_trigger_manual_refresh, slugify,
+ find_category_by_identifier, find_visible_post_by_identifier,
+ finish_refresh, render_blog_page, should_trigger_manual_refresh, slugify,
visible_posts_for_blog, BLOG_CATEGORIES, BLOG_POSTS,
};
use crate::modules::blog::config::{BlogCategory, BlogPost};
@@ -499,12 +592,15 @@ mod tests {
let original_posts = BLOG_POSTS.read().unwrap().clone();
let category = BlogCategory {
title: "Test Blog".to_string(),
+ slugs: vec![],
description: None,
priority: 1,
notion_id: "blog-id".to_string(),
};
let visible_post = BlogPost {
title: "Visible".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -516,6 +612,8 @@ mod tests {
};
let hidden_post = BlogPost {
title: "Hidden".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -543,6 +641,8 @@ mod tests {
fn visible_posts_for_blog_excludes_hidden_posts() {
let visible_post = BlogPost {
title: "Visible".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -554,6 +654,8 @@ mod tests {
};
let hidden_post = BlogPost {
title: "Hidden".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -574,9 +676,11 @@ mod tests {
}
#[test]
- fn find_visible_post_by_slug_skips_hidden_posts() {
+ fn find_visible_post_by_identifier_skips_hidden_posts() {
let visible_post = BlogPost {
title: "Visible Post".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -588,6 +692,8 @@ mod tests {
};
let hidden_post = BlogPost {
title: "Hidden Post".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -600,10 +706,12 @@ mod tests {
let posts = vec![visible_post, hidden_post];
assert!(
- find_visible_post_by_slug(&posts, "blog-id", "visible_post").is_some()
+ find_visible_post_by_identifier(&posts, "blog-id", "visible_post")
+ .is_some()
);
assert!(
- find_visible_post_by_slug(&posts, "blog-id", "hidden_post").is_none()
+ find_visible_post_by_identifier(&posts, "blog-id", "hidden_post")
+ .is_none()
);
}
@@ -611,6 +719,8 @@ mod tests {
fn visible_posts_for_blog_are_sorted_chronologically_desc() {
let oldest_post = BlogPost {
title: "Oldest".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: Some("2024-01-01".to_string()),
@@ -622,6 +732,8 @@ mod tests {
};
let newest_post = BlogPost {
title: "Newest".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: Some("2026-03-10".to_string()),
@@ -633,6 +745,8 @@ mod tests {
};
let middle_post = BlogPost {
title: "Middle".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: Some("2025-02-02".to_string()),
@@ -662,12 +776,15 @@ mod tests {
fn global_posts_are_sorted_chronologically_desc() {
let categories = vec![BlogCategory {
title: "Test Blog".to_string(),
+ slugs: vec![],
description: None,
priority: 1,
notion_id: "blog-id".to_string(),
}];
let older_post = BlogPost {
title: "Older".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: Some("2025-01-01".to_string()),
@@ -679,6 +796,8 @@ mod tests {
};
let newer_post = BlogPost {
title: "Newer".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: Some("2026-01-01".to_string()),
@@ -690,6 +809,8 @@ mod tests {
};
let hidden_newest_post = BlogPost {
title: "Hidden".to_string(),
+ slugs: vec![],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: Some("2027-01-01".to_string()),
@@ -719,12 +840,15 @@ mod tests {
fn render_blog_page_without_description_has_single_spacing_before_listing() {
let category = BlogCategory {
title: "The Daily".to_string(),
+ slugs: vec![],
description: None,
priority: 1,
notion_id: "blog-id".to_string(),
};
let post = BlogPost {
title: "Entry One".to_string(),
+ slugs: vec!["entry-one".to_string(), "entry_one".to_string()],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -736,7 +860,9 @@ mod tests {
};
let rendered = render_blog_page(&category, "the_daily", &[&post]);
- assert!(rendered.contains("# The Daily (1)\n\n=> /blog/the_daily/entry_one Entry One"));
+ assert!(rendered.contains(
+ "# The Daily (1)\n\n=> /blog/the_daily/entry-one Entry One"
+ ));
assert!(!rendered.contains("# The Daily (1)\n\n\n"));
}
@@ -744,12 +870,15 @@ mod tests {
fn render_blog_page_with_description_keeps_expected_spacing() {
let category = BlogCategory {
title: "The Daily".to_string(),
+ slugs: vec![],
description: Some("desc".to_string()),
priority: 1,
notion_id: "blog-id".to_string(),
};
let post = BlogPost {
title: "Entry One".to_string(),
+ slugs: vec!["entry-one".to_string()],
+ notion_id: "post-id".to_string(),
description: None,
author: None,
created_raw: None,
@@ -761,9 +890,10 @@ mod tests {
};
let rendered = render_blog_page(&category, "the_daily", &[&post]);
- assert!(rendered.contains(
- "# The Daily (1)\n\ndesc\n\n=> /blog/the_daily/entry_one Entry One"
- ));
+ assert!(
+ rendered
+ .contains("# The Daily (1)\n\ndesc\n\n=> /blog/the_daily/entry-one Entry One")
+ );
}
#[test]
@@ -785,4 +915,90 @@ mod tests {
assert!(begin_refresh());
finish_refresh();
}
+
+ #[test]
+ fn find_category_by_identifier_supports_slug_aliases() {
+ let categories = vec![
+ BlogCategory {
+ title: "Alpha".to_string(),
+ slugs: vec!["alpha".to_string(), "a".to_string()],
+ description: None,
+ priority: 0,
+ notion_id: "11111111-1111-1111-1111-111111111111".to_string(),
+ },
+ BlogCategory {
+ title: "Beta".to_string(),
+ slugs: vec![
+ "the-daily".to_string(),
+ "thedaily".to_string(),
+ "daily".to_string(),
+ ],
+ description: None,
+ priority: 0,
+ notion_id: "22222222-2222-2222-2222-222222222222".to_string(),
+ },
+ ];
+
+ assert_eq!(
+ find_category_by_identifier(&categories, "thedaily")
+ .map(|c| c.notion_id.clone()),
+ Some("22222222-2222-2222-2222-222222222222".to_string())
+ );
+ assert_eq!(
+ find_category_by_identifier(&categories, "daily").map(|c| c.notion_id.clone()),
+ Some("22222222-2222-2222-2222-222222222222".to_string())
+ );
+ assert_eq!(
+ find_category_by_identifier(&categories, "alpha")
+ .map(|c| c.notion_id.clone()),
+ Some("11111111-1111-1111-1111-111111111111".to_string())
+ );
+ }
+
+ #[test]
+ fn find_visible_post_by_identifier_supports_slug_aliases() {
+ let posts = vec![
+ BlogPost {
+ title: "First".to_string(),
+ slugs: vec!["first".to_string()],
+ notion_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string(),
+ description: None,
+ author: None,
+ created_raw: None,
+ created: None,
+ last_modified: None,
+ content: "first".to_string(),
+ blog_id: "blog-id".to_string(),
+ hidden: false,
+ },
+ BlogPost {
+ title: "Second".to_string(),
+ slugs: vec![
+ "post-two".to_string(),
+ "post2".to_string(),
+ "second".to_string(),
+ ],
+ notion_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(),
+ description: None,
+ author: None,
+ created_raw: None,
+ created: None,
+ last_modified: None,
+ content: "second".to_string(),
+ blog_id: "blog-id".to_string(),
+ hidden: false,
+ },
+ ];
+
+ assert_eq!(
+ find_visible_post_by_identifier(&posts, "blog-id", "post2")
+ .map(|post| post.title.clone()),
+ Some("Second".to_string())
+ );
+ assert_eq!(
+ find_visible_post_by_identifier(&posts, "blog-id", "first")
+ .map(|post| post.title.clone()),
+ Some("First".to_string())
+ );
+ }
}
diff --git a/src/notion.rs b/src/notion.rs
index 006466f..4dd7c5e 100644
--- a/src/notion.rs
+++ b/src/notion.rs
@@ -60,11 +60,35 @@ pub fn extract_number(
properties: &serde_json::Value,
field: &str,
) -> Option<u8> {
- properties[field]["number"]
- .as_u64()
+ extract_non_negative_whole_number(properties, field)
.and_then(|number_value| u8::try_from(number_value).ok())
}
+fn extract_non_negative_whole_number(
+ properties: &serde_json::Value,
+ field: &str,
+) -> Option<u64> {
+ let number_value = &properties[field]["number"];
+
+ number_value.as_u64().or_else(|| {
+ number_value.as_f64().and_then(|floating_value| {
+ if !floating_value.is_finite() || floating_value < 0.0 {
+ return None;
+ }
+
+ let rounded_value = floating_value.round();
+ let is_whole_number =
+ (floating_value - rounded_value).abs() <= f64::EPSILON;
+
+ if !is_whole_number {
+ return None;
+ }
+
+ format!("{rounded_value:.0}").parse::<u64>().ok()
+ })
+ })
+}
+
pub fn extract_date(properties: &serde_json::Value, field: &str) -> String {
properties[field]["date"]["start"].as_str().unwrap_or("").to_string()
}