From b978edf4f284025f14e29f8f4d43ca11ab35fec5 Mon Sep 17 00:00:00 2001 From: Timothy DeHerrera Date: Sat, 31 Jan 2026 13:52:21 -0700 Subject: [PATCH] feat(content): add filesystem-driven nav discovery Add NavItem struct and discover_nav() function to scan content directory and automatically build navigation from: - Top-level .md files (pages) - Directories with _index.md (sections) Navigation is sorted by frontmatter weight, then alphabetically. Custom nav_label field allows overriding title in nav menu. Includes 5 unit tests covering page/section discovery, weight ordering, and nav_label support. --- Cargo.lock | 43 ++++++++++++ Cargo.toml | 3 + src/content.rs | 178 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 223 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b82d661..08a8c59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -630,6 +640,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.14" @@ -707,6 +723,7 @@ dependencies = [ "mermaid-rs-renderer", "pulldown-cmark", "serde", + "tempfile", "thiserror 2.0.18", "toml 0.8.23", "tree-sitter", @@ -1181,6 +1198,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1375,6 +1405,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 5af601e..8aed6b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,6 @@ mermaid-rs-renderer = { version = "0.1", default-features = false } # Patch dagre_rust to fix unwrap on None bug [patch.crates-io] dagre_rust = { path = "patches/dagre_rust" } + +[dev-dependencies] +tempfile = "3.24.0" diff --git a/src/content.rs b/src/content.rs index 04eb827..203cf7e 100644 --- a/src/content.rs +++ b/src/content.rs @@ -18,6 +18,17 @@ pub enum ContentKind { Project, } +/// A navigation menu item discovered from the filesystem. +#[derive(Debug, Clone)] +pub struct NavItem { + /// Display label (from nav_label or title) + pub label: String, + /// URL path (e.g., "/blog/index.html" or "/about.html") + pub path: String, + /// Sort order (lower = first, default 50) + pub weight: i64, +} + /// Parsed frontmatter from a content file. #[derive(Debug)] pub struct Frontmatter { @@ -25,10 +36,12 @@ pub struct Frontmatter { pub description: Option, pub date: Option, pub tags: Vec, - /// For project cards: sort order + /// Sort order for nav and listings pub weight: Option, /// For project cards: external link pub link_to: Option, + /// Custom navigation label (defaults to title) + pub nav_label: Option, } /// A content item ready for rendering. @@ -120,6 +133,7 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result< let date = pod.get("date").and_then(|v| v.as_string().ok()); let weight = pod.get("weight").and_then(|v| v.as_i64().ok()); let link_to = pod.get("link_to").and_then(|v| v.as_string().ok()); + let nav_label = pod.get("nav_label").and_then(|v| v.as_string().ok()); // Handle nested taxonomies.tags structure let tags = if let Some(taxonomies) = pod.get("taxonomies") { @@ -147,5 +161,167 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result< tags, weight, link_to, + nav_label, }) } + +/// Discover navigation items from the content directory structure. +/// +/// Rules: +/// - Top-level `.md` files (except `_index.md`) become nav items (pages) +/// - Directories containing `_index.md` become nav items (sections) +/// - Items are sorted by weight (lower first), then alphabetically by label +pub fn discover_nav(content_dir: &Path) -> Result> { + let mut nav_items = Vec::new(); + + // Read top-level entries in content directory + let entries = fs::read_dir(content_dir).map_err(|e| Error::ReadFile { + path: content_dir.to_path_buf(), + source: e, + })?; + + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + + if path.is_file() { + // Top-level .md file (except _index.md) → page nav item + if path.extension().is_some_and(|ext| ext == "md") { + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if file_name != "_index.md" { + let content = Content::from_path(&path, ContentKind::Page)?; + let slug = path.file_stem().and_then(|s| s.to_str()).unwrap_or("page"); + nav_items.push(NavItem { + label: content + .frontmatter + .nav_label + .unwrap_or(content.frontmatter.title), + path: format!("/{}.html", slug), + weight: content.frontmatter.weight.unwrap_or(50), + }); + } + } + } else if path.is_dir() { + // Directory with _index.md → section nav item + let index_path = path.join("_index.md"); + if index_path.exists() { + let content = Content::from_path(&index_path, ContentKind::Section)?; + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("section"); + nav_items.push(NavItem { + label: content + .frontmatter + .nav_label + .unwrap_or(content.frontmatter.title), + path: format!("/{}/index.html", dir_name), + weight: content.frontmatter.weight.unwrap_or(50), + }); + } + } + } + + // Sort by weight, then alphabetically by label + nav_items.sort_by(|a, b| a.weight.cmp(&b.weight).then_with(|| a.label.cmp(&b.label))); + + Ok(nav_items) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn create_test_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("failed to create temp dir") + } + + fn write_frontmatter(path: &Path, title: &str, weight: Option, nav_label: Option<&str>) { + let mut content = format!("---\ntitle: \"{}\"\n", title); + if let Some(w) = weight { + content.push_str(&format!("weight: {}\n", w)); + } + if let Some(label) = nav_label { + content.push_str(&format!("nav_label: \"{}\"\n", label)); + } + content.push_str("---\n\nBody content."); + fs::write(path, content).expect("failed to write test file"); + } + + #[test] + fn test_discover_nav_finds_pages() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + // Create top-level page + write_frontmatter(&content_dir.join("about.md"), "About Me", None, None); + + let nav = discover_nav(content_dir).expect("discover_nav failed"); + assert_eq!(nav.len(), 1); + assert_eq!(nav[0].label, "About Me"); + assert_eq!(nav[0].path, "/about.html"); + } + + #[test] + fn test_discover_nav_finds_sections() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + // Create section directory with _index.md + let blog_dir = content_dir.join("blog"); + fs::create_dir(&blog_dir).expect("failed to create blog dir"); + write_frontmatter(&blog_dir.join("_index.md"), "Blog", None, None); + + let nav = discover_nav(content_dir).expect("discover_nav failed"); + assert_eq!(nav.len(), 1); + assert_eq!(nav[0].label, "Blog"); + assert_eq!(nav[0].path, "/blog/index.html"); + } + + #[test] + fn test_discover_nav_excludes_root_index() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + // Create _index.md at root (should be excluded from nav) + write_frontmatter(&content_dir.join("_index.md"), "Home", None, None); + write_frontmatter(&content_dir.join("about.md"), "About", None, None); + + let nav = discover_nav(content_dir).expect("discover_nav failed"); + assert_eq!(nav.len(), 1); + assert_eq!(nav[0].label, "About"); + } + + #[test] + fn test_discover_nav_sorts_by_weight() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter(&content_dir.join("about.md"), "About", Some(30), None); + write_frontmatter(&content_dir.join("contact.md"), "Contact", Some(10), None); + write_frontmatter(&content_dir.join("blog.md"), "Blog", Some(20), None); + + let nav = discover_nav(content_dir).expect("discover_nav failed"); + assert_eq!(nav.len(), 3); + assert_eq!(nav[0].label, "Contact"); // weight 10 + assert_eq!(nav[1].label, "Blog"); // weight 20 + assert_eq!(nav[2].label, "About"); // weight 30 + } + + #[test] + fn test_discover_nav_uses_nav_label() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter( + &content_dir.join("about.md"), + "About The Author", + None, + Some("About"), + ); + + let nav = discover_nav(content_dir).expect("discover_nav failed"); + assert_eq!(nav.len(), 1); + assert_eq!(nav[0].label, "About"); // Uses nav_label, not title + } +}