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.
This commit is contained in:
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
178
src/content.rs
178
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<String>,
|
||||
pub date: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
/// For project cards: sort order
|
||||
/// Sort order for nav and listings
|
||||
pub weight: Option<i64>,
|
||||
/// For project cards: external link
|
||||
pub link_to: Option<String>,
|
||||
/// Custom navigation label (defaults to title)
|
||||
pub nav_label: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec<NavItem>> {
|
||||
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<i64>, 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user