//! Content discovery and frontmatter parsing. use crate::error::{Error, Result}; use gray_matter::{engine::YAML, Matter}; use serde::Serialize; use std::fs; use std::path::{Path, PathBuf}; /// The type of content being processed. #[derive(Debug, Clone, PartialEq)] pub enum ContentKind { /// Blog post with full metadata (date, tags, etc.) Post, /// Standalone page (about, collab) Page, /// Section index (_index.md) Section, /// Project card with external link Project, } /// A navigation menu item discovered from the filesystem. #[derive(Debug, Clone, Serialize)] 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 { pub title: String, pub description: Option, pub date: Option, pub tags: Vec, /// 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, /// Section type for template dispatch (e.g., "blog", "projects") pub section_type: Option, /// Override template for this content item pub template: Option, } /// A content item ready for rendering. #[derive(Debug)] pub struct Content { pub kind: ContentKind, pub frontmatter: Frontmatter, pub body: String, pub source_path: PathBuf, pub slug: String, } impl Content { /// Load and parse a markdown file with YAML frontmatter. pub fn from_path(path: impl AsRef, kind: ContentKind) -> Result { Self::from_path_inner(path.as_ref(), kind) } fn from_path_inner(path: &Path, kind: ContentKind) -> Result { let raw = fs::read_to_string(path).map_err(|e| Error::ReadFile { path: path.to_path_buf(), source: e, })?; let matter = Matter::::new(); let parsed = matter.parse(&raw); let frontmatter = parse_frontmatter(path, &parsed)?; // Derive slug from filename (without extension) let slug = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("untitled") .to_string(); Ok(Content { kind, frontmatter, body: parsed.content, source_path: path.to_path_buf(), slug, }) } /// Compute the output path relative to the output directory. /// e.g., content/blog/foo.md → blog/foo.html pub fn output_path(&self, content_root: &Path) -> PathBuf { let relative = self .source_path .strip_prefix(content_root) .unwrap_or(&self.source_path); match self.kind { ContentKind::Section => { // _index.md → parent/index.html (listing pages stay as index.html) let parent = relative.parent().unwrap_or(Path::new("")); parent.join("index.html") } _ => { // Regular content → parent/slug.html (flat structure) let parent = relative.parent().unwrap_or(Path::new("")); parent.join(format!("{}.html", self.slug)) } } } } fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result { let data = parsed.data.as_ref().ok_or_else(|| Error::Frontmatter { path: path.to_path_buf(), message: "missing frontmatter".to_string(), })?; let pod = data.as_hashmap().map_err(|_| Error::Frontmatter { path: path.to_path_buf(), message: "frontmatter is not a valid map".to_string(), })?; let title = pod .get("title") .and_then(|v| v.as_string().ok()) .ok_or_else(|| Error::Frontmatter { path: path.to_path_buf(), message: "missing required 'title' field".to_string(), })?; let description = pod.get("description").and_then(|v| v.as_string().ok()); 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()); let section_type = pod.get("section_type").and_then(|v| v.as_string().ok()); let template = pod.get("template").and_then(|v| v.as_string().ok()); // Handle nested taxonomies.tags structure let tags = if let Some(taxonomies) = pod.get("taxonomies") { if let Ok(tax_map) = taxonomies.as_hashmap() { if let Some(tags_pod) = tax_map.get("tags") { if let Ok(tags_vec) = tags_pod.as_vec() { tags_vec.iter().filter_map(|v| v.as_string().ok()).collect() } else { Vec::new() } } else { Vec::new() } } else { Vec::new() } } else { Vec::new() }; Ok(Frontmatter { title, description, date, tags, weight, link_to, nav_label, section_type, template, }) } /// 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) } /// A discovered section from the content directory. #[derive(Debug)] pub struct Section { /// The section's index content (_index.md) pub index: Content, /// Directory name (e.g., "blog", "projects") pub name: String, /// Section type for template dispatch (from frontmatter or directory name) pub section_type: String, /// Path to section directory pub path: PathBuf, } impl Section { /// Collect all content items in this section (excluding _index.md). pub fn collect_items(&self) -> Result> { let mut items = Vec::new(); for entry in fs::read_dir(&self.path) .map_err(|e| Error::ReadFile { path: self.path.clone(), source: e, })? .filter_map(|e| e.ok()) { let path = entry.path(); if path.is_file() && path.extension().is_some_and(|ext| ext == "md") && path.file_name().is_some_and(|n| n != "_index.md") { // Determine content kind based on section type let kind = match self.section_type.as_str() { "blog" => ContentKind::Post, "projects" => ContentKind::Project, _ => ContentKind::Page, }; items.push(Content::from_path(&path, kind)?); } } Ok(items) } } /// Discover all sections (directories with _index.md) in the content directory. pub fn discover_sections(content_dir: &Path) -> Result> { let mut sections = Vec::new(); 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_dir() { let index_path = path.join("_index.md"); if index_path.exists() { let index = Content::from_path(&index_path, ContentKind::Section)?; let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("section") .to_string(); // Section type from frontmatter, or fall back to directory name let section_type = index .frontmatter .section_type .clone() .unwrap_or_else(|| name.clone()); sections.push(Section { index, name, section_type, path, }); } } } // Sort by weight sections.sort_by(|a, b| { let wa = a.index.frontmatter.weight.unwrap_or(50); let wb = b.index.frontmatter.weight.unwrap_or(50); wa.cmp(&wb) }); Ok(sections) } #[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 } // ========================================================================= // discover_sections tests // ========================================================================= fn write_section_index( path: &Path, title: &str, section_type: Option<&str>, weight: Option, ) { let mut content = format!("---\ntitle: \"{}\"\n", title); if let Some(st) = section_type { content.push_str(&format!("section_type: \"{}\"\n", st)); } if let Some(w) = weight { content.push_str(&format!("weight: {}\n", w)); } content.push_str("---\nSection content.\n"); fs::write(path, content).expect("failed to write section index"); } #[test] fn test_discover_sections_finds_directories() { let dir = create_test_dir(); let content_dir = dir.path(); // Create two sections fs::create_dir(content_dir.join("blog")).unwrap(); write_section_index(&content_dir.join("blog/_index.md"), "Blog", None, None); fs::create_dir(content_dir.join("projects")).unwrap(); write_section_index( &content_dir.join("projects/_index.md"), "Projects", None, None, ); let sections = discover_sections(content_dir).expect("discover_sections failed"); assert_eq!(sections.len(), 2); let names: Vec<_> = sections.iter().map(|s| s.name.as_str()).collect(); assert!(names.contains(&"blog")); assert!(names.contains(&"projects")); } #[test] fn test_discover_sections_uses_section_type_from_frontmatter() { let dir = create_test_dir(); let content_dir = dir.path(); fs::create_dir(content_dir.join("writings")).unwrap(); write_section_index( &content_dir.join("writings/_index.md"), "My Writings", Some("blog"), None, ); let sections = discover_sections(content_dir).expect("discover_sections failed"); assert_eq!(sections.len(), 1); assert_eq!(sections[0].name, "writings"); assert_eq!(sections[0].section_type, "blog"); // From frontmatter, not dir name } #[test] fn test_discover_sections_falls_back_to_dir_name() { let dir = create_test_dir(); let content_dir = dir.path(); fs::create_dir(content_dir.join("gallery")).unwrap(); write_section_index( &content_dir.join("gallery/_index.md"), "Gallery", None, None, ); let sections = discover_sections(content_dir).expect("discover_sections failed"); assert_eq!(sections.len(), 1); assert_eq!(sections[0].section_type, "gallery"); // Falls back to dir name } #[test] fn test_discover_sections_sorts_by_weight() { let dir = create_test_dir(); let content_dir = dir.path(); fs::create_dir(content_dir.join("blog")).unwrap(); write_section_index(&content_dir.join("blog/_index.md"), "Blog", None, Some(20)); fs::create_dir(content_dir.join("projects")).unwrap(); write_section_index( &content_dir.join("projects/_index.md"), "Projects", None, Some(10), ); let sections = discover_sections(content_dir).expect("discover_sections failed"); assert_eq!(sections.len(), 2); assert_eq!(sections[0].name, "projects"); // weight 10 assert_eq!(sections[1].name, "blog"); // weight 20 } #[test] fn test_section_collect_items() { let dir = create_test_dir(); let content_dir = dir.path(); fs::create_dir(content_dir.join("blog")).unwrap(); write_section_index( &content_dir.join("blog/_index.md"), "Blog", Some("blog"), None, ); write_frontmatter(&content_dir.join("blog/post1.md"), "Post 1", None, None); write_frontmatter(&content_dir.join("blog/post2.md"), "Post 2", None, None); let sections = discover_sections(content_dir).expect("discover_sections failed"); assert_eq!(sections.len(), 1); let items = sections[0].collect_items().expect("collect_items failed"); assert_eq!(items.len(), 2); let titles: Vec<_> = items.iter().map(|c| c.frontmatter.title.as_str()).collect(); assert!(titles.contains(&"Post 1")); assert!(titles.contains(&"Post 2")); } }