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;
15
16use crate::{
17 assets::{AssetError, AssetManifest, AssetProcessor},
18 collector::{CollectorError, ContentCollector, SiteContent},
19 html::{HtmlError, HtmlGenerator, list_item_html, pagination_html},
20 rss::{RssError, RssGenerator},
21 sitemap::{SitemapError, SitemapGenerator},
22};
23
24#[derive(Debug, Error)]
26pub enum BuildError {
27 #[error("IO error: {0}")]
29 Io(#[from] std::io::Error),
30
31 #[error("collector error: {0}")]
33 Collector(#[from] CollectorError),
34
35 #[error("HTML error: {0}")]
37 Html(#[from] HtmlError),
38
39 #[error("RSS error: {0}")]
41 Rss(#[from] RssError),
42
43 #[error("sitemap error: {0}")]
45 Sitemap(#[from] SitemapError),
46
47 #[error("asset error: {0}")]
49 Asset(#[from] AssetError),
50
51 #[error("config error: {0}")]
53 Config(String),
54}
55
56pub type Result<T> = std::result::Result<T, BuildError>;
58
59#[derive(Debug, Clone, Default)]
61pub struct BuildStats {
62 pub pages: usize,
64
65 pub taxonomy_pages: usize,
67
68 pub redirects: usize,
70
71 pub assets: usize,
73
74 pub duration_ms: u64,
76}
77
78#[derive(Debug)]
80pub struct Builder {
81 config: Config,
82 content_dir: PathBuf,
83 output_dir: PathBuf,
84 static_dir: Option<PathBuf>,
85}
86
87impl Builder {
88 #[must_use]
90 pub fn new(
91 config: Config,
92 content_dir: impl Into<PathBuf>,
93 output_dir: impl Into<PathBuf>,
94 ) -> Self {
95 Self {
96 config,
97 content_dir: content_dir.into(),
98 output_dir: output_dir.into(),
99 static_dir: None,
100 }
101 }
102
103 #[must_use]
105 pub fn with_static_dir(mut self, dir: impl Into<PathBuf>) -> Self {
106 self.static_dir = Some(dir.into());
107 self
108 }
109
110 pub fn build(&self) -> Result<BuildStats> {
112 let start = Instant::now();
113 let mut stats = BuildStats::default();
114
115 info!(
116 content = %self.content_dir.display(),
117 output = %self.output_dir.display(),
118 "starting build"
119 );
120
121 self.clean_output()?;
123
124 let collector = ContentCollector::new(self.config.clone(), &self.content_dir);
126 let content = collector.collect()?;
127
128 stats.pages = self.generate_pages(&content)?;
130
131 stats.taxonomy_pages = self.generate_taxonomy_pages(&content)?;
133
134 stats.redirects = self.generate_redirects(&content)?;
136
137 if self.config.rss.enabled {
139 self.generate_rss(&content)?;
140 }
141
142 self.generate_sitemap(&content)?;
144
145 if let Some(ref static_dir) = self.static_dir {
147 let manifest = self.process_assets(static_dir)?;
148 stats.assets = manifest.assets().len();
149 }
150
151 stats.duration_ms = start.elapsed().as_millis() as u64;
152
153 info!(
154 pages = stats.pages,
155 taxonomy_pages = stats.taxonomy_pages,
156 redirects = stats.redirects,
157 assets = stats.assets,
158 duration_ms = stats.duration_ms,
159 "build complete"
160 );
161
162 Ok(stats)
163 }
164
165 fn clean_output(&self) -> Result<()> {
167 if self.output_dir.exists() {
168 debug!(dir = %self.output_dir.display(), "cleaning output directory");
169 fs::remove_dir_all(&self.output_dir)?;
170 }
171 fs::create_dir_all(&self.output_dir)?;
172 Ok(())
173 }
174
175 fn generate_pages(&self, content: &SiteContent) -> Result<usize> {
177 let generator = HtmlGenerator::new(self.config.clone());
178 let pages: Vec<_> = content.pages.values().collect();
179
180 info!(count = pages.len(), "generating HTML pages");
181
182 let results: Vec<_> = pages
184 .par_iter()
185 .map(|page| {
186 let html = generator.generate_page(page)?;
187 let output_path = generator.output_path(page, &self.output_dir);
188
189 if let Some(parent) = output_path.parent() {
191 fs::create_dir_all(parent)?;
192 }
193 fs::write(&output_path, &html)?;
194
195 debug!(path = %output_path.display(), "wrote page");
196 Ok::<_, BuildError>(())
197 })
198 .collect();
199
200 let mut count = 0;
202 for result in results {
203 match result {
204 Ok(()) => count += 1,
205 Err(e) => warn!(error = %e, "failed to generate page"),
206 }
207 }
208
209 Ok(count)
210 }
211
212 fn generate_taxonomy_pages(&self, content: &SiteContent) -> Result<usize> {
214 let generator = HtmlGenerator::new(self.config.clone());
215 let per_page = self.config.taxonomies.tags.paginate;
216 let mut count = 0;
217
218 for (tag, slugs) in &content.taxonomies.tags {
220 let pages: Vec<_> = slugs.iter().filter_map(|s| content.pages.get(s)).collect();
221 count += self
222 .generate_taxonomy_term_pages(&generator, "Tags", tag, &pages, per_page, "tags")?;
223 }
224
225 for (category, slugs) in &content.taxonomies.categories {
227 let pages: Vec<_> = slugs.iter().filter_map(|s| content.pages.get(s)).collect();
228 count += self.generate_taxonomy_term_pages(
229 &generator,
230 "Categories",
231 category,
232 &pages,
233 per_page,
234 "categories",
235 )?;
236 }
237
238 Ok(count)
239 }
240
241 fn generate_taxonomy_term_pages(
243 &self,
244 generator: &HtmlGenerator,
245 taxonomy_name: &str,
246 term: &str,
247 pages: &[&typstify_core::Page],
248 per_page: usize,
249 url_prefix: &str,
250 ) -> Result<usize> {
251 use crate::collector::paginate;
252
253 let term_slug = term.to_lowercase().replace(' ', "-");
254 let base_url = format!("/{url_prefix}/{term_slug}");
255 let total_pages = (pages.len() + per_page - 1).max(1) / per_page.max(1);
256 let mut count = 0;
257
258 for page_num in 1..=total_pages.max(1) {
259 let (page_items, _) = paginate(pages, page_num, per_page);
260
261 let items_html: String = page_items.iter().map(|p| list_item_html(p)).collect();
262
263 let pagination = pagination_html(page_num, total_pages, &base_url);
264
265 let html = generator.generate_taxonomy_page(
266 taxonomy_name,
267 term,
268 &items_html,
269 pagination.as_deref(),
270 )?;
271
272 let output_path = if page_num == 1 {
274 self.output_dir
275 .join(url_prefix)
276 .join(&term_slug)
277 .join("index.html")
278 } else {
279 self.output_dir
280 .join(url_prefix)
281 .join(&term_slug)
282 .join("page")
283 .join(page_num.to_string())
284 .join("index.html")
285 };
286
287 if let Some(parent) = output_path.parent() {
288 fs::create_dir_all(parent)?;
289 }
290 fs::write(&output_path, &html)?;
291 count += 1;
292 }
293
294 Ok(count)
295 }
296
297 fn generate_redirects(&self, content: &SiteContent) -> Result<usize> {
299 let generator = HtmlGenerator::new(self.config.clone());
300 let mut count = 0;
301
302 for page in content.pages.values() {
303 for alias in &page.aliases {
304 let redirect_url = format!("{}{}", self.config.site.base_url, page.url);
305 let html = generator.generate_redirect(&redirect_url)?;
306
307 let alias_path = alias.trim_matches('/');
308 let output_path = self.output_dir.join(alias_path).join("index.html");
309
310 if let Some(parent) = output_path.parent() {
311 fs::create_dir_all(parent)?;
312 }
313 fs::write(&output_path, &html)?;
314 count += 1;
315
316 debug!(alias = alias, target = %page.url, "generated redirect");
317 }
318 }
319
320 Ok(count)
321 }
322
323 fn generate_rss(&self, content: &SiteContent) -> Result<()> {
325 let generator = RssGenerator::new(self.config.clone());
326 let pages = ContentCollector::pages_by_date(content);
327
328 let posts: Vec<_> = pages.into_iter().filter(|p| p.date.is_some()).collect();
330
331 let xml = generator.generate(&posts)?;
332 let output_path = self.output_dir.join("rss.xml");
333 fs::write(&output_path, xml)?;
334
335 info!(path = %output_path.display(), "generated RSS feed");
336 Ok(())
337 }
338
339 fn generate_sitemap(&self, content: &SiteContent) -> Result<()> {
341 let generator = SitemapGenerator::new(self.config.clone());
342 let pages: Vec<_> = content.pages.values().collect();
343
344 let xml = generator.generate(&pages)?;
345 let output_path = self.output_dir.join("sitemap.xml");
346 fs::write(&output_path, xml)?;
347
348 info!(path = %output_path.display(), "generated sitemap");
349 Ok(())
350 }
351
352 fn process_assets(&self, static_dir: &Path) -> Result<AssetManifest> {
354 let processor = AssetProcessor::new(self.config.build.minify);
355 let manifest = processor.process(static_dir, &self.output_dir)?;
356
357 let manifest_path = self.output_dir.join("asset-manifest.json");
359 fs::write(&manifest_path, manifest.to_json())?;
360
361 Ok(manifest)
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use tempfile::TempDir;
368
369 use super::*;
370
371 fn test_config() -> Config {
372 Config {
373 site: typstify_core::config::SiteConfig {
374 title: "Test Site".to_string(),
375 base_url: "https://example.com".to_string(),
376 default_language: "en".to_string(),
377 languages: vec!["en".to_string()],
378 description: None,
379 author: None,
380 },
381 build: typstify_core::config::BuildConfig::default(),
382 search: typstify_core::config::SearchConfig::default(),
383 rss: typstify_core::config::RssConfig {
384 enabled: true,
385 limit: 20,
386 },
387 taxonomies: typstify_core::config::TaxonomyConfig::default(),
388 }
389 }
390
391 #[test]
392 fn test_build_empty_site() {
393 let content_dir = TempDir::new().unwrap();
394 let output_dir = TempDir::new().unwrap();
395
396 let builder = Builder::new(test_config(), content_dir.path(), output_dir.path());
397
398 let stats = builder.build().unwrap();
399
400 assert_eq!(stats.pages, 0);
401 assert!(output_dir.path().join("sitemap.xml").exists());
402 assert!(output_dir.path().join("rss.xml").exists());
403 }
404
405 #[test]
406 fn test_build_with_content() {
407 let content_dir = TempDir::new().unwrap();
408 let output_dir = TempDir::new().unwrap();
409
410 let post_path = content_dir.path().join("test-post.md");
412 fs::write(
413 &post_path,
414 r#"---
415title: "Test Post"
416date: 2026-01-14T00:00:00Z
417tags:
418 - rust
419 - web
420---
421
422Hello, world!
423"#,
424 )
425 .unwrap();
426
427 assert!(post_path.exists());
429
430 let builder = Builder::new(test_config(), content_dir.path(), output_dir.path());
431
432 let stats = builder.build().unwrap();
433
434 let html_path = output_dir.path().join("test-post/index.html");
436 let tags_rust = output_dir.path().join("tags/rust/index.html");
437 let tags_web = output_dir.path().join("tags/web/index.html");
438
439 if html_path.exists() {
441 eprintln!("HTML exists at {:?}", html_path);
442 } else {
443 eprintln!("HTML NOT found at {:?}", html_path);
444 if output_dir.path().exists() {
446 for entry in std::fs::read_dir(output_dir.path()).unwrap() {
447 eprintln!(" Output contains: {:?}", entry.unwrap().path());
448 }
449 }
450 }
451
452 assert_eq!(stats.pages, 1, "Expected 1 page, got {}", stats.pages);
453 assert!(html_path.exists(), "HTML file should exist");
454 assert!(tags_rust.exists(), "tags/rust should exist");
455 assert!(tags_web.exists(), "tags/web should exist");
456 }
457
458 #[test]
459 fn test_build_stats() {
460 let stats = BuildStats::default();
461 assert_eq!(stats.pages, 0);
462 assert_eq!(stats.duration_ms, 0);
463 }
464
465 #[test]
466 fn test_builder_with_static_dir() {
467 let content_dir = TempDir::new().unwrap();
468 let output_dir = TempDir::new().unwrap();
469 let static_dir = TempDir::new().unwrap();
470
471 fs::write(static_dir.path().join("style.css"), "body {}").unwrap();
473
474 let builder = Builder::new(test_config(), content_dir.path(), output_dir.path())
475 .with_static_dir(static_dir.path());
476
477 let stats = builder.build().unwrap();
478
479 assert_eq!(stats.assets, 1);
480 assert!(output_dir.path().join("style.css").exists());
481 }
482}