refactor(content): add SiteManifest for unified content discovery
Introduce SiteManifest struct that aggregates all site content from a single discovery pass: - homepage: content/_index.md - sections: directories with _index.md - pages: top-level standalone .md files - posts: blog section items (sorted by date for feed) - nav: navigation menu items Add discover_pages() helper and 5 unit tests covering homepage, sections, pages, posts, and nav discovery. Not yet integrated into main.rs pipeline.
This commit is contained in:
181
src/content.rs
181
src/content.rs
@@ -329,6 +329,86 @@ pub fn discover_sections(content_dir: &Path) -> Result<Vec<Section>> {
|
|||||||
Ok(sections)
|
Ok(sections)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discover standalone pages (top-level .md files except _index.md).
|
||||||
|
pub fn discover_pages(content_dir: &Path) -> Result<Vec<Content>> {
|
||||||
|
let mut pages = 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_file()
|
||||||
|
&& 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)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete site content manifest from a single discovery pass.
|
||||||
|
///
|
||||||
|
/// Aggregates all content types for use by rendering, feed, and sitemap generation.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SiteManifest {
|
||||||
|
/// Homepage content (content/_index.md)
|
||||||
|
pub homepage: Content,
|
||||||
|
/// All sections (directories with _index.md)
|
||||||
|
pub sections: Vec<Section>,
|
||||||
|
/// Standalone pages (top-level .md files)
|
||||||
|
pub pages: Vec<Content>,
|
||||||
|
/// Blog posts for feed generation (items from "blog" sections)
|
||||||
|
pub posts: Vec<Content>,
|
||||||
|
/// Navigation menu items
|
||||||
|
pub nav: Vec<NavItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SiteManifest {
|
||||||
|
/// Discover all site content in a single pass.
|
||||||
|
pub fn discover(content_dir: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
Self::discover_inner(content_dir.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_inner(content_dir: &Path) -> Result<Self> {
|
||||||
|
// Load homepage
|
||||||
|
let homepage_path = content_dir.join("_index.md");
|
||||||
|
let homepage = Content::from_path(&homepage_path, ContentKind::Section)?;
|
||||||
|
|
||||||
|
// Discover navigation
|
||||||
|
let nav = discover_nav(content_dir)?;
|
||||||
|
|
||||||
|
// Discover sections
|
||||||
|
let sections = discover_sections(content_dir)?;
|
||||||
|
|
||||||
|
// Collect section items and identify blog posts
|
||||||
|
let mut posts = Vec::new();
|
||||||
|
for section in §ions {
|
||||||
|
if section.section_type == "blog" {
|
||||||
|
let mut items = section.collect_items()?;
|
||||||
|
// Sort blog posts by date, newest first
|
||||||
|
items.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
|
||||||
|
posts.extend(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover standalone pages
|
||||||
|
let pages = discover_pages(content_dir)?;
|
||||||
|
|
||||||
|
Ok(SiteManifest {
|
||||||
|
homepage,
|
||||||
|
sections,
|
||||||
|
pages,
|
||||||
|
posts,
|
||||||
|
nav,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -557,4 +637,105 @@ mod tests {
|
|||||||
assert!(titles.contains(&"Post 1"));
|
assert!(titles.contains(&"Post 1"));
|
||||||
assert!(titles.contains(&"Post 2"));
|
assert!(titles.contains(&"Post 2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SiteManifest tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_manifest_discovers_homepage() {
|
||||||
|
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).expect("discover failed");
|
||||||
|
assert_eq!(manifest.homepage.frontmatter.title, "Home");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_manifest_discovers_sections() {
|
||||||
|
let dir = create_test_dir();
|
||||||
|
let content_dir = dir.path();
|
||||||
|
|
||||||
|
write_frontmatter(&content_dir.join("_index.md"), "Home", None, None);
|
||||||
|
fs::create_dir(content_dir.join("blog")).unwrap();
|
||||||
|
write_section_index(
|
||||||
|
&content_dir.join("blog/_index.md"),
|
||||||
|
"Blog",
|
||||||
|
Some("blog"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let manifest = SiteManifest::discover(content_dir).expect("discover failed");
|
||||||
|
assert_eq!(manifest.sections.len(), 1);
|
||||||
|
assert_eq!(manifest.sections[0].name, "blog");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_manifest_discovers_pages() {
|
||||||
|
let dir = create_test_dir();
|
||||||
|
let content_dir = dir.path();
|
||||||
|
|
||||||
|
write_frontmatter(&content_dir.join("_index.md"), "Home", None, None);
|
||||||
|
write_frontmatter(&content_dir.join("about.md"), "About", None, None);
|
||||||
|
write_frontmatter(&content_dir.join("contact.md"), "Contact", None, None);
|
||||||
|
|
||||||
|
let manifest = SiteManifest::discover(content_dir).expect("discover failed");
|
||||||
|
assert_eq!(manifest.pages.len(), 2);
|
||||||
|
|
||||||
|
let titles: Vec<_> = manifest
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.frontmatter.title.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(titles.contains(&"About"));
|
||||||
|
assert!(titles.contains(&"Contact"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_manifest_collects_blog_posts() {
|
||||||
|
let dir = create_test_dir();
|
||||||
|
let content_dir = dir.path();
|
||||||
|
|
||||||
|
write_frontmatter(&content_dir.join("_index.md"), "Home", None, None);
|
||||||
|
fs::create_dir(content_dir.join("blog")).unwrap();
|
||||||
|
write_section_index(
|
||||||
|
&content_dir.join("blog/_index.md"),
|
||||||
|
"Blog",
|
||||||
|
Some("blog"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create blog posts with dates
|
||||||
|
let post1 = format!("---\ntitle: \"Post 1\"\ndate: \"2026-01-15\"\n---\nContent.");
|
||||||
|
let post2 = format!("---\ntitle: \"Post 2\"\ndate: \"2026-01-20\"\n---\nContent.");
|
||||||
|
fs::write(content_dir.join("blog/post1.md"), &post1).unwrap();
|
||||||
|
fs::write(content_dir.join("blog/post2.md"), &post2).unwrap();
|
||||||
|
|
||||||
|
let manifest = SiteManifest::discover(content_dir).expect("discover failed");
|
||||||
|
assert_eq!(manifest.posts.len(), 2);
|
||||||
|
|
||||||
|
// Should be sorted by date, newest first
|
||||||
|
assert_eq!(manifest.posts[0].frontmatter.title, "Post 2"); // 2026-01-20
|
||||||
|
assert_eq!(manifest.posts[1].frontmatter.title, "Post 1"); // 2026-01-15
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_manifest_discovers_nav() {
|
||||||
|
let dir = create_test_dir();
|
||||||
|
let content_dir = dir.path();
|
||||||
|
|
||||||
|
write_frontmatter(&content_dir.join("_index.md"), "Home", None, None);
|
||||||
|
write_frontmatter(&content_dir.join("about.md"), "About", Some(10), None);
|
||||||
|
fs::create_dir(content_dir.join("blog")).unwrap();
|
||||||
|
write_section_index(&content_dir.join("blog/_index.md"), "Blog", None, Some(20));
|
||||||
|
|
||||||
|
let manifest = SiteManifest::discover(content_dir).expect("discover failed");
|
||||||
|
assert_eq!(manifest.nav.len(), 2);
|
||||||
|
|
||||||
|
// Nav should be sorted by weight
|
||||||
|
assert_eq!(manifest.nav[0].label, "About"); // weight 10
|
||||||
|
assert_eq!(manifest.nav[1].label, "Blog"); // weight 20
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user