rs_web/content/
mod.rs

1mod frontmatter;
2mod page;
3mod post;
4
5pub use page::Page;
6pub use post::{ContentType, Post};
7
8// Re-export for tests
9#[cfg(test)]
10pub use frontmatter::Frontmatter;
11
12use crate::config::PathsConfig;
13use anyhow::Result;
14use ignore::WalkBuilder;
15use std::collections::HashMap;
16use std::path::Path;
17use walkdir::WalkDir;
18
19/// A section is a subdirectory containing posts (e.g., blog, projects, notes)
20#[derive(Debug)]
21pub struct Section {
22    pub name: String,
23    pub posts: Vec<Post>,
24}
25
26/// Content holds the home page and all discovered sections
27#[derive(Debug)]
28pub struct Content {
29    pub home: Option<Page>,
30    pub sections: HashMap<String, Section>,
31}
32
33/// Discover all content files based on paths config
34pub fn discover_content(paths: &PathsConfig) -> Result<Content> {
35    let content_dir = Path::new(&paths.content);
36
37    // Build list of excluded directories (built-in + user-specified)
38    let mut excluded: Vec<&str> = vec![&paths.styles, &paths.static_files, &paths.templates];
39    excluded.extend(paths.exclude.iter().map(|s| s.as_str()));
40
41    // Load home page
42    let home_path = content_dir.join(&paths.home);
43    let home = if home_path.exists() {
44        Some(Page::from_file(&home_path)?)
45    } else {
46        None
47    };
48
49    // Discover all sections (subdirectories)
50    let mut sections = HashMap::new();
51
52    // Collect section paths using appropriate walker
53    let section_paths: Vec<_> = if paths.respect_gitignore {
54        WalkBuilder::new(content_dir)
55            .max_depth(Some(1))
56            .hidden(false) // Don't skip hidden files by default
57            .build()
58            .filter_map(|e| e.ok())
59            .filter(|e| e.depth() == 1 && e.path().is_dir())
60            .map(|e| e.into_path())
61            .collect()
62    } else {
63        WalkDir::new(content_dir)
64            .min_depth(1)
65            .max_depth(1)
66            .into_iter()
67            .filter_map(|e| e.ok())
68            .filter(|e| e.path().is_dir())
69            .map(|e| e.into_path())
70            .collect()
71    };
72
73    // Process each section
74    for path in section_paths {
75        process_section(&path, &excluded, &mut sections, paths)?;
76    }
77
78    Ok(Content { home, sections })
79}
80
81/// Process a section directory and add it to sections map
82fn process_section(
83    path: &Path,
84    excluded: &[&str],
85    sections: &mut HashMap<String, Section>,
86    paths: &PathsConfig,
87) -> Result<()> {
88    let section_name = path
89        .file_name()
90        .and_then(|n| n.to_str())
91        .unwrap_or("")
92        .to_string();
93
94    // Skip excluded directories
95    if excluded
96        .iter()
97        .any(|ex| section_name == *ex || path.ends_with(ex))
98    {
99        return Ok(());
100    }
101
102    // Collect content file paths (markdown and HTML) using appropriate walker
103    let post_paths: Vec<_> = if paths.respect_gitignore {
104        WalkBuilder::new(path)
105            .max_depth(Some(1))
106            .hidden(false)
107            .build()
108            .filter_map(|e| e.ok())
109            .filter(|e| {
110                e.depth() == 1
111                    && e.path()
112                        .extension()
113                        .is_some_and(|ext| ext == "md" || ext == "html" || ext == "htm")
114            })
115            .map(|e| e.into_path())
116            .collect()
117    } else {
118        WalkDir::new(path)
119            .min_depth(1)
120            .max_depth(1)
121            .into_iter()
122            .filter_map(|e| e.ok())
123            .filter(|e| {
124                e.path()
125                    .extension()
126                    .is_some_and(|ext| ext == "md" || ext == "html" || ext == "htm")
127            })
128            .map(|e| e.into_path())
129            .collect()
130    };
131
132    // Load posts (can be parallelized with rayon if needed)
133    let mut posts = Vec::new();
134    for post_path in post_paths {
135        let post = Post::from_file_with_section(&post_path, &section_name)?;
136        if !post.frontmatter.draft.unwrap_or(false) {
137            posts.push(post);
138        }
139    }
140
141    // Sort posts by date (newest first)
142    posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
143
144    if !posts.is_empty() {
145        sections.insert(
146            section_name.clone(),
147            Section {
148                name: section_name,
149                posts,
150            },
151        );
152    }
153
154    Ok(())
155}