diff options
| author | Fuwn <[email protected]> | 2026-02-18 05:35:53 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-18 05:35:53 -0800 |
| commit | f6b7e526352eaa2d7a83423097a4a0927b7b4824 (patch) | |
| tree | 7ef2a1025ae6b178b80dc5c99c8e5ef7a24d0b2a /src | |
| parent | refactor(modules): Remove skills route (diff) | |
| download | locus-f6b7e526352eaa2d7a83423097a4a0927b7b4824.tar.xz locus-f6b7e526352eaa2d7a83423097a4a0927b7b4824.zip | |
feat(blog): Add canonical and alias slug routing from Notion Slugs with title fallback
Diffstat (limited to 'src')
| -rw-r--r-- | src/modules.rs | 4 | ||||
| -rw-r--r-- | src/modules/blog/config.rs | 3 | ||||
| -rw-r--r-- | src/modules/blog/module.rs | 282 | ||||
| -rw-r--r-- | src/notion.rs | 28 |
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() } |