typstify_generator/
build.rs

1//! Build orchestration.
2//!
3//! Coordinates the full site build process.
4
5use 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/// Build errors.
25#[derive(Debug, Error)]
26pub enum BuildError {
27    /// IO error.
28    #[error("IO error: {0}")]
29    Io(#[from] std::io::Error),
30
31    /// Collector error.
32    #[error("collector error: {0}")]
33    Collector(#[from] CollectorError),
34
35    /// HTML generation error.
36    #[error("HTML error: {0}")]
37    Html(#[from] HtmlError),
38
39    /// RSS generation error.
40    #[error("RSS error: {0}")]
41    Rss(#[from] RssError),
42
43    /// Sitemap generation error.
44    #[error("sitemap error: {0}")]
45    Sitemap(#[from] SitemapError),
46
47    /// Asset error.
48    #[error("asset error: {0}")]
49    Asset(#[from] AssetError),
50
51    /// Configuration error.
52    #[error("config error: {0}")]
53    Config(String),
54}
55
56/// Result type for build operations.
57pub type Result<T> = std::result::Result<T, BuildError>;
58
59/// Build statistics.
60#[derive(Debug, Clone, Default)]
61pub struct BuildStats {
62    /// Number of pages generated.
63    pub pages: usize,
64
65    /// Number of taxonomy pages generated.
66    pub taxonomy_pages: usize,
67
68    /// Number of redirect pages generated.
69    pub redirects: usize,
70
71    /// Number of assets processed.
72    pub assets: usize,
73
74    /// Build duration in milliseconds.
75    pub duration_ms: u64,
76}
77
78/// Site builder that orchestrates the build process.
79#[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    /// Create a new builder.
89    #[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    /// Set the static assets directory.
104    #[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    /// Execute the full build process.
111    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        // 1. Clean output directory
122        self.clean_output()?;
123
124        // 2. Collect content
125        let collector = ContentCollector::new(self.config.clone(), &self.content_dir);
126        let content = collector.collect()?;
127
128        // 3. Generate HTML pages
129        stats.pages = self.generate_pages(&content)?;
130
131        // 4. Generate taxonomy pages
132        stats.taxonomy_pages = self.generate_taxonomy_pages(&content)?;
133
134        // 5. Generate redirects
135        stats.redirects = self.generate_redirects(&content)?;
136
137        // 6. Generate RSS feed
138        if self.config.rss.enabled {
139            self.generate_rss(&content)?;
140        }
141
142        // 7. Generate sitemap
143        self.generate_sitemap(&content)?;
144
145        // 8. Process assets
146        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    /// Clean the output directory.
166    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    /// Generate HTML pages for all content.
176    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        // Generate pages in parallel
183        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                // Write HTML file
190                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        // Check for errors
201        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    /// Generate taxonomy (tag/category) pages.
213    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        // Generate tag pages
219        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        // Generate category pages
226        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    /// Generate paginated pages for a taxonomy term.
242    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            // Determine output path
273            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    /// Generate redirect pages for URL aliases.
298    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    /// Generate RSS feed.
324    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        // Filter to only posts (pages with dates)
329        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    /// Generate sitemap.
340    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    /// Process static assets.
353    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        // Write manifest
358        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        // Create a test markdown file with proper frontmatter
411        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        // Verify file was created
428        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        // Check outputs
435        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        // Debug: print what exists
440        if html_path.exists() {
441            eprintln!("HTML exists at {:?}", html_path);
442        } else {
443            eprintln!("HTML NOT found at {:?}", html_path);
444            // List output dir contents
445            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        // Create a static file
472        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}