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