1use std::{
6 fs,
7 path::{Path, PathBuf},
8 time::Instant,
9};
10
11use rayon::prelude::*;
12use thiserror::Error;
13use tracing::{debug, info, warn};
14use typstify_core::{Config, Page};
15use typstify_search::SimpleSearchIndex;
16
17use crate::{
18 assets::{AssetError, AssetManifest, AssetProcessor},
19 collector::{CollectorError, ContentCollector, SiteContent, paginate},
20 html::{HtmlError, HtmlGenerator, list_item_html, pagination_html},
21 robots::{RobotsError, RobotsGenerator},
22 rss::{RssError, RssGenerator},
23 sitemap::{SitemapError, SitemapGenerator},
24};
25
26#[derive(Debug, Error)]
28pub enum BuildError {
29 #[error("IO error: {0}")]
31 Io(#[from] std::io::Error),
32
33 #[error("collector error: {0}")]
35 Collector(#[from] CollectorError),
36
37 #[error("HTML error: {0}")]
39 Html(#[from] HtmlError),
40
41 #[error("RSS error: {0}")]
43 Rss(#[from] RssError),
44
45 #[error("sitemap error: {0}")]
47 Sitemap(#[from] SitemapError),
48
49 #[error("robots error: {0}")]
51 Robots(#[from] RobotsError),
52
53 #[error("asset error: {0}")]
55 Asset(#[from] AssetError),
56
57 #[error("config error: {0}")]
59 Config(String),
60}
61
62pub type Result<T> = std::result::Result<T, BuildError>;
64
65#[derive(Debug, Clone, Default)]
67pub struct BuildStats {
68 pub pages: usize,
70
71 pub taxonomy_pages: usize,
73
74 pub redirects: usize,
76
77 pub auto_pages: usize,
79
80 pub assets: usize,
82
83 pub duration_ms: u64,
85}
86
87#[derive(Debug)]
89pub struct Builder {
90 config: Config,
91 content_dir: PathBuf,
92 output_dir: PathBuf,
93 static_dir: Option<PathBuf>,
94}
95
96impl Builder {
97 #[must_use]
99 pub fn new(
100 config: Config,
101 content_dir: impl Into<PathBuf>,
102 output_dir: impl Into<PathBuf>,
103 ) -> Self {
104 Self {
105 config,
106 content_dir: content_dir.into(),
107 output_dir: output_dir.into(),
108 static_dir: None,
109 }
110 }
111
112 #[must_use]
114 pub fn with_static_dir(mut self, dir: impl Into<PathBuf>) -> Self {
115 self.static_dir = Some(dir.into());
116 self
117 }
118
119 pub fn build(&self) -> Result<BuildStats> {
121 let start = Instant::now();
122 let mut stats = BuildStats::default();
123
124 info!(
125 content = %self.content_dir.display(),
126 output = %self.output_dir.display(),
127 "starting build"
128 );
129
130 self.clean_output()?;
132
133 let collector = ContentCollector::new(self.config.clone(), &self.content_dir);
135 let content = collector.collect()?;
136
137 stats.pages = self.generate_pages(&content)?;
139
140 stats.taxonomy_pages = self.generate_taxonomy_pages(&content)?;
142
143 stats.auto_pages = self.generate_auto_pages(&content)?;
145
146 stats.redirects = self.generate_redirects(&content)?;
148
149 if self.config.rss.enabled {
151 self.generate_rss(&content)?;
152 }
153
154 self.generate_sitemap(&content)?;
156
157 self.generate_robots()?;
159
160 if self.config.search.enabled {
162 self.generate_search_indexes(&content)?;
163 }
164
165 crate::static_assets::generate_static_assets(&self.output_dir)
167 .map_err(|e| BuildError::Io(std::io::Error::other(e.to_string())))?;
168
169 if let Some(ref static_dir) = self.static_dir {
171 let manifest = self.process_assets(static_dir)?;
172 stats.assets = manifest.assets().len();
173 }
174
175 stats.duration_ms = start.elapsed().as_millis() as u64;
176
177 info!(
178 pages = stats.pages,
179 taxonomy_pages = stats.taxonomy_pages,
180 auto_pages = stats.auto_pages,
181 redirects = stats.redirects,
182 assets = stats.assets,
183 duration_ms = stats.duration_ms,
184 "build complete"
185 );
186
187 Ok(stats)
188 }
189
190 fn clean_output(&self) -> Result<()> {
192 if self.output_dir.exists() {
193 debug!(dir = %self.output_dir.display(), "cleaning output directory");
194 fs::remove_dir_all(&self.output_dir)?;
195 }
196 fs::create_dir_all(&self.output_dir)?;
197 Ok(())
198 }
199
200 fn generate_pages(&self, content: &SiteContent) -> Result<usize> {
202 let generator = HtmlGenerator::new(self.config.clone());
203 let pages: Vec<_> = content.pages.values().collect();
204
205 info!(count = pages.len(), "generating HTML pages");
206
207 let results: Vec<_> = pages
209 .par_iter()
210 .map(|page| {
211 let mut alternates = Vec::new();
213 if let Some(slugs) = content.translations.get(&page.canonical_id) {
214 for slug in slugs {
215 if let Some(alt_page) = content.pages.get(slug) {
216 alternates.push((alt_page.lang.as_str(), alt_page.url.as_str()));
217 }
218 }
219 }
220
221 let html = generator.generate_page(page, &alternates)?;
222 let output_path = generator.output_path(page, &self.output_dir);
223
224 if let Some(parent) = output_path.parent() {
226 fs::create_dir_all(parent)?;
227 }
228 fs::write(&output_path, &html)?;
229
230 debug!(path = %output_path.display(), "wrote page");
231 Ok::<_, BuildError>(())
232 })
233 .collect();
234
235 let mut count = 0;
237 for result in results {
238 match result {
239 Ok(()) => count += 1,
240 Err(e) => warn!(error = %e, "failed to generate page"),
241 }
242 }
243
244 Ok(count)
245 }
246
247 fn generate_taxonomy_pages(&self, content: &SiteContent) -> Result<usize> {
249 let generator = HtmlGenerator::new(self.config.clone());
250 let per_page = self.config.taxonomies.tags.paginate;
251 let mut count = 0;
252
253 for (tag, slugs) in &content.taxonomies.tags {
255 let pages: Vec<_> = slugs.iter().filter_map(|s| content.pages.get(s)).collect();
256 count += self
257 .generate_taxonomy_term_pages(&generator, "Tags", tag, &pages, per_page, "tags")?;
258 }
259
260 for (category, slugs) in &content.taxonomies.categories {
262 let pages: Vec<_> = slugs.iter().filter_map(|s| content.pages.get(s)).collect();
263 count += self.generate_taxonomy_term_pages(
264 &generator,
265 "Categories",
266 category,
267 &pages,
268 per_page,
269 "categories",
270 )?;
271 }
272
273 Ok(count)
274 }
275
276 fn generate_taxonomy_term_pages(
278 &self,
279 generator: &HtmlGenerator,
280 taxonomy_name: &str,
281 term: &str,
282 pages: &[&typstify_core::Page],
283 per_page: usize,
284 url_prefix: &str,
285 ) -> Result<usize> {
286 use crate::collector::paginate;
287
288 let term_slug = term.to_lowercase().replace(' ', "-");
289 let base_url = format!("/{url_prefix}/{term_slug}");
290 let total_pages = (pages.len() + per_page - 1).max(1) / per_page.max(1);
291 let mut count = 0;
292
293 for page_num in 1..=total_pages.max(1) {
294 let (page_items, _) = paginate(pages, page_num, per_page);
295
296 let items_html: String = page_items.iter().map(|p| list_item_html(p)).collect();
297
298 let pagination = pagination_html(page_num, total_pages, &base_url);
299
300 let html = generator.generate_taxonomy_page(
301 taxonomy_name,
302 term,
303 &items_html,
304 pagination.as_deref(),
305 )?;
306
307 let output_path = if page_num == 1 {
309 self.output_dir
310 .join(url_prefix)
311 .join(&term_slug)
312 .join("index.html")
313 } else {
314 self.output_dir
315 .join(url_prefix)
316 .join(&term_slug)
317 .join("page")
318 .join(page_num.to_string())
319 .join("index.html")
320 };
321
322 if let Some(parent) = output_path.parent() {
323 fs::create_dir_all(parent)?;
324 }
325 fs::write(&output_path, &html)?;
326 count += 1;
327 }
328
329 Ok(count)
330 }
331
332 fn generate_auto_pages(&self, content: &SiteContent) -> Result<usize> {
335 let generator = HtmlGenerator::new(self.config.clone());
336 let mut count = 0;
337
338 let all_languages = self.config.all_languages();
340 let default_lang = &self.config.site.default_language;
341
342 for lang in &all_languages {
344 let is_default = *lang == default_lang.as_str();
345 let lang_prefix = if is_default {
346 String::new()
347 } else {
348 lang.to_string()
349 };
350
351 let lang_pages: Vec<_> = content.pages.values().filter(|p| p.lang == *lang).collect();
353
354 let lang_tags: std::collections::HashMap<String, Vec<String>> = lang_pages
356 .iter()
357 .flat_map(|p| p.tags.iter().map(|t| (t.clone(), p.url.clone())))
358 .fold(std::collections::HashMap::new(), |mut acc, (tag, url)| {
359 acc.entry(tag).or_default().push(url);
360 acc
361 });
362
363 if !lang_tags.is_empty() {
364 let html = generator.generate_tags_index_page(&lang_tags, lang)?;
365 let output_path = if is_default {
366 self.output_dir.join("tags").join("index.html")
367 } else {
368 self.output_dir
369 .join(&lang_prefix)
370 .join("tags")
371 .join("index.html")
372 };
373 if let Some(parent) = output_path.parent() {
374 fs::create_dir_all(parent)?;
375 }
376 fs::write(&output_path, &html)?;
377 count += 1;
378 info!(path = %output_path.display(), lang = lang, "generated tags index page");
379 }
380
381 let lang_categories: std::collections::HashMap<String, Vec<String>> = lang_pages
383 .iter()
384 .flat_map(|p| p.categories.iter().map(|c| (c.clone(), p.url.clone())))
385 .fold(std::collections::HashMap::new(), |mut acc, (cat, url)| {
386 acc.entry(cat).or_default().push(url);
387 acc
388 });
389
390 if !lang_categories.is_empty() {
391 let html = generator.generate_categories_index_page(&lang_categories, lang)?;
392 let output_path = if is_default {
393 self.output_dir.join("categories").join("index.html")
394 } else {
395 self.output_dir
396 .join(&lang_prefix)
397 .join("categories")
398 .join("index.html")
399 };
400 if let Some(parent) = output_path.parent() {
401 fs::create_dir_all(parent)?;
402 }
403 fs::write(&output_path, &html)?;
404 count += 1;
405 info!(path = %output_path.display(), lang = lang, "generated categories index page");
406 }
407
408 let mut lang_posts: Vec<_> = lang_pages
410 .iter()
411 .filter(|p| p.date.is_some())
412 .copied()
413 .collect();
414 lang_posts.sort_by(|a, b| b.date.cmp(&a.date));
415
416 if !lang_posts.is_empty() {
417 let html = generator.generate_archives_page(&lang_posts, lang)?;
418 let output_path = if is_default {
419 self.output_dir.join("archives").join("index.html")
420 } else {
421 self.output_dir
422 .join(&lang_prefix)
423 .join("archives")
424 .join("index.html")
425 };
426 if let Some(parent) = output_path.parent() {
427 fs::create_dir_all(parent)?;
428 }
429 fs::write(&output_path, &html)?;
430 count += 1;
431 info!(path = %output_path.display(), lang = lang, "generated archives page");
432 }
433
434 let mut sections: std::collections::HashMap<String, Vec<&Page>> =
437 std::collections::HashMap::new();
438 for page in lang_pages.iter().copied() {
439 let url = page.url.trim_start_matches('/');
441 let section = if is_default {
442 url.split('/').next().unwrap_or("")
443 } else {
444 url.split('/').nth(1).unwrap_or("")
446 };
447
448 if !section.is_empty() && section != "index.html" {
449 sections.entry(section.to_string()).or_default().push(page);
450 }
451 }
452
453 for (section, mut section_pages) in sections {
454 section_pages.sort_by(|a, b| match (&b.date, &a.date) {
456 (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
457 (Some(_), None) => std::cmp::Ordering::Less,
458 (None, Some(_)) => std::cmp::Ordering::Greater,
459 (None, None) => a.title.cmp(&b.title),
460 });
461
462 let per_page = self.config.taxonomies.tags.paginate;
464 let total_pages = section_pages.len().div_ceil(per_page).max(1);
465
466 for page_num in 1..=total_pages {
467 let (page_items, _) = paginate(§ion_pages, page_num, per_page);
468
469 let items_html: String = page_items.iter().map(|p| list_item_html(p)).collect();
470 let base_url = if is_default {
471 format!("/{section}")
472 } else {
473 format!("/{lang}/{section}")
474 };
475 let pagination = pagination_html(page_num, total_pages, &base_url);
476
477 let html = generator.generate_section_page(
478 §ion,
479 None, &items_html,
481 pagination.as_deref(),
482 lang,
483 )?;
484
485 let output_path = if page_num == 1 {
486 if is_default {
487 self.output_dir.join(§ion).join("index.html")
488 } else {
489 self.output_dir
490 .join(&lang_prefix)
491 .join(§ion)
492 .join("index.html")
493 }
494 } else if is_default {
495 self.output_dir
496 .join(§ion)
497 .join("page")
498 .join(page_num.to_string())
499 .join("index.html")
500 } else {
501 self.output_dir
502 .join(&lang_prefix)
503 .join(§ion)
504 .join("page")
505 .join(page_num.to_string())
506 .join("index.html")
507 };
508
509 if let Some(parent) = output_path.parent() {
510 fs::create_dir_all(parent)?;
511 }
512 fs::write(&output_path, &html)?;
513 count += 1;
514 }
515
516 info!(section = %section, lang = %lang, "generated section index page");
517 }
518 }
519
520 Ok(count)
521 }
522
523 fn generate_redirects(&self, content: &SiteContent) -> Result<usize> {
525 let generator = HtmlGenerator::new(self.config.clone());
526 let mut count = 0;
527
528 for page in content.pages.values() {
529 for alias in &page.aliases {
530 let redirect_url = format!("{}{}", self.config.site.base_url, page.url);
531 let html = generator.generate_redirect(&redirect_url)?;
532
533 let alias_path = alias.trim_matches('/');
534 let output_path = self.output_dir.join(alias_path).join("index.html");
535
536 if let Some(parent) = output_path.parent() {
537 fs::create_dir_all(parent)?;
538 }
539 fs::write(&output_path, &html)?;
540 count += 1;
541
542 debug!(alias = alias, target = %page.url, "generated redirect");
543 }
544 }
545
546 Ok(count)
547 }
548
549 fn generate_rss(&self, content: &SiteContent) -> Result<()> {
551 let generator = RssGenerator::new(self.config.clone());
552 let pages = ContentCollector::pages_by_date(content);
553
554 let posts: Vec<_> = pages.into_iter().filter(|p| p.date.is_some()).collect();
556
557 let xml = generator.generate(&posts)?;
559 let output_path = self.output_dir.join("rss.xml");
560 fs::write(&output_path, xml)?;
561 info!(path = %output_path.display(), "generated RSS feed");
562
563 let all_languages = self.config.all_languages();
565 let default_lang = &self.config.site.default_language;
566
567 for lang in &all_languages {
568 let lang_posts: Vec<_> = posts.iter().filter(|p| p.lang == *lang).copied().collect();
570
571 if lang_posts.is_empty() {
572 continue;
573 }
574
575 let lang_xml = generator.generate_for_lang(&lang_posts, lang)?;
577
578 let lang_output_path = if *lang == default_lang.as_str() {
580 self.output_dir.join(lang).join("rss.xml")
582 } else {
583 self.output_dir.join(lang).join("rss.xml")
584 };
585
586 if let Some(parent) = lang_output_path.parent() {
588 fs::create_dir_all(parent)?;
589 }
590
591 fs::write(&lang_output_path, lang_xml)?;
592 info!(path = %lang_output_path.display(), lang = lang, "generated language-specific RSS feed");
593 }
594
595 Ok(())
596 }
597
598 fn generate_sitemap(&self, content: &SiteContent) -> Result<()> {
600 let generator = SitemapGenerator::new(self.config.clone());
601 let pages: Vec<_> = content.pages.values().collect();
602
603 let xml = generator.generate(&pages)?;
604 let output_path = self.output_dir.join("sitemap.xml");
605 fs::write(&output_path, xml)?;
606 info!(path = %output_path.display(), "generated sitemap");
607
608 let xsl = crate::sitemap::generate_sitemap_xsl();
610 let xsl_path = self.output_dir.join("sitemap-style.xsl");
611 fs::write(&xsl_path, xsl)?;
612 info!(path = %xsl_path.display(), "generated sitemap stylesheet");
613
614 Ok(())
615 }
616
617 fn generate_robots(&self) -> Result<()> {
619 let generator = RobotsGenerator::new(self.config.clone());
620 generator.generate(&self.output_dir)?;
621 Ok(())
622 }
623
624 fn generate_search_indexes(&self, content: &SiteContent) -> Result<()> {
629 let all_languages = self.config.all_languages();
630 let default_lang = &self.config.site.default_language;
631
632 for lang in &all_languages {
633 let lang_pages: Vec<_> = content.pages.values().filter(|p| p.lang == *lang).collect();
635
636 if lang_pages.is_empty() {
637 continue;
638 }
639
640 let index = SimpleSearchIndex::from_pages(&lang_pages);
642
643 let output_path = if *lang == default_lang.as_str() {
645 self.output_dir.join("search-index.json")
646 } else {
647 self.output_dir.join(lang).join("search-index.json")
648 };
649
650 if let Some(parent) = output_path.parent() {
652 fs::create_dir_all(parent)?;
653 }
654
655 index
657 .write_to_file(&output_path)
658 .map_err(|e| BuildError::Config(e.to_string()))?;
659
660 info!(
661 path = %output_path.display(),
662 lang = lang,
663 documents = lang_pages.len(),
664 "generated search index"
665 );
666 }
667
668 Ok(())
669 }
670
671 fn process_assets(&self, static_dir: &Path) -> Result<AssetManifest> {
673 let processor = AssetProcessor::new(self.config.build.minify);
674 let manifest = processor.process(static_dir, &self.output_dir)?;
675
676 let manifest_path = self.output_dir.join("asset-manifest.json");
678 fs::write(&manifest_path, manifest.to_json())?;
679
680 Ok(manifest)
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use std::collections::HashMap;
687
688 use tempfile::TempDir;
689
690 use super::*;
691
692 fn test_config() -> Config {
693 Config {
694 site: typstify_core::config::SiteConfig {
695 title: "Test Site".to_string(),
696 base_url: "https://example.com".to_string(),
697 default_language: "en".to_string(),
698 description: None,
699 author: None,
700 },
701 languages: HashMap::new(),
702 build: typstify_core::config::BuildConfig::default(),
703 search: typstify_core::config::SearchConfig::default(),
704 rss: typstify_core::config::RssConfig {
705 enabled: true,
706 limit: 20,
707 },
708 robots: typstify_core::config::RobotsConfig::default(),
709 taxonomies: typstify_core::config::TaxonomyConfig::default(),
710 }
711 }
712
713 #[test]
714 fn test_build_empty_site() {
715 let content_dir = TempDir::new().unwrap();
716 let output_dir = TempDir::new().unwrap();
717
718 let builder = Builder::new(test_config(), content_dir.path(), output_dir.path());
719
720 let stats = builder.build().unwrap();
721
722 assert_eq!(stats.pages, 0);
723 assert!(output_dir.path().join("sitemap.xml").exists());
724 assert!(output_dir.path().join("rss.xml").exists());
725 }
726
727 #[test]
728 fn test_build_with_content() {
729 let content_dir = TempDir::new().unwrap();
730 let output_dir = TempDir::new().unwrap();
731
732 let post_path = content_dir.path().join("test-post.md");
734 fs::write(
735 &post_path,
736 r#"---
737title: "Test Post"
738date: 2026-01-14T00:00:00Z
739tags:
740 - rust
741 - web
742---
743
744Hello, world!
745"#,
746 )
747 .unwrap();
748
749 assert!(post_path.exists());
751
752 let builder = Builder::new(test_config(), content_dir.path(), output_dir.path());
753
754 let stats = builder.build().unwrap();
755
756 let html_path = output_dir.path().join("test-post/index.html");
758 let tags_rust = output_dir.path().join("tags/rust/index.html");
759 let tags_web = output_dir.path().join("tags/web/index.html");
760
761 if html_path.exists() {
763 eprintln!("HTML exists at {:?}", html_path);
764 } else {
765 eprintln!("HTML NOT found at {:?}", html_path);
766 if output_dir.path().exists() {
768 for entry in std::fs::read_dir(output_dir.path()).unwrap() {
769 eprintln!(" Output contains: {:?}", entry.unwrap().path());
770 }
771 }
772 }
773
774 assert_eq!(stats.pages, 1, "Expected 1 page, got {}", stats.pages);
775 assert!(html_path.exists(), "HTML file should exist");
776 assert!(tags_rust.exists(), "tags/rust should exist");
777 assert!(tags_web.exists(), "tags/web should exist");
778 }
779
780 #[test]
781 fn test_build_stats() {
782 let stats = BuildStats::default();
783 assert_eq!(stats.pages, 0);
784 assert_eq!(stats.duration_ms, 0);
785 }
786
787 #[test]
788 fn test_builder_with_static_dir() {
789 let content_dir = TempDir::new().unwrap();
790 let output_dir = TempDir::new().unwrap();
791 let static_dir = TempDir::new().unwrap();
792
793 fs::write(static_dir.path().join("style.css"), "body {}").unwrap();
795
796 let builder = Builder::new(test_config(), content_dir.path(), output_dir.path())
797 .with_static_dir(static_dir.path());
798
799 let stats = builder.build().unwrap();
800
801 assert_eq!(stats.assets, 1);
802 assert!(output_dir.path().join("style.css").exists());
803 }
804}