Skip to main content

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::{
21        HtmlError, HtmlGenerator, list_item_html, pagination_html, shorts_with_separators_html,
22    },
23    robots::{RobotsError, RobotsGenerator},
24    rss::{RssError, RssGenerator},
25    sitemap::{SitemapError, SitemapGenerator},
26};
27
28/// Build errors.
29#[derive(Debug, Error)]
30pub enum BuildError {
31    /// IO error.
32    #[error("IO error: {0}")]
33    Io(#[from] std::io::Error),
34
35    /// Collector error.
36    #[error("collector error: {0}")]
37    Collector(#[from] CollectorError),
38
39    /// HTML generation error.
40    #[error("HTML error: {0}")]
41    Html(#[from] HtmlError),
42
43    /// RSS generation error.
44    #[error("RSS error: {0}")]
45    Rss(#[from] RssError),
46
47    /// Sitemap generation error.
48    #[error("sitemap error: {0}")]
49    Sitemap(#[from] SitemapError),
50
51    /// Robots generation error.
52    #[error("robots error: {0}")]
53    Robots(#[from] RobotsError),
54
55    /// Asset error.
56    #[error("asset error: {0}")]
57    Asset(#[from] AssetError),
58
59    /// Configuration error.
60    #[error("config error: {0}")]
61    Config(String),
62
63    /// Page generation failed.
64    #[error("page generation failed: {} errors", errors.len())]
65    PageGenerationFailed { errors: Vec<String> },
66}
67
68/// Result type for build operations.
69pub type Result<T> = std::result::Result<T, BuildError>;
70
71/// Build statistics.
72#[derive(Debug, Clone, Default)]
73pub struct BuildStats {
74    /// Number of pages generated.
75    pub pages: usize,
76
77    /// Number of taxonomy pages generated.
78    pub taxonomy_pages: usize,
79
80    /// Number of redirect pages generated.
81    pub redirects: usize,
82
83    /// Number of auto-generated index pages (archives, tags index, section indices).
84    pub auto_pages: usize,
85
86    /// Number of assets processed.
87    pub assets: usize,
88
89    /// Build duration in milliseconds.
90    pub duration_ms: u64,
91}
92
93/// Site builder that orchestrates the build process.
94#[derive(Debug)]
95pub struct Builder {
96    config: Config,
97    content_dir: PathBuf,
98    output_dir: PathBuf,
99    static_dir: Option<PathBuf>,
100}
101
102impl Builder {
103    /// Create a new builder.
104    #[must_use]
105    pub fn new(
106        config: Config,
107        content_dir: impl Into<PathBuf>,
108        output_dir: impl Into<PathBuf>,
109    ) -> Self {
110        Self {
111            config,
112            content_dir: content_dir.into(),
113            output_dir: output_dir.into(),
114            static_dir: None,
115        }
116    }
117
118    /// Set the static assets directory.
119    #[must_use]
120    pub fn with_static_dir(mut self, dir: impl Into<PathBuf>) -> Self {
121        self.static_dir = Some(dir.into());
122        self
123    }
124
125    /// Execute the full build process.
126    pub fn build(&self) -> Result<BuildStats> {
127        let start = Instant::now();
128        let mut stats = BuildStats::default();
129
130        info!(
131            content = %self.content_dir.display(),
132            output = %self.output_dir.display(),
133            "starting build"
134        );
135
136        // 1. Clean output directory
137        self.clean_output()?;
138
139        // 2. Collect content
140        let collector = ContentCollector::new(&self.config, &self.content_dir);
141        let content = collector.collect()?;
142
143        // 3. Extract sections for dynamic navigation
144        let sections: Vec<String> = content.sections.keys().cloned().collect();
145
146        // 4. Generate HTML pages
147        stats.pages = self.generate_pages(&content, &sections)?;
148
149        // 5. Generate taxonomy pages
150        stats.taxonomy_pages = self.generate_taxonomy_pages(&content, &sections)?;
151
152        // 6. Generate auto-generated index pages (archives, tags index, section indices)
153        stats.auto_pages = self.generate_auto_pages(&content, &sections)?;
154
155        // 6. Generate redirects
156        stats.redirects = self.generate_redirects(&content)?;
157
158        // 7. Generate RSS feed
159        if self.config.rss.enabled {
160            self.generate_rss(&content)?;
161        }
162
163        // 8. Generate sitemap
164        self.generate_sitemap(&content)?;
165
166        // 9. Generate robots.txt
167        self.generate_robots()?;
168
169        // 10. Generate search index (per language)
170        if self.config.search.enabled {
171            self.generate_search_indexes(&content)?;
172        }
173
174        // 11. Generate static CSS/JS assets for better caching
175        crate::static_assets::generate_static_assets(&self.output_dir)
176            .map_err(|e| BuildError::Io(std::io::Error::other(e.to_string())))?;
177
178        // 12. Process user-provided assets
179        if let Some(ref static_dir) = self.static_dir {
180            let manifest = self.process_assets(static_dir)?;
181            stats.assets = manifest.assets().len();
182        }
183
184        stats.duration_ms = start.elapsed().as_millis() as u64;
185
186        info!(
187            pages = stats.pages,
188            taxonomy_pages = stats.taxonomy_pages,
189            auto_pages = stats.auto_pages,
190            redirects = stats.redirects,
191            assets = stats.assets,
192            duration_ms = stats.duration_ms,
193            "build complete"
194        );
195
196        Ok(stats)
197    }
198
199    /// Clean the output directory.
200    fn clean_output(&self) -> Result<()> {
201        if self.output_dir.exists() {
202            debug!(dir = %self.output_dir.display(), "cleaning output directory");
203            fs::remove_dir_all(&self.output_dir)?;
204        }
205        fs::create_dir_all(&self.output_dir)?;
206        Ok(())
207    }
208
209    /// Generate HTML pages for all content.
210    fn generate_pages(&self, content: &SiteContent, sections: &[String]) -> Result<usize> {
211        let generator = HtmlGenerator::new(&self.config).with_sections(sections.to_vec());
212        let pages: Vec<_> = content.pages.values().collect();
213
214        info!(count = pages.len(), "generating HTML pages");
215
216        // Generate pages in parallel
217        let results: Vec<_> = pages
218            .par_iter()
219            .map(|page| {
220                // Collect alternate language versions
221                let mut alternates = Vec::new();
222                if let Some(slugs) = content.translations.get(&page.canonical_id) {
223                    for slug in slugs {
224                        if let Some(alt_page) = content.pages.get(slug) {
225                            alternates.push((alt_page.lang.as_str(), alt_page.url.as_str()));
226                        }
227                    }
228                }
229
230                let html = generator.generate_page(page, &alternates)?;
231                let output_path = generator.output_path(page, &self.output_dir);
232
233                // Write HTML file
234                if let Some(parent) = output_path.parent() {
235                    fs::create_dir_all(parent)?;
236                }
237                fs::write(&output_path, &html)?;
238
239                debug!(path = %output_path.display(), "wrote page");
240                Ok::<_, BuildError>(())
241            })
242            .collect();
243
244        // Check for errors
245        let mut count = 0;
246        let mut errors = Vec::new();
247        for result in results {
248            match result {
249                Ok(()) => count += 1,
250                Err(e) => {
251                    warn!(error = %e, "failed to generate page");
252                    errors.push(e.to_string());
253                }
254            }
255        }
256
257        if !errors.is_empty() {
258            return Err(BuildError::PageGenerationFailed { errors });
259        }
260
261        Ok(count)
262    }
263
264    /// Generate taxonomy (tag/category) pages.
265    fn generate_taxonomy_pages(&self, content: &SiteContent, sections: &[String]) -> Result<usize> {
266        let generator = HtmlGenerator::new(&self.config).with_sections(sections.to_vec());
267        let per_page = self.config.taxonomies.tags.paginate;
268        let mut count = 0;
269
270        // Generate tag pages
271        for (tag, slugs) in &content.taxonomies.tags {
272            let pages: Vec<_> = slugs.iter().filter_map(|s| content.pages.get(s)).collect();
273            count += self
274                .generate_taxonomy_term_pages(&generator, "Tags", tag, &pages, per_page, "tags")?;
275        }
276
277        // Generate category pages
278        for (category, slugs) in &content.taxonomies.categories {
279            let pages: Vec<_> = slugs.iter().filter_map(|s| content.pages.get(s)).collect();
280            count += self.generate_taxonomy_term_pages(
281                &generator,
282                "Categories",
283                category,
284                &pages,
285                per_page,
286                "categories",
287            )?;
288        }
289
290        Ok(count)
291    }
292
293    /// Generate paginated pages for a taxonomy term.
294    fn generate_taxonomy_term_pages(
295        &self,
296        generator: &HtmlGenerator,
297        taxonomy_name: &str,
298        term: &str,
299        pages: &[&typstify_core::Page],
300        per_page: usize,
301        url_prefix: &str,
302    ) -> Result<usize> {
303        use crate::collector::paginate;
304
305        let term_slug = term.to_lowercase().replace(' ', "-");
306        let base_url = format!("/{url_prefix}/{term_slug}");
307        let total_pages = (pages.len() + per_page - 1).max(1) / per_page.max(1);
308        let mut count = 0;
309
310        for page_num in 1..=total_pages.max(1) {
311            let (page_items, _) = paginate(pages, page_num, per_page);
312
313            let items_html: String = page_items.iter().map(|p| list_item_html(p)).collect();
314
315            let pagination = pagination_html(page_num, total_pages, &base_url);
316
317            let html = generator.generate_taxonomy_page(
318                taxonomy_name,
319                term,
320                &items_html,
321                pagination.as_deref(),
322            )?;
323
324            // Determine output path
325            let output_path = if page_num == 1 {
326                self.output_dir
327                    .join(url_prefix)
328                    .join(&term_slug)
329                    .join("index.html")
330            } else {
331                self.output_dir
332                    .join(url_prefix)
333                    .join(&term_slug)
334                    .join("page")
335                    .join(page_num.to_string())
336                    .join("index.html")
337            };
338
339            if let Some(parent) = output_path.parent() {
340                fs::create_dir_all(parent)?;
341            }
342            fs::write(&output_path, &html)?;
343            count += 1;
344        }
345
346        Ok(count)
347    }
348
349    /// Generate auto-generated index pages: archives, tags index, categories index, section indices.
350    /// Generates per-language versions when multiple languages are configured.
351    fn generate_auto_pages(&self, content: &SiteContent, sections: &[String]) -> Result<usize> {
352        let generator = HtmlGenerator::new(&self.config).with_sections(sections.to_vec());
353        let mut count = 0;
354
355        // Get all languages
356        let all_languages = self.config.all_languages();
357        let default_lang = &self.config.site.default_language;
358
359        // Generate pages for each language
360        for lang in &all_languages {
361            let is_default = *lang == default_lang.as_str();
362            let lang_prefix = if is_default {
363                String::new()
364            } else {
365                lang.to_string()
366            };
367
368            // Filter pages by language
369            let lang_pages: Vec<_> = content.pages.values().filter(|p| p.lang == *lang).collect();
370
371            // 1. Generate tags index page (/tags/ or /{lang}/tags/)
372            let lang_tags: std::collections::HashMap<String, Vec<String>> = lang_pages
373                .iter()
374                .flat_map(|p| p.tags.iter().map(|t| (t.clone(), p.url.clone())))
375                .fold(std::collections::HashMap::new(), |mut acc, (tag, url)| {
376                    acc.entry(tag).or_default().push(url);
377                    acc
378                });
379
380            if !lang_tags.is_empty() {
381                let html = generator.generate_tags_index_page(&lang_tags, lang)?;
382                let output_path = if is_default {
383                    self.output_dir.join("tags").join("index.html")
384                } else {
385                    self.output_dir
386                        .join(&lang_prefix)
387                        .join("tags")
388                        .join("index.html")
389                };
390                if let Some(parent) = output_path.parent() {
391                    fs::create_dir_all(parent)?;
392                }
393                fs::write(&output_path, &html)?;
394                count += 1;
395                info!(path = %output_path.display(), lang = lang, "generated tags index page");
396            }
397
398            // 2. Generate categories index page (/categories/ or /{lang}/categories/)
399            let lang_categories: std::collections::HashMap<String, Vec<String>> = lang_pages
400                .iter()
401                .flat_map(|p| p.categories.iter().map(|c| (c.clone(), p.url.clone())))
402                .fold(std::collections::HashMap::new(), |mut acc, (cat, url)| {
403                    acc.entry(cat).or_default().push(url);
404                    acc
405                });
406
407            if !lang_categories.is_empty() {
408                let html = generator.generate_categories_index_page(&lang_categories, lang)?;
409                let output_path = if is_default {
410                    self.output_dir.join("categories").join("index.html")
411                } else {
412                    self.output_dir
413                        .join(&lang_prefix)
414                        .join("categories")
415                        .join("index.html")
416                };
417                if let Some(parent) = output_path.parent() {
418                    fs::create_dir_all(parent)?;
419                }
420                fs::write(&output_path, &html)?;
421                count += 1;
422                info!(path = %output_path.display(), lang = lang, "generated categories index page");
423            }
424
425            // 3. Generate archives page (/archives/ or /{lang}/archives/)
426            let mut lang_posts: Vec<_> = lang_pages
427                .iter()
428                .filter(|p| p.date.is_some())
429                .copied()
430                .collect();
431            lang_posts.sort_by_key(|b| std::cmp::Reverse(b.date));
432
433            if !lang_posts.is_empty() {
434                let html = generator.generate_archives_page(&lang_posts, lang)?;
435                let output_path = if is_default {
436                    self.output_dir.join("archives").join("index.html")
437                } else {
438                    self.output_dir
439                        .join(&lang_prefix)
440                        .join("archives")
441                        .join("index.html")
442                };
443                if let Some(parent) = output_path.parent() {
444                    fs::create_dir_all(parent)?;
445                }
446                fs::write(&output_path, &html)?;
447                count += 1;
448                info!(path = %output_path.display(), lang = lang, "generated archives page");
449            }
450
451            // 4. Generate section index pages (e.g., /posts/, /{lang}/posts/)
452            // Group pages by section within this language
453            let mut sections: std::collections::HashMap<String, Vec<&Page>> =
454                std::collections::HashMap::new();
455            for page in lang_pages.iter().copied() {
456                // Extract section from URL (first path segment after lang prefix if any)
457                let url = page.url.trim_start_matches('/');
458                let section = if is_default {
459                    url.split('/').next().unwrap_or("")
460                } else {
461                    // For non-default lang, URL starts with /{lang}/section/...
462                    url.split('/').nth(1).unwrap_or("")
463                };
464
465                if !section.is_empty() && section != "index.html" {
466                    sections.entry(section.to_string()).or_default().push(page);
467                }
468            }
469
470            for (section, mut section_pages) in sections {
471                // Sort by date (newest first) or by title
472                section_pages.sort_by(|a, b| match (&b.date, &a.date) {
473                    (Some(b_date), Some(a_date)) => b_date.cmp(a_date),
474                    (Some(_), None) => std::cmp::Ordering::Less,
475                    (None, Some(_)) => std::cmp::Ordering::Greater,
476                    (None, None) => a.title.cmp(&b.title),
477                });
478
479                // Generate paginated section index
480                let per_page = self.config.taxonomies.tags.paginate;
481                let total_pages = section_pages.len().div_ceil(per_page).max(1);
482
483                // Use shorts-specific template for shorts section
484                let is_shorts = section == "shorts";
485                let author = self.config.site.author.as_deref().unwrap_or("Author");
486
487                for page_num in 1..=total_pages {
488                    let (page_items, _) = paginate(&section_pages, page_num, per_page);
489
490                    // Use appropriate item html based on section type
491                    let items_html: String = if is_shorts {
492                        shorts_with_separators_html(page_items, author)
493                    } else {
494                        page_items.iter().map(|p| list_item_html(p)).collect()
495                    };
496
497                    let base_url = if is_default {
498                        format!("/{section}")
499                    } else {
500                        format!("/{lang}/{section}")
501                    };
502                    let pagination = pagination_html(page_num, total_pages, &base_url);
503
504                    // Use shorts template for shorts section
505                    let html = if is_shorts {
506                        generator.generate_shorts_page(
507                            &section,
508                            None, // description
509                            &items_html,
510                            pagination.as_deref(),
511                            lang,
512                        )?
513                    } else {
514                        generator.generate_section_page(
515                            &section,
516                            None, // description
517                            &items_html,
518                            pagination.as_deref(),
519                            lang,
520                        )?
521                    };
522
523                    let output_path = if page_num == 1 {
524                        if is_default {
525                            self.output_dir.join(&section).join("index.html")
526                        } else {
527                            self.output_dir
528                                .join(&lang_prefix)
529                                .join(&section)
530                                .join("index.html")
531                        }
532                    } else if is_default {
533                        self.output_dir
534                            .join(&section)
535                            .join("page")
536                            .join(page_num.to_string())
537                            .join("index.html")
538                    } else {
539                        self.output_dir
540                            .join(&lang_prefix)
541                            .join(&section)
542                            .join("page")
543                            .join(page_num.to_string())
544                            .join("index.html")
545                    };
546
547                    if let Some(parent) = output_path.parent() {
548                        fs::create_dir_all(parent)?;
549                    }
550                    fs::write(&output_path, &html)?;
551                    count += 1;
552                }
553
554                info!(section = %section, lang = %lang, "generated section index page");
555            }
556        }
557
558        Ok(count)
559    }
560
561    /// Generate redirect pages for URL aliases.
562    fn generate_redirects(&self, content: &SiteContent) -> Result<usize> {
563        let generator = HtmlGenerator::new(&self.config);
564        let mut count = 0;
565
566        for page in content.pages.values() {
567            for alias in &page.aliases {
568                let redirect_url = format!("{}{}", self.config.base_url(), page.url);
569                let html = generator.generate_redirect(&redirect_url)?;
570
571                let alias_path = alias.trim_matches('/');
572                let output_path = self.output_dir.join(alias_path).join("index.html");
573
574                if let Some(parent) = output_path.parent() {
575                    fs::create_dir_all(parent)?;
576                }
577                fs::write(&output_path, &html)?;
578                count += 1;
579
580                debug!(alias = alias, target = %page.url, "generated redirect");
581            }
582        }
583
584        Ok(count)
585    }
586
587    /// Generate RSS feed.
588    fn generate_rss(&self, content: &SiteContent) -> Result<()> {
589        let generator = RssGenerator::new(&self.config);
590        let pages = ContentCollector::pages_by_date(content);
591
592        // Filter to only posts (pages with dates)
593        let posts: Vec<_> = pages.into_iter().filter(|p| p.date.is_some()).collect();
594
595        // Generate main RSS feed with all languages
596        let xml = generator.generate(&posts)?;
597        let output_path = self.output_dir.join("rss.xml");
598        fs::write(&output_path, xml)?;
599        info!(path = %output_path.display(), "generated RSS feed");
600
601        // Generate language-specific RSS feeds
602        let all_languages = self.config.all_languages();
603        let default_lang = &self.config.site.default_language;
604
605        for lang in &all_languages {
606            // Filter posts by language
607            let lang_posts: Vec<_> = posts.iter().filter(|p| p.lang == *lang).copied().collect();
608
609            if lang_posts.is_empty() {
610                continue;
611            }
612
613            // Generate language-specific feed
614            let lang_xml = generator.generate_for_lang(&lang_posts, lang)?;
615
616            // Determine output path
617            let lang_output_path = if *lang == default_lang.as_str() {
618                self.output_dir.join("rss.xml")
619            } else {
620                self.output_dir.join(lang).join("rss.xml")
621            };
622
623            // Create parent directories if needed
624            if let Some(parent) = lang_output_path.parent() {
625                fs::create_dir_all(parent)?;
626            }
627
628            fs::write(&lang_output_path, lang_xml)?;
629            info!(path = %lang_output_path.display(), lang = lang, "generated language-specific RSS feed");
630        }
631
632        Ok(())
633    }
634
635    /// Generate sitemap.
636    fn generate_sitemap(&self, content: &SiteContent) -> Result<()> {
637        let generator = SitemapGenerator::new(&self.config);
638        let pages: Vec<_> = content.pages.values().collect();
639
640        let xml = generator.generate(&pages)?;
641        let output_path = self.output_dir.join("sitemap.xml");
642        fs::write(&output_path, xml)?;
643        info!(path = %output_path.display(), "generated sitemap");
644
645        // Generate XSLT stylesheet for sitemap
646        let xsl = crate::sitemap::generate_sitemap_xsl();
647        let xsl_path = self.output_dir.join("sitemap-style.xsl");
648        fs::write(&xsl_path, xsl)?;
649        info!(path = %xsl_path.display(), "generated sitemap stylesheet");
650
651        Ok(())
652    }
653
654    /// Generate robots.txt.
655    fn generate_robots(&self) -> Result<()> {
656        let generator = RobotsGenerator::new(&self.config);
657        generator.generate(&self.output_dir)?;
658        Ok(())
659    }
660
661    /// Generate search indexes per language.
662    ///
663    /// Creates a `search-index.json` for default language at root,
664    /// and `/{lang}/search-index.json` for non-default languages.
665    fn generate_search_indexes(&self, content: &SiteContent) -> Result<()> {
666        let all_languages = self.config.all_languages();
667        let default_lang = &self.config.site.default_language;
668
669        for lang in &all_languages {
670            // Filter pages by language
671            let lang_pages: Vec<_> = content.pages.values().filter(|p| p.lang == *lang).collect();
672
673            if lang_pages.is_empty() {
674                continue;
675            }
676
677            // Build simple search index
678            let index = SimpleSearchIndex::from_pages(&lang_pages);
679
680            // Determine output path
681            let output_path = if *lang == default_lang.as_str() {
682                self.output_dir.join("search-index.json")
683            } else {
684                self.output_dir.join(lang).join("search-index.json")
685            };
686
687            // Create parent directories if needed
688            if let Some(parent) = output_path.parent() {
689                fs::create_dir_all(parent)?;
690            }
691
692            // Write the index
693            index
694                .write_to_file(&output_path)
695                .map_err(|e| BuildError::Config(e.to_string()))?;
696
697            info!(
698                path = %output_path.display(),
699                lang = lang,
700                documents = lang_pages.len(),
701                "generated search index"
702            );
703        }
704
705        Ok(())
706    }
707
708    /// Process static assets.
709    fn process_assets(&self, static_dir: &Path) -> Result<AssetManifest> {
710        let processor = AssetProcessor::new(self.config.build.minify);
711        let manifest = processor.process(static_dir, &self.output_dir)?;
712
713        // Write manifest
714        let manifest_path = self.output_dir.join("asset-manifest.json");
715        fs::write(&manifest_path, manifest.to_json())?;
716
717        Ok(manifest)
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use std::collections::HashMap;
724
725    use tempfile::TempDir;
726    use typstify_core::test_fixtures::test_config;
727
728    use super::*;
729
730    #[test]
731    fn test_build_empty_site() {
732        let content_dir = TempDir::new().unwrap();
733        let output_dir = TempDir::new().unwrap();
734
735        let builder = Builder::new(test_config(), content_dir.path(), output_dir.path());
736
737        let stats = builder.build().unwrap();
738
739        assert_eq!(stats.pages, 0);
740        assert!(output_dir.path().join("sitemap.xml").exists());
741        assert!(output_dir.path().join("rss.xml").exists());
742    }
743
744    #[test]
745    fn test_build_with_content() {
746        let content_dir = TempDir::new().unwrap();
747        let output_dir = TempDir::new().unwrap();
748
749        // Create a test markdown file with proper frontmatter
750        let post_path = content_dir.path().join("test-post.md");
751        fs::write(
752            &post_path,
753            r#"---
754title: "Test Post"
755date: 2026-01-14T00:00:00Z
756tags:
757  - rust
758  - web
759---
760
761Hello, world!
762"#,
763        )
764        .unwrap();
765
766        // Verify file was created
767        assert!(post_path.exists());
768
769        let builder = Builder::new(test_config(), content_dir.path(), output_dir.path());
770
771        let stats = builder.build().unwrap();
772
773        // Check outputs
774        let html_path = output_dir.path().join("test-post/index.html");
775        let tags_rust = output_dir.path().join("tags/rust/index.html");
776        let tags_web = output_dir.path().join("tags/web/index.html");
777
778        // Debug: print what exists
779        if html_path.exists() {
780            eprintln!("HTML exists at {:?}", html_path);
781        } else {
782            eprintln!("HTML NOT found at {:?}", html_path);
783            // List output dir contents
784            if output_dir.path().exists() {
785                for entry in std::fs::read_dir(output_dir.path()).unwrap() {
786                    eprintln!("  Output contains: {:?}", entry.unwrap().path());
787                }
788            }
789        }
790
791        assert_eq!(stats.pages, 1, "Expected 1 page, got {}", stats.pages);
792        assert!(html_path.exists(), "HTML file should exist");
793        assert!(tags_rust.exists(), "tags/rust should exist");
794        assert!(tags_web.exists(), "tags/web should exist");
795    }
796
797    #[test]
798    fn test_build_stats() {
799        let stats = BuildStats::default();
800        assert_eq!(stats.pages, 0);
801        assert_eq!(stats.duration_ms, 0);
802    }
803
804    #[test]
805    fn test_builder_with_static_dir() {
806        let content_dir = TempDir::new().unwrap();
807        let output_dir = TempDir::new().unwrap();
808        let static_dir = TempDir::new().unwrap();
809
810        // Create a static file
811        fs::write(static_dir.path().join("style.css"), "body {}").unwrap();
812
813        let builder = Builder::new(test_config(), content_dir.path(), output_dir.path())
814            .with_static_dir(static_dir.path());
815
816        let stats = builder.build().unwrap();
817
818        assert_eq!(stats.assets, 1);
819        assert!(output_dir.path().join("style.css").exists());
820    }
821
822    #[test]
823    fn test_rss_feed_paths_default_vs_nondefault() {
824        let content_dir = TempDir::new().unwrap();
825        let output_dir = TempDir::new().unwrap();
826
827        // Create posts in two languages — lang is detected from filename suffix
828        fs::write(
829            content_dir.path().join("en-post.md"),
830            r#"---
831title: "English Post"
832date: 2026-01-14T00:00:00Z
833---
834
835English content.
836"#,
837        )
838        .unwrap();
839        fs::write(
840            content_dir.path().join("zh-post.zh.md"),
841            r#"---
842title: "Chinese Post"
843date: 2026-01-14T00:00:00Z
844---
845
846Chinese content.
847"#,
848        )
849        .unwrap();
850
851        let mut config = test_config();
852        // Register both languages so all_languages() returns both
853        config.languages.insert(
854            "zh".to_string(),
855            typstify_core::config::LanguageConfig {
856                name: Some("中文".to_string()),
857                title: None,
858                description: None,
859            },
860        );
861
862        let builder = Builder::new(config, content_dir.path(), output_dir.path());
863        let stats = builder.build().unwrap();
864
865        // Default language feed at root
866        assert!(
867            output_dir.path().join("rss.xml").exists(),
868            "main RSS feed should exist at root"
869        );
870        // Non-default language feed in subdirectory
871        assert!(
872            output_dir.path().join("zh/rss.xml").exists(),
873            "zh RSS feed should exist at output_dir/zh/rss.xml"
874        );
875        assert!(stats.pages >= 2);
876    }
877
878    fn multilang_config() -> Config {
879        let mut languages = HashMap::new();
880        languages.insert(
881            "zh".to_string(),
882            typstify_core::config::LanguageConfig {
883                name: Some("中文".to_string()),
884                title: None,
885                description: None,
886            },
887        );
888        Config::from_parts(
889            typstify_core::config::SiteConfig {
890                title: "Test Site".to_string(),
891                host: "https://example.com".to_string(),
892                base_path: String::new(),
893                default_language: "en".to_string(),
894                description: None,
895                author: None,
896            },
897            typstify_core::config::BuildConfig::default(),
898            typstify_core::config::SearchConfig::default(),
899            typstify_core::config::RssConfig::default(),
900            typstify_core::config::RobotsConfig::default(),
901            typstify_core::config::TaxonomyConfig::default(),
902            languages,
903        )
904    }
905
906    #[test]
907    fn test_multilang_page_generation() {
908        use chrono::{TimeZone, Utc};
909
910        let config = multilang_config();
911        let generator = HtmlGenerator::new(&config);
912
913        // Create English (default language) page
914        let en_page = Page {
915            url: "/posts/hello-world".to_string(),
916            title: "Hello, World!".to_string(),
917            description: Some("A test".to_string()),
918            date: Some(Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap()),
919            updated: None,
920            draft: false,
921            lang: "en".to_string(),
922            is_default_lang: true,
923            canonical_id: "posts/hello-world".to_string(),
924            tags: vec![],
925            categories: vec![],
926            content: "<p>Hello, World!</p>".to_string(),
927            summary: None,
928            reading_time: None,
929            word_count: None,
930            toc: vec![],
931            custom_js: vec![],
932            custom_css: vec![],
933            aliases: vec![],
934            template: None,
935            weight: 0,
936            source_path: None,
937        };
938
939        // Create Chinese (non-default language) page
940        let zh_page = Page {
941            url: "/zh/posts/hello-world".to_string(),
942            title: "你好世界".to_string(),
943            description: Some("一个测试".to_string()),
944            date: Some(Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap()),
945            updated: None,
946            draft: false,
947            lang: "zh".to_string(),
948            is_default_lang: false,
949            canonical_id: "posts/hello-world".to_string(),
950            tags: vec![],
951            categories: vec![],
952            content: "<p>你好</p>".to_string(),
953            summary: None,
954            reading_time: None,
955            word_count: None,
956            toc: vec![],
957            custom_js: vec![],
958            custom_css: vec![],
959            aliases: vec![],
960            template: None,
961            weight: 0,
962            source_path: None,
963        };
964
965        // Generate HTML for both pages
966        let en_html = generator
967            .generate_page(&en_page, &[])
968            .expect("English page generation should succeed");
969        let zh_html = generator
970            .generate_page(&zh_page, &[])
971            .expect("Chinese page generation should succeed");
972
973        // English page: default language navigation URLs (no /zh/ prefix on nav links)
974        assert!(
975            en_html.contains("/posts"),
976            "English page should have default language navigation URLs"
977        );
978        assert!(
979            en_html.contains("/archives"),
980            "English page should have default language archives URL"
981        );
982        assert!(
983            en_html.contains("/tags"),
984            "English page should have default language tags URL"
985        );
986
987        // Chinese page: contains Chinese language content
988        assert!(
989            zh_html.contains("你好"),
990            "Chinese page should contain Chinese content"
991        );
992        assert!(
993            zh_html.contains("lang=\"zh\""),
994            "Chinese page should have lang='zh' attribute"
995        );
996
997        // Chinese page: /zh/ prefix in navigation URLs
998        assert!(
999            zh_html.contains("/zh/posts"),
1000            "Chinese page should have /zh/ prefix in navigation URLs"
1001        );
1002        assert!(
1003            zh_html.contains("/zh/archives"),
1004            "Chinese page should have /zh/ prefix in archives URL"
1005        );
1006        assert!(
1007            zh_html.contains("/zh/tags"),
1008            "Chinese page should have /zh/ prefix in tags URL"
1009        );
1010
1011        // Language switcher: appears on both pages when multiple languages configured
1012        assert!(
1013            zh_html.contains("lang-switcher"),
1014            "Chinese page should contain language switcher"
1015        );
1016        assert!(
1017            zh_html.contains("中文"),
1018            "Language switcher should show Chinese language name"
1019        );
1020        assert!(
1021            en_html.contains("lang-switcher"),
1022            "English page should also contain language switcher when multiple languages configured"
1023        );
1024        assert!(
1025            en_html.contains("/zh/posts/hello-world"),
1026            "English page language switcher should link to Chinese version with /zh/ prefix"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_page_generation_failed_error() {
1032        let errors = vec![
1033            "page1.html".to_string(),
1034            "page2.html".to_string(),
1035            "page3.html".to_string(),
1036        ];
1037        let err = BuildError::PageGenerationFailed {
1038            errors: errors.clone(),
1039        };
1040        let msg = err.to_string();
1041        assert!(
1042            msg.contains("3 errors"),
1043            "should report error count, got: {msg}"
1044        );
1045        assert!(
1046            msg.contains("page generation failed"),
1047            "should include prefix, got: {msg}"
1048        );
1049
1050        // Verify the inner errors are preserved
1051        match err {
1052            BuildError::PageGenerationFailed { errors: inner } => {
1053                assert_eq!(inner.len(), 3);
1054                assert_eq!(inner[0], "page1.html");
1055            }
1056            _ => panic!("wrong variant"),
1057        }
1058    }
1059}