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, 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/// Build errors.
27#[derive(Debug, Error)]
28pub enum BuildError {
29    /// IO error.
30    #[error("IO error: {0}")]
31    Io(#[from] std::io::Error),
32
33    /// Collector error.
34    #[error("collector error: {0}")]
35    Collector(#[from] CollectorError),
36
37    /// HTML generation error.
38    #[error("HTML error: {0}")]
39    Html(#[from] HtmlError),
40
41    /// RSS generation error.
42    #[error("RSS error: {0}")]
43    Rss(#[from] RssError),
44
45    /// Sitemap generation error.
46    #[error("sitemap error: {0}")]
47    Sitemap(#[from] SitemapError),
48
49    /// Robots generation error.
50    #[error("robots error: {0}")]
51    Robots(#[from] RobotsError),
52
53    /// Asset error.
54    #[error("asset error: {0}")]
55    Asset(#[from] AssetError),
56
57    /// Configuration error.
58    #[error("config error: {0}")]
59    Config(String),
60}
61
62/// Result type for build operations.
63pub type Result<T> = std::result::Result<T, BuildError>;
64
65/// Build statistics.
66#[derive(Debug, Clone, Default)]
67pub struct BuildStats {
68    /// Number of pages generated.
69    pub pages: usize,
70
71    /// Number of taxonomy pages generated.
72    pub taxonomy_pages: usize,
73
74    /// Number of redirect pages generated.
75    pub redirects: usize,
76
77    /// Number of auto-generated index pages (archives, tags index, section indices).
78    pub auto_pages: usize,
79
80    /// Number of assets processed.
81    pub assets: usize,
82
83    /// Build duration in milliseconds.
84    pub duration_ms: u64,
85}
86
87/// Site builder that orchestrates the build process.
88#[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    /// Create a new builder.
98    #[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    /// Set the static assets directory.
113    #[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    /// Execute the full build process.
120    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        // 1. Clean output directory
131        self.clean_output()?;
132
133        // 2. Collect content
134        let collector = ContentCollector::new(self.config.clone(), &self.content_dir);
135        let content = collector.collect()?;
136
137        // 3. Generate HTML pages
138        stats.pages = self.generate_pages(&content)?;
139
140        // 4. Generate taxonomy pages
141        stats.taxonomy_pages = self.generate_taxonomy_pages(&content)?;
142
143        // 5. Generate auto-generated index pages (archives, tags index, section indices)
144        stats.auto_pages = self.generate_auto_pages(&content)?;
145
146        // 6. Generate redirects
147        stats.redirects = self.generate_redirects(&content)?;
148
149        // 7. Generate RSS feed
150        if self.config.rss.enabled {
151            self.generate_rss(&content)?;
152        }
153
154        // 8. Generate sitemap
155        self.generate_sitemap(&content)?;
156
157        // 9. Generate robots.txt
158        self.generate_robots()?;
159
160        // 10. Generate search index (per language)
161        if self.config.search.enabled {
162            self.generate_search_indexes(&content)?;
163        }
164
165        // 11. Generate static CSS/JS assets for better caching
166        crate::static_assets::generate_static_assets(&self.output_dir)
167            .map_err(|e| BuildError::Io(std::io::Error::other(e.to_string())))?;
168
169        // 12. Process user-provided assets
170        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    /// Clean the output directory.
191    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    /// Generate HTML pages for all content.
201    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        // Generate pages in parallel
208        let results: Vec<_> = pages
209            .par_iter()
210            .map(|page| {
211                // Collect alternate language versions
212                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                // Write HTML file
225                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        // Check for errors
236        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    /// Generate taxonomy (tag/category) pages.
248    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        // Generate tag pages
254        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        // Generate category pages
261        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    /// Generate paginated pages for a taxonomy term.
277    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            // Determine output path
308            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    /// Generate auto-generated index pages: archives, tags index, categories index, section indices.
333    /// Generates per-language versions when multiple languages are configured.
334    fn generate_auto_pages(&self, content: &SiteContent) -> Result<usize> {
335        let generator = HtmlGenerator::new(self.config.clone());
336        let mut count = 0;
337
338        // Get all languages
339        let all_languages = self.config.all_languages();
340        let default_lang = &self.config.site.default_language;
341
342        // Generate pages for each language
343        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            // Filter pages by language
352            let lang_pages: Vec<_> = content.pages.values().filter(|p| p.lang == *lang).collect();
353
354            // 1. Generate tags index page (/tags/ or /{lang}/tags/)
355            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            // 2. Generate categories index page (/categories/ or /{lang}/categories/)
382            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            // 3. Generate archives page (/archives/ or /{lang}/archives/)
409            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            // 4. Generate section index pages (e.g., /posts/, /{lang}/posts/)
435            // Group pages by section within this language
436            let mut sections: std::collections::HashMap<String, Vec<&Page>> =
437                std::collections::HashMap::new();
438            for page in lang_pages.iter().copied() {
439                // Extract section from URL (first path segment after lang prefix if any)
440                let url = page.url.trim_start_matches('/');
441                let section = if is_default {
442                    url.split('/').next().unwrap_or("")
443                } else {
444                    // For non-default lang, URL starts with /{lang}/section/...
445                    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                // Sort by date (newest first) or by title
455                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                // Generate paginated section index
463                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(&section_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                        &section,
479                        None, // description
480                        &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(&section).join("index.html")
488                        } else {
489                            self.output_dir
490                                .join(&lang_prefix)
491                                .join(&section)
492                                .join("index.html")
493                        }
494                    } else if is_default {
495                        self.output_dir
496                            .join(&section)
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(&section)
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    /// Generate redirect pages for URL aliases.
524    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    /// Generate RSS feed.
550    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        // Filter to only posts (pages with dates)
555        let posts: Vec<_> = pages.into_iter().filter(|p| p.date.is_some()).collect();
556
557        // Generate main RSS feed with all languages
558        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        // Generate language-specific RSS feeds
564        let all_languages = self.config.all_languages();
565        let default_lang = &self.config.site.default_language;
566
567        for lang in &all_languages {
568            // Filter posts by language
569            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            // Generate language-specific feed
576            let lang_xml = generator.generate_for_lang(&lang_posts, lang)?;
577
578            // Determine output path
579            let lang_output_path = if *lang == default_lang.as_str() {
580                // For default language, still put at root but also in lang folder
581                self.output_dir.join(lang).join("rss.xml")
582            } else {
583                self.output_dir.join(lang).join("rss.xml")
584            };
585
586            // Create parent directories if needed
587            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    /// Generate sitemap.
599    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        // Generate XSLT stylesheet for sitemap
609        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    /// Generate robots.txt.
618    fn generate_robots(&self) -> Result<()> {
619        let generator = RobotsGenerator::new(self.config.clone());
620        generator.generate(&self.output_dir)?;
621        Ok(())
622    }
623
624    /// Generate search indexes per language.
625    ///
626    /// Creates a `search-index.json` for default language at root,
627    /// and `/{lang}/search-index.json` for non-default languages.
628    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            // Filter pages by language
634            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            // Build simple search index
641            let index = SimpleSearchIndex::from_pages(&lang_pages);
642
643            // Determine output path
644            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            // Create parent directories if needed
651            if let Some(parent) = output_path.parent() {
652                fs::create_dir_all(parent)?;
653            }
654
655            // Write the index
656            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    /// Process static assets.
672    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        // Write manifest
677        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        // Create a test markdown file with proper frontmatter
733        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        // Verify file was created
750        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        // Check outputs
757        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        // Debug: print what exists
762        if html_path.exists() {
763            eprintln!("HTML exists at {:?}", html_path);
764        } else {
765            eprintln!("HTML NOT found at {:?}", html_path);
766            // List output dir contents
767            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        // Create a static file
794        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}