1use std::{
6 fs,
7 path::{Path, PathBuf},
8 time::Instant,
9};
10
11use rayon::prelude::*;
12use thiserror::Error;
13use tracing::{debug, info, warn};
14use typstify_core::{Config, Page};
15use typstify_search::SimpleSearchIndex;
16
17use crate::{
18 assets::{AssetError, AssetManifest, AssetProcessor},
19 collector::{CollectorError, ContentCollector, SiteContent, paginate},
20 html::{
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#[derive(Debug, Error)]
30pub enum BuildError {
31 #[error("IO error: {0}")]
33 Io(#[from] std::io::Error),
34
35 #[error("collector error: {0}")]
37 Collector(#[from] CollectorError),
38
39 #[error("HTML error: {0}")]
41 Html(#[from] HtmlError),
42
43 #[error("RSS error: {0}")]
45 Rss(#[from] RssError),
46
47 #[error("sitemap error: {0}")]
49 Sitemap(#[from] SitemapError),
50
51 #[error("robots error: {0}")]
53 Robots(#[from] RobotsError),
54
55 #[error("asset error: {0}")]
57 Asset(#[from] AssetError),
58
59 #[error("config error: {0}")]
61 Config(String),
62
63 #[error("page generation failed: {} errors", errors.len())]
65 PageGenerationFailed { errors: Vec<String> },
66}
67
68pub type Result<T> = std::result::Result<T, BuildError>;
70
71#[derive(Debug, Clone, Default)]
73pub struct BuildStats {
74 pub pages: usize,
76
77 pub taxonomy_pages: usize,
79
80 pub redirects: usize,
82
83 pub auto_pages: usize,
85
86 pub assets: usize,
88
89 pub duration_ms: u64,
91}
92
93#[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 #[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 #[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 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 self.clean_output()?;
138
139 let collector = ContentCollector::new(&self.config, &self.content_dir);
141 let content = collector.collect()?;
142
143 let sections: Vec<String> = content.sections.keys().cloned().collect();
145
146 stats.pages = self.generate_pages(&content, §ions)?;
148
149 stats.taxonomy_pages = self.generate_taxonomy_pages(&content, §ions)?;
151
152 stats.auto_pages = self.generate_auto_pages(&content, §ions)?;
154
155 stats.redirects = self.generate_redirects(&content)?;
157
158 if self.config.rss.enabled {
160 self.generate_rss(&content)?;
161 }
162
163 self.generate_sitemap(&content)?;
165
166 self.generate_robots()?;
168
169 if self.config.search.enabled {
171 self.generate_search_indexes(&content)?;
172 }
173
174 crate::static_assets::generate_static_assets(&self.output_dir)
176 .map_err(|e| BuildError::Io(std::io::Error::other(e.to_string())))?;
177
178 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 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 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 let results: Vec<_> = pages
218 .par_iter()
219 .map(|page| {
220 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 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 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 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 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 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 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 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 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 let all_languages = self.config.all_languages();
357 let default_lang = &self.config.site.default_language;
358
359 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 let lang_pages: Vec<_> = content.pages.values().filter(|p| p.lang == *lang).collect();
370
371 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 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 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 let mut sections: std::collections::HashMap<String, Vec<&Page>> =
454 std::collections::HashMap::new();
455 for page in lang_pages.iter().copied() {
456 let url = page.url.trim_start_matches('/');
458 let section = if is_default {
459 url.split('/').next().unwrap_or("")
460 } else {
461 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 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 let per_page = self.config.taxonomies.tags.paginate;
481 let total_pages = section_pages.len().div_ceil(per_page).max(1);
482
483 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(§ion_pages, page_num, per_page);
489
490 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 let html = if is_shorts {
506 generator.generate_shorts_page(
507 §ion,
508 None, &items_html,
510 pagination.as_deref(),
511 lang,
512 )?
513 } else {
514 generator.generate_section_page(
515 §ion,
516 None, &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(§ion).join("index.html")
526 } else {
527 self.output_dir
528 .join(&lang_prefix)
529 .join(§ion)
530 .join("index.html")
531 }
532 } else if is_default {
533 self.output_dir
534 .join(§ion)
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(§ion)
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 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 fn generate_rss(&self, content: &SiteContent) -> Result<()> {
589 let generator = RssGenerator::new(&self.config);
590 let pages = ContentCollector::pages_by_date(content);
591
592 let posts: Vec<_> = pages.into_iter().filter(|p| p.date.is_some()).collect();
594
595 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 let all_languages = self.config.all_languages();
603 let default_lang = &self.config.site.default_language;
604
605 for lang in &all_languages {
606 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 let lang_xml = generator.generate_for_lang(&lang_posts, lang)?;
615
616 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 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 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 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 fn generate_robots(&self) -> Result<()> {
656 let generator = RobotsGenerator::new(&self.config);
657 generator.generate(&self.output_dir)?;
658 Ok(())
659 }
660
661 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 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 let index = SimpleSearchIndex::from_pages(&lang_pages);
679
680 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 if let Some(parent) = output_path.parent() {
689 fs::create_dir_all(parent)?;
690 }
691
692 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 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 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 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 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 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 if html_path.exists() {
780 eprintln!("HTML exists at {:?}", html_path);
781 } else {
782 eprintln!("HTML NOT found at {:?}", html_path);
783 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 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 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 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 assert!(
867 output_dir.path().join("rss.xml").exists(),
868 "main RSS feed should exist at root"
869 );
870 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 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 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 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 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 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 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 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 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}