1use std::{
6 collections::HashMap,
7 fs,
8 path::{Path, PathBuf},
9};
10
11use rayon::prelude::*;
12use thiserror::Error;
13use tracing::{debug, info, warn};
14use typstify_core::{Config, ContentPath, ContentType, Page};
15use typstify_parser::ParserRegistry;
16
17#[derive(Debug, Error)]
19pub enum CollectorError {
20 #[error("IO error: {0}")]
22 Io(#[from] std::io::Error),
23
24 #[error("parse error in {path}: {message}")]
26 Parse { path: PathBuf, message: String },
27
28 #[error("invalid content path: {0}")]
30 InvalidPath(PathBuf),
31}
32
33pub type Result<T> = std::result::Result<T, CollectorError>;
35
36#[derive(Debug, Default)]
38pub struct SiteContent {
39 pub pages: HashMap<String, Page>,
41
42 pub sections: HashMap<String, Vec<String>>,
44
45 pub taxonomies: TaxonomyIndex,
47
48 pub translations: HashMap<String, Vec<String>>,
50}
51
52#[derive(Debug, Default)]
54pub struct TaxonomyIndex {
55 pub tags: HashMap<String, Vec<String>>,
57
58 pub categories: HashMap<String, Vec<String>>,
60}
61
62#[derive(Debug)]
64pub struct ContentCollector<'a> {
65 config: &'a Config,
66 parser: ParserRegistry,
67 content_dir: PathBuf,
68}
69
70impl<'a> ContentCollector<'a> {
71 #[must_use]
73 pub fn new(config: &'a Config, content_dir: impl Into<PathBuf>) -> Self {
74 Self {
75 config,
76 parser: ParserRegistry::new(),
77 content_dir: content_dir.into(),
78 }
79 }
80
81 pub fn collect(&self) -> Result<SiteContent> {
83 info!(dir = %self.content_dir.display(), "collecting content");
84
85 let files = self.find_content_files()?;
87 info!(count = files.len(), "found content files");
88
89 let pages: Vec<_> = files
91 .par_iter()
92 .filter_map(|path| {
93 match self.parse_file(path) {
94 Ok(page) => {
95 if page.draft && !self.config.build.drafts {
97 debug!(url = %page.url, "skipping draft");
98 None
99 } else {
100 Some(page)
101 }
102 }
103 Err(e) => {
104 warn!(path = %path.display(), error = %e, "failed to parse file");
105 None
106 }
107 }
108 })
109 .collect();
110
111 let mut content = SiteContent::default();
113
114 for page in pages {
115 let url = page.url.clone();
116 let slug = url.trim_start_matches('/').to_string();
117
118 let section = slug.split('/').next().unwrap_or("").to_string();
120 if !section.is_empty() {
121 content
122 .sections
123 .entry(section)
124 .or_default()
125 .push(url.clone());
126 }
127
128 for tag in &page.tags {
130 content
131 .taxonomies
132 .tags
133 .entry(tag.clone())
134 .or_default()
135 .push(url.clone());
136 }
137 for category in &page.categories {
138 content
139 .taxonomies
140 .categories
141 .entry(category.clone())
142 .or_default()
143 .push(url.clone());
144 }
145
146 if !page.canonical_id.is_empty() {
148 content
149 .translations
150 .entry(page.canonical_id.clone())
151 .or_default()
152 .push(url.clone());
153 }
154
155 content.pages.insert(url, page);
156 }
157
158 info!(
159 pages = content.pages.len(),
160 sections = content.sections.len(),
161 tags = content.taxonomies.tags.len(),
162 categories = content.taxonomies.categories.len(),
163 "content collection complete"
164 );
165
166 Ok(content)
167 }
168
169 fn find_content_files(&self) -> Result<Vec<PathBuf>> {
171 let mut files = Vec::new();
172 self.walk_dir(&self.content_dir, &mut files)?;
173 Ok(files)
174 }
175
176 fn walk_dir(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
178 if !dir.exists() {
179 return Ok(());
180 }
181
182 for entry in fs::read_dir(dir)? {
183 let entry = entry?;
184 let path = entry.path();
185
186 if path.is_dir() {
187 if path
189 .file_name()
190 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
191 {
192 continue;
193 }
194 self.walk_dir(&path, files)?;
195 } else if path.is_file() {
196 if let Some(ext) = path.extension()
198 && ContentType::from_extension(&ext.to_string_lossy()).is_some()
199 {
200 files.push(path);
201 }
202 }
203 }
204
205 Ok(())
206 }
207
208 fn parse_file(&self, path: &Path) -> Result<Page> {
210 debug!(path = %path.display(), "parsing file");
211
212 let content = fs::read_to_string(path)?;
214
215 let relative_path = path.strip_prefix(&self.content_dir).unwrap_or(path);
217 let content_path =
218 ContentPath::from_path(relative_path, &self.config.site.default_language)
219 .ok_or_else(|| CollectorError::InvalidPath(path.to_path_buf()))?;
220
221 let parsed = self
223 .parser
224 .parse(&content, path)
225 .map_err(|e| CollectorError::Parse {
226 path: path.to_path_buf(),
227 message: e.to_string(),
228 })?;
229
230 Ok(Page::from_parsed(parsed, &content_path))
231 }
232
233 pub fn pages_by_date(content: &SiteContent) -> Vec<&Page> {
235 let mut pages: Vec<_> = content.pages.values().collect();
236 pages.sort_by(|a, b| match (&b.date, &a.date) {
237 (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
238 (Some(_), None) => std::cmp::Ordering::Less,
239 (None, Some(_)) => std::cmp::Ordering::Greater,
240 (None, None) => a.title.cmp(&b.title),
241 });
242 pages
243 }
244
245 pub fn section_pages<'b>(content: &'b SiteContent, section: &str) -> Vec<&'b Page> {
247 let mut pages: Vec<_> = content
248 .sections
249 .get(section)
250 .map(|urls| urls.iter().filter_map(|u| content.pages.get(u)).collect())
251 .unwrap_or_default();
252
253 pages.sort_by(|a, b| match (&b.date, &a.date) {
254 (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
255 (Some(_), None) => std::cmp::Ordering::Less,
256 (None, Some(_)) => std::cmp::Ordering::Greater,
257 (None, None) => a.title.cmp(&b.title),
258 });
259 pages
260 }
261
262 pub fn taxonomy_pages<'b>(
264 content: &'b SiteContent,
265 taxonomy: &str,
266 term: &str,
267 ) -> Vec<&'b Page> {
268 let urls = match taxonomy {
269 "tags" => content.taxonomies.tags.get(term),
270 "categories" => content.taxonomies.categories.get(term),
271 _ => None,
272 };
273
274 let mut pages: Vec<_> = urls
275 .map(|u| u.iter().filter_map(|url| content.pages.get(url)).collect())
276 .unwrap_or_default();
277
278 pages.sort_by(|a, b| match (&b.date, &a.date) {
279 (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
280 (Some(_), None) => std::cmp::Ordering::Less,
281 (None, Some(_)) => std::cmp::Ordering::Greater,
282 (None, None) => a.title.cmp(&b.title),
283 });
284 pages
285 }
286}
287
288pub fn paginate<T>(items: &[T], page: usize, per_page: usize) -> (&[T], usize) {
290 let total_pages = items.len().div_ceil(per_page);
291 let start = (page - 1) * per_page;
292 let end = (start + per_page).min(items.len());
293
294 if start >= items.len() {
295 (&[], total_pages)
296 } else {
297 (&items[start..end], total_pages)
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use std::collections::HashMap;
304
305 use typstify_core::test_fixtures::test_config;
306
307 use super::*;
308
309 #[test]
310 fn test_paginate() {
311 let items = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
312
313 let (page1, total) = paginate(&items, 1, 3);
314 assert_eq!(page1, &[1, 2, 3]);
315 assert_eq!(total, 4);
316
317 let (page2, _) = paginate(&items, 2, 3);
318 assert_eq!(page2, &[4, 5, 6]);
319
320 let (page4, _) = paginate(&items, 4, 3);
321 assert_eq!(page4, &[10]);
322
323 let (page5, _) = paginate(&items, 5, 3);
324 assert!(page5.is_empty());
325 }
326
327 #[test]
328 fn test_taxonomy_index() {
329 let mut index = TaxonomyIndex::default();
330 index.tags.insert(
331 "rust".to_string(),
332 vec!["post1".to_string(), "post2".to_string()],
333 );
334 index
335 .tags
336 .insert("web".to_string(), vec!["post2".to_string()]);
337
338 assert_eq!(index.tags.get("rust").unwrap().len(), 2);
339 assert_eq!(index.tags.get("web").unwrap().len(), 1);
340 assert!(!index.tags.contains_key("python"));
341 }
342
343 #[test]
344 fn test_site_content_default() {
345 let content = SiteContent::default();
346 assert!(content.pages.is_empty());
347 assert!(content.sections.is_empty());
348 assert!(content.taxonomies.tags.is_empty());
349 }
350
351 fn testdata_dir() -> PathBuf {
352 Path::new(env!("CARGO_MANIFEST_DIR"))
353 .join("testdata")
354 .join("content")
355 }
356
357 #[test]
358 fn collect_returns_correct_page_count() {
359 let config = test_config();
360 let collector = ContentCollector::new(&config, testdata_dir());
361 let content = collector.collect().expect("collect should succeed");
362
363 assert_eq!(content.pages.len(), 5);
366 }
367
368 #[test]
369 fn collect_assigns_pages_to_correct_sections() {
370 let config = test_config();
371 let collector = ContentCollector::new(&config, testdata_dir());
372 let content = collector.collect().expect("collect should succeed");
373
374 let posts = content
376 .sections
377 .get("posts")
378 .expect("posts section should exist");
379 assert_eq!(posts.len(), 2);
380 assert!(posts.contains(&"/posts/hello-world".to_string()));
381 assert!(posts.contains(&"/posts/getting-started".to_string()));
382
383 let zh = content.sections.get("zh").expect("zh section should exist");
385 assert!(zh.contains(&"/zh/posts/hello-world".to_string()));
386
387 let about = content
389 .sections
390 .get("about")
391 .expect("about section should exist");
392 assert_eq!(about.len(), 1);
393 assert!(about.contains(&"/about".to_string()));
394
395 let docs = content
397 .sections
398 .get("docs")
399 .expect("docs section should exist");
400 assert_eq!(docs.len(), 1);
401 assert!(docs.contains(&"/docs/technical-spec".to_string()));
402 }
403
404 #[test]
405 fn collect_builds_translation_groups() {
406 let config = test_config();
407 let collector = ContentCollector::new(&config, testdata_dir());
408 let content = collector.collect().expect("collect should succeed");
409
410 let group = content
412 .translations
413 .get("posts/hello-world")
414 .expect("translation group should exist");
415 assert_eq!(group.len(), 2);
416 assert!(group.contains(&"/posts/hello-world".to_string()));
417 assert!(group.contains(&"/zh/posts/hello-world".to_string()));
418 }
419
420 #[test]
421 fn collect_indexes_taxonomy_entries() {
422 let config = test_config();
423 let collector = ContentCollector::new(&config, testdata_dir());
424 let content = collector.collect().expect("collect should succeed");
425
426 let intro_pages = content
428 .taxonomies
429 .tags
430 .get("intro")
431 .expect("'intro' tag should exist");
432 assert!(intro_pages.contains(&"/posts/hello-world".to_string()));
433
434 let welcome_pages = content
435 .taxonomies
436 .tags
437 .get("welcome")
438 .expect("'welcome' tag should exist");
439 assert!(welcome_pages.contains(&"/posts/hello-world".to_string()));
440
441 assert!(content.taxonomies.tags.contains_key("tutorial"));
443 assert!(content.taxonomies.tags.contains_key("beginner"));
444
445 assert!(content.taxonomies.tags.contains_key("typst"));
447 assert!(content.taxonomies.tags.contains_key("technical"));
448 assert!(content.taxonomies.tags.contains_key("spec"));
449 }
450
451 #[test]
452 fn collect_excludes_drafts_when_config_disallows() {
453 let tmpdir = tempfile::tempdir().expect("create temp dir");
454 let content_dir = tmpdir.path().join("content");
455 let posts_dir = content_dir.join("posts");
456 fs::create_dir_all(&posts_dir).expect("create posts dir");
457
458 let draft_content = "---\ntitle: \"Draft Post\"\ndate: 2024-01-01T00:00:00Z\ndraft: true\ntags: []\n---\nThis is a draft.\n";
460 fs::write(posts_dir.join("draft-post.md"), draft_content).expect("write draft file");
461
462 let published_content = "---\ntitle: \"Published Post\"\ndate: 2024-01-01T00:00:00Z\ndraft: false\ntags: []\n---\nThis is published.\n";
464 fs::write(posts_dir.join("published-post.md"), published_content)
465 .expect("write published file");
466
467 let config = test_config();
469 let collector = ContentCollector::new(&config, &content_dir);
470 let content = collector.collect().expect("collect should succeed");
471
472 assert_eq!(
473 content.pages.len(),
474 1,
475 "only published page should be collected"
476 );
477 assert!(
478 content.pages.contains_key("/posts/published-post"),
479 "published page should be present"
480 );
481 assert!(
482 !content.pages.contains_key("/posts/draft-post"),
483 "draft page should be excluded"
484 );
485
486 let config_with_drafts = Config::from_parts(
488 typstify_core::config::SiteConfig {
489 title: "Test Site".to_string(),
490 host: "https://example.com".to_string(),
491 base_path: String::new(),
492 default_language: "en".to_string(),
493 description: None,
494 author: None,
495 },
496 typstify_core::config::BuildConfig {
497 drafts: true,
498 ..Default::default()
499 },
500 typstify_core::config::SearchConfig::default(),
501 typstify_core::config::RssConfig::default(),
502 typstify_core::config::RobotsConfig::default(),
503 typstify_core::config::TaxonomyConfig::default(),
504 HashMap::new(),
505 );
506 let collector_with_drafts = ContentCollector::new(&config_with_drafts, &content_dir);
507 let content_with_drafts = collector_with_drafts
508 .collect()
509 .expect("collect should succeed");
510
511 assert_eq!(
512 content_with_drafts.pages.len(),
513 2,
514 "both pages should be collected when drafts enabled"
515 );
516 assert!(
517 content_with_drafts.pages.contains_key("/posts/draft-post"),
518 "draft page should be included when drafts enabled"
519 );
520 }
521}