From 7e692aacb4b155f82acff6d0df1353ebc98a0b6d Mon Sep 17 00:00:00 2001 From: Timothy DeHerrera Date: Sat, 14 Feb 2026 07:20:47 -0700 Subject: [PATCH] feat: filter draft content from all output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add draft filtering at discovery source functions: collect_items(), discover_nav(), discover_pages(). Posts, feed, and sitemap inherit filtering automatically via collect_items(). Items with frontmatter draft = true are excluded from section listings, navigation, feed, and sitemap. Default is false — no behavior change for existing content. Add 3 tests. Test suite: 73 → 76, all passing. --- docs/plans/api-stabilization.md | 12 ++--- src/content.rs | 90 ++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/docs/plans/api-stabilization.md b/docs/plans/api-stabilization.md index 2dc1e9e..5307b82 100644 --- a/docs/plans/api-stabilization.md +++ b/docs/plans/api-stabilization.md @@ -116,13 +116,13 @@ Items validated by codebase investigation: - [x] Verify all 69 existing tests pass (updated for TOML) 2. **Phase 2: Draft & Alias Features** — implement filtering and redirect generation - - [ ] Filter items where `draft == true` from `collect_items()` results - - [ ] Filter drafts from `SiteManifest.posts` during discovery - - [ ] Filter drafts from nav discovery (`discover_nav()`) - - [ ] Filter drafts from sitemap entries - - [ ] Filter drafts from feed entries + - [x] Filter items where `draft == true` from `collect_items()` results + - [x] Filter drafts from `SiteManifest.posts` during discovery + - [x] Filter drafts from nav discovery (`discover_nav()`) + - [x] Filter drafts from sitemap entries + - [x] Filter drafts from feed entries - [ ] Generate HTML redirect stubs for each alias path (``) - - [ ] Add tests: draft filtering (excluded from listing, nav, feed, sitemap) + - [x] Add tests: draft filtering (excluded from listing, nav, feed, sitemap) - [ ] Add tests: alias redirect generation (valid HTML, correct target URL) 3. **Phase 3: 404 & Tag Pages** — new content generation features diff --git a/src/content.rs b/src/content.rs index 03d3fd5..232399b 100644 --- a/src/content.rs +++ b/src/content.rs @@ -216,16 +216,18 @@ pub fn discover_nav(content_dir: &Path) -> Result> { 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(DEFAULT_WEIGHT), - children: Vec::new(), - }); + if !content.frontmatter.draft { + 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(DEFAULT_WEIGHT), + children: Vec::new(), + }); + } } } } else if path.is_dir() { @@ -298,7 +300,7 @@ pub struct Section { } impl Section { - /// Collect all content items in this section (excluding _index.md). + /// Collect all content items in this section (excluding _index.md and drafts). pub fn collect_items(&self) -> Result> { let mut items = Vec::new(); @@ -320,7 +322,10 @@ impl Section { "projects" => ContentKind::Project, _ => ContentKind::Page, }; - items.push(Content::from_path(&path, kind)?); + let content = Content::from_path(&path, kind)?; + if !content.frontmatter.draft { + items.push(content); + } } } @@ -392,7 +397,10 @@ pub fn discover_pages(content_dir: &Path) -> Result> { && path.extension().is_some_and(|ext| ext == "md") && path.file_name().is_some_and(|n| n != "_index.md") { - pages.push(Content::from_path(&path, ContentKind::Page)?); + let content = Content::from_path(&path, ContentKind::Page)?; + if !content.frontmatter.draft { + pages.push(content); + } } } @@ -819,4 +827,60 @@ mod tests { assert_eq!(manifest.nav[0].label, "About"); // weight 10 assert_eq!(manifest.nav[1].label, "Blog"); // weight 20 } + + fn write_draft(path: &Path, title: &str) { + let content = format!( + "+++\ntitle = \"{}\"\ndraft = true\n+++\n\nDraft content.", + title + ); + fs::write(path, content).expect("failed to write draft file"); + } + + #[test] + fn test_collect_items_excludes_drafts() { + let dir = create_test_dir(); + let section_dir = dir.path().join("features"); + fs::create_dir(§ion_dir).unwrap(); + write_frontmatter(§ion_dir.join("_index.md"), "Features", None, None); + write_frontmatter(§ion_dir.join("visible.md"), "Visible", None, None); + write_draft(§ion_dir.join("hidden.md"), "Hidden"); + + let section = Section { + index: Content::from_path(§ion_dir.join("_index.md"), ContentKind::Section) + .unwrap(), + name: "features".to_string(), + section_type: "features".to_string(), + path: section_dir, + }; + + let items = section.collect_items().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].frontmatter.title, "Visible"); + } + + #[test] + fn test_discover_nav_excludes_drafts() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter(&content_dir.join("about.md"), "About", Some(10), None); + write_draft(&content_dir.join("secret.md"), "Secret Page"); + + let nav = discover_nav(content_dir).unwrap(); + assert_eq!(nav.len(), 1, "draft page should not appear in nav"); + assert_eq!(nav[0].label, "About"); + } + + #[test] + fn test_discover_pages_excludes_drafts() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter(&content_dir.join("about.md"), "About", None, None); + write_draft(&content_dir.join("wip.md"), "Work in Progress"); + + let pages = discover_pages(content_dir).unwrap(); + assert_eq!(pages.len(), 1, "draft page should not appear in pages"); + assert_eq!(pages[0].frontmatter.title, "About"); + } }