diff --git a/docs/content/_404.md b/docs/content/_404.md new file mode 100644 index 0000000..470d326 --- /dev/null +++ b/docs/content/_404.md @@ -0,0 +1,9 @@ ++++ +title = "Page Not Found" ++++ + +# Page Not Found + +The page you're looking for doesn't exist or has been moved. + +[Return to the homepage](/) diff --git a/docs/plans/api-stabilization.md b/docs/plans/api-stabilization.md index 9c6cd84..efc98c6 100644 --- a/docs/plans/api-stabilization.md +++ b/docs/plans/api-stabilization.md @@ -126,14 +126,14 @@ Items validated by codebase investigation: - [x] Add tests: alias redirect generation (valid HTML, correct target URL) 3. **Phase 3: 404 & Tag Pages** — new content generation features - - [ ] Detect `content/404.md` in content discovery, treat as special page - - [ ] Render `404.md` to `404.html` in output root - - [ ] Collect all unique tags across content items during build - - [ ] Create `tags/default.html` template in `docs/templates/` - - [ ] Generate `/tags/.html` for each unique tag with list of tagged items + - [x] Detect `content/_404.md` in content discovery, treat as special page + - [x] Render `_404.md` to `404.html` in output root + - [x] Collect all unique tags across content items during build + - [x] Create `tags/default.html` template in `docs/templates/` + - [x] Generate `/tags/.html` for each unique tag with list of tagged items - [ ] Add tag listing page entries to sitemap (if enabled) - - [ ] Add tests: 404 page generation - - [ ] Add tests: tag listing page generation (correct paths, correct items per tag) + - [x] Add tests: 404 page generation + - [x] Add tests: tag listing page generation (correct paths, correct items per tag) - [ ] End-to-end: build `docs/` site and verify all outputs ## Verification @@ -145,7 +145,7 @@ Items validated by codebase investigation: - [ ] End-to-end: build `docs/` site with `cargo run`, verify: - [ ] `public/sitemap.xml` exists (default enabled) - [ ] `public/atom.xml` exists (default enabled) - - [ ] `public/404.html` exists (with 404.md in docs/content) + - [ ] `public/404.html` exists (with \_404.md in docs/content) - [ ] Templates use `config.nav.nested` (not `config.nested_nav`) - [ ] Templates use `config.base_url` (not bare `base_url`) - [ ] No `section/features.html` or `homepage.html` templates remain diff --git a/docs/templates/tags/default.html b/docs/templates/tags/default.html new file mode 100644 index 0000000..11e93dc --- /dev/null +++ b/docs/templates/tags/default.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} {% block content %} + +{% endblock content %} \ No newline at end of file diff --git a/src/content.rs b/src/content.rs index 232399b..e10b86c 100644 --- a/src/content.rs +++ b/src/content.rs @@ -382,7 +382,7 @@ pub fn discover_sections(content_dir: &Path) -> Result> { Ok(sections) } -/// Discover standalone pages (top-level .md files except _index.md). +/// Discover standalone pages (top-level .md files except _index.md and _404.md). pub fn discover_pages(content_dir: &Path) -> Result> { let mut pages = Vec::new(); @@ -395,7 +395,9 @@ pub fn discover_pages(content_dir: &Path) -> Result> { 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") + && path + .file_name() + .is_some_and(|n| n != "_index.md" && n != "_404.md") { let content = Content::from_path(&path, ContentKind::Page)?; if !content.frontmatter.draft { @@ -414,6 +416,8 @@ pub fn discover_pages(content_dir: &Path) -> Result> { pub struct SiteManifest { /// Homepage content (content/_index.md) pub homepage: Content, + /// Custom 404 page (content/_404.md), if present + pub page_404: Option, /// All sections (directories with _index.md) pub sections: Vec
, /// Standalone pages (top-level .md files) @@ -435,6 +439,14 @@ impl SiteManifest { let homepage_path = content_dir.join("_index.md"); let homepage = Content::from_path(&homepage_path, ContentKind::Section)?; + // Load 404 page if present + let page_404_path = content_dir.join("_404.md"); + let page_404 = if page_404_path.exists() { + Some(Content::from_path(&page_404_path, ContentKind::Page)?) + } else { + None + }; + // Discover navigation let nav = discover_nav(content_dir)?; @@ -457,6 +469,7 @@ impl SiteManifest { Ok(SiteManifest { homepage, + page_404, sections, pages, posts, @@ -883,4 +896,55 @@ mod tests { assert_eq!(pages.len(), 1, "draft page should not appear in pages"); assert_eq!(pages[0].frontmatter.title, "About"); } + + #[test] + fn test_discover_pages_excludes_404() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter(&content_dir.join("about.md"), "About", None, None); + fs::write( + content_dir.join("_404.md"), + "+++\ntitle = \"Not Found\"\n+++\n\n404 body.", + ) + .unwrap(); + + let pages = discover_pages(content_dir).unwrap(); + assert_eq!(pages.len(), 1, "_404.md should not appear in pages"); + assert_eq!(pages[0].frontmatter.title, "About"); + } + + #[test] + fn test_manifest_detects_404() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter(&content_dir.join("_index.md"), "Home", None, None); + fs::write( + content_dir.join("_404.md"), + "+++\ntitle = \"Page Not Found\"\n+++\n\n404 body.", + ) + .unwrap(); + + let manifest = SiteManifest::discover(content_dir).unwrap(); + assert!(manifest.page_404.is_some(), "page_404 should be populated"); + assert_eq!( + manifest.page_404.unwrap().frontmatter.title, + "Page Not Found" + ); + } + + #[test] + fn test_manifest_without_404() { + let dir = create_test_dir(); + let content_dir = dir.path(); + + write_frontmatter(&content_dir.join("_index.md"), "Home", None, None); + + let manifest = SiteManifest::discover(content_dir).unwrap(); + assert!( + manifest.page_404.is_none(), + "page_404 should be None when _404.md absent" + ); + } } diff --git a/src/main.rs b/src/main.rs index bf4366c..c1365e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod template_engine; use crate::content::{Content, ContentKind, DEFAULT_WEIGHT, DEFAULT_WEIGHT_HIGH, NavItem}; use crate::error::{Error, Result}; use crate::template_engine::{ContentContext, TemplateEngine}; +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -194,12 +195,20 @@ fn run(config_path: &Path) -> Result<()> { // 4. Generate homepage generate_homepage(&manifest, &output_dir, &config, &engine)?; - // 5. Generate sitemap (if enabled) + // 5. Generate 404 page (if _404.md exists) + if let Some(ref page_404) = manifest.page_404 { + generate_404(page_404, &output_dir, &config, &manifest.nav, &engine)?; + } + + // 6. Generate tag listing pages + generate_tag_pages(&output_dir, &content_dir, &config, &manifest, &engine)?; + + // 7. Generate sitemap (if enabled) if config.sitemap.enabled { generate_sitemap_file(&output_dir, &manifest, &config, &content_dir)?; } - // 6. Generate alias redirects + // 8. Generate alias redirects generate_aliases(&output_dir, &content_dir, &manifest, &config)?; eprintln!("done!"); @@ -266,7 +275,9 @@ fn process_pages( 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") + && path + .file_name() + .is_some_and(|n| n != "_index.md" && n != "_404.md") { eprintln!("processing: {}", path.display()); @@ -317,6 +328,106 @@ fn generate_homepage( Ok(()) } +/// Generate the 404 error page from manifest.page_404 +fn generate_404( + page_404: &Content, + output_dir: &Path, + config: &config::SiteConfig, + nav: &[NavItem], + engine: &TemplateEngine, +) -> Result<()> { + eprintln!("generating: 404 page"); + + let (html_body, anchors) = render::markdown_to_html(&page_404.body); + let html = engine.render_page(page_404, &html_body, "/404.html", config, nav, &anchors)?; + + let out_path = output_dir.join("404.html"); + fs::write(&out_path, html).map_err(|e| Error::WriteFile { + path: out_path.clone(), + source: e, + })?; + + eprintln!(" → {}", out_path.display()); + Ok(()) +} + +/// Collect all unique tags across section items and standalone pages. +/// +/// Returns a sorted map of tag → tagged items for deterministic output. +fn collect_tags( + sections: &[content::Section], + pages: &[Content], + content_dir: &Path, + config: &config::SiteConfig, +) -> BTreeMap> { + let mut tags: BTreeMap> = BTreeMap::new(); + + // Collect from section items + for section in sections { + if let Ok(items) = section.collect_items() { + for item in &items { + for tag in &item.frontmatter.tags { + tags.entry(tag.clone()) + .or_default() + .push(ContentContext::from_content(item, content_dir, config)); + } + } + } + } + + // Collect from standalone pages + for page in pages { + for tag in &page.frontmatter.tags { + tags.entry(tag.clone()) + .or_default() + .push(ContentContext::from_content(page, content_dir, config)); + } + } + + tags +} + +/// Generate tag listing pages for all unique tags across content. +fn generate_tag_pages( + output_dir: &Path, + content_dir: &Path, + config: &config::SiteConfig, + manifest: &content::SiteManifest, + engine: &TemplateEngine, +) -> Result<()> { + let tags = collect_tags(&manifest.sections, &manifest.pages, content_dir, config); + + if tags.is_empty() { + return Ok(()); + } + + let tags_dir = output_dir.join("tags"); + fs::create_dir_all(&tags_dir).map_err(|e| Error::CreateDir { + path: tags_dir.clone(), + source: e, + })?; + + for (tag, items) in &tags { + let page_path = format!("/tags/{}.html", tag); + let html = engine.render_tag_page(tag, items, &page_path, config, &manifest.nav)?; + + let out_path = tags_dir.join(format!("{}.html", tag)); + fs::write(&out_path, html).map_err(|e| Error::WriteFile { + path: out_path.clone(), + source: e, + })?; + + eprintln!( + " tag: {} ({} items) → {}", + tag, + items.len(), + out_path.display() + ); + } + + Ok(()) +} + /// Write a content item to its output path fn write_output( output_dir: &Path, @@ -531,4 +642,81 @@ mod tests { assert!(html.contains("")); assert!(html.contains("")); } + + #[test] + fn test_collect_tags_groups_items() { + let dir = tempfile::tempdir().unwrap(); + let content_dir = dir.path(); + + // Create a section with tagged items + let section_dir = content_dir.join("blog"); + std::fs::create_dir_all(§ion_dir).unwrap(); + std::fs::write( + section_dir.join("_index.md"), + "+++\ntitle = \"Blog\"\nsection_type = \"blog\"\n+++\n", + ) + .unwrap(); + std::fs::write( + section_dir.join("post1.md"), + "+++\ntitle = \"Post 1\"\ntags = [\"rust\", \"web\"]\n+++\n\nBody.", + ) + .unwrap(); + std::fs::write( + section_dir.join("post2.md"), + "+++\ntitle = \"Post 2\"\ntags = [\"rust\"]\n+++\n\nBody.", + ) + .unwrap(); + + let sections = content::discover_sections(content_dir).unwrap(); + let pages: Vec = vec![]; + let config = config::SiteConfig { + title: String::new(), + author: String::new(), + base_url: "https://example.com".into(), + paths: Default::default(), + nav: Default::default(), + feed: Default::default(), + sitemap: Default::default(), + }; + + let tags = collect_tags(§ions, &pages, content_dir, &config); + assert_eq!(tags.len(), 2, "should have 2 unique tags"); + assert_eq!(tags["rust"].len(), 2, "rust tag should have 2 items"); + assert_eq!(tags["web"].len(), 1, "web tag should have 1 item"); + } + + #[test] + fn test_collect_tags_empty_when_no_tags() { + let dir = tempfile::tempdir().unwrap(); + let content_dir = dir.path(); + + // Create a section with untagged items + let section_dir = content_dir.join("docs"); + std::fs::create_dir_all(§ion_dir).unwrap(); + std::fs::write( + section_dir.join("_index.md"), + "+++\ntitle = \"Docs\"\n+++\n", + ) + .unwrap(); + std::fs::write( + section_dir.join("page.md"), + "+++\ntitle = \"A Page\"\n+++\n\nBody.", + ) + .unwrap(); + + let sections = content::discover_sections(content_dir).unwrap(); + let pages: Vec = vec![]; + let config = config::SiteConfig { + title: String::new(), + author: String::new(), + base_url: "https://example.com".into(), + paths: Default::default(), + nav: Default::default(), + feed: Default::default(), + sitemap: Default::default(), + }; + + let tags = collect_tags(§ions, &pages, content_dir, &config); + assert!(tags.is_empty(), "should have no tags"); + } } diff --git a/src/template_engine.rs b/src/template_engine.rs index 0970d8d..0691dc1 100644 --- a/src/template_engine.rs +++ b/src/template_engine.rs @@ -115,6 +115,24 @@ impl TemplateEngine { self.render(&template, &ctx) } + /// Render a tag listing page. + /// + /// Shows all content items tagged with the given tag. + pub fn render_tag_page( + &self, + tag: &str, + items: &[ContentContext], + page_path: &str, + config: &SiteConfig, + nav: &[NavItem], + ) -> Result { + let mut ctx = self.base_context(page_path, config, nav); + ctx.insert("title", &format!("Tagged: {}", tag)); + ctx.insert("tag", tag); + ctx.insert("items", items); + self.render("tags/default.html", &ctx) + } + /// Build base context with common variables. fn base_context(&self, page_path: &str, config: &SiteConfig, nav: &[NavItem]) -> Context { let mut ctx = Context::new();