1use crate::plugin::{Plugin, PluginContext};
24use anyhow::{Context, Result};
25use serde::{Deserialize, Serialize};
26use std::{
27 collections::{HashMap, HashSet},
28 fs,
29 path::Path,
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40#[derive(Default)]
41#[non_exhaustive]
42pub enum UrlPrefixStrategy {
43 #[default]
45 SubPath,
46 SubDomain,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct I18nConfig {
62 pub default_locale: String,
64 pub locales: Vec<String>,
66 #[serde(default)]
68 pub url_prefix: UrlPrefixStrategy,
69}
70
71impl Default for I18nConfig {
72 fn default() -> Self {
73 Self {
74 default_locale: "en".to_string(),
75 locales: vec!["en".to_string()],
76 url_prefix: UrlPrefixStrategy::default(),
77 }
78 }
79}
80
81#[derive(Debug)]
85pub struct I18nPlugin {
86 config: I18nConfig,
87}
88
89impl I18nPlugin {
90 #[must_use]
92 pub const fn new(config: I18nConfig) -> Self {
93 Self { config }
94 }
95}
96
97impl Plugin for I18nPlugin {
98 fn name(&self) -> &'static str {
99 "i18n"
100 }
101
102 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
103 if !ctx.site_dir.exists() {
104 return Ok(());
105 }
106
107 if self.config.locales.len() < 2 {
109 return Ok(());
110 }
111
112 let present_locales =
114 detect_locale_dirs(&ctx.site_dir, &self.config.locales);
115 if present_locales.len() < 2 {
116 return Ok(());
117 }
118
119 let pages = collect_locale_pages(&ctx.site_dir, &present_locales)?;
121
122 let base_url = ctx.config.as_ref().map_or_else(
124 || "https://example.com".to_string(),
125 |c| c.base_url.clone(),
126 );
127
128 inject_hreflang_all(
130 &ctx.site_dir,
131 &pages,
132 &present_locales,
133 &self.config.default_locale,
134 &base_url,
135 &self.config.url_prefix,
136 )?;
137
138 generate_locale_sitemaps(
140 &ctx.site_dir,
141 &pages,
142 &present_locales,
143 &self.config.default_locale,
144 &base_url,
145 &self.config.url_prefix,
146 )?;
147
148 crate::server::generate_locale_redirect(
150 &ctx.site_dir,
151 &present_locales,
152 &self.config.default_locale,
153 )?;
154
155 Ok(())
156 }
157}
158
159fn detect_locale_dirs(site_dir: &Path, locales: &[String]) -> Vec<String> {
164 locales
165 .iter()
166 .filter(|l| site_dir.join(l).is_dir())
167 .cloned()
168 .collect()
169}
170
171fn collect_locale_pages(
178 site_dir: &Path,
179 locales: &[String],
180) -> Result<HashMap<String, HashSet<String>>> {
181 let mut map: HashMap<String, HashSet<String>> = HashMap::new();
182
183 for locale in locales {
184 let locale_dir = site_dir.join(locale);
185 if !locale_dir.is_dir() {
186 continue;
187 }
188 collect_html_files_recursive(
189 &locale_dir,
190 &locale_dir,
191 locale,
192 &mut map,
193 )?;
194 }
195
196 Ok(map)
197}
198
199fn collect_html_files_recursive(
201 root: &Path,
202 current: &Path,
203 locale: &str,
204 map: &mut HashMap<String, HashSet<String>>,
205) -> Result<()> {
206 let entries = fs::read_dir(current).with_context(|| {
207 format!("Failed to read directory {}", current.display())
208 })?;
209
210 for entry in entries {
211 let entry = entry?;
212 let path = entry.path();
213 if path.is_dir() {
214 collect_html_files_recursive(root, &path, locale, map)?;
215 } else if path.extension().is_some_and(|e| e == "html") {
216 let rel = path
217 .strip_prefix(root)
218 .unwrap_or(&path)
219 .to_string_lossy()
220 .replace('\\', "/");
221 let _ = map.entry(rel).or_default().insert(locale.to_string());
222 }
223 }
224
225 Ok(())
226}
227
228const HREFLANG_MARKER: &str = "rel=\"alternate\" hreflang=";
232
233fn inject_hreflang_all(
236 site_dir: &Path,
237 pages: &HashMap<String, HashSet<String>>,
238 locales: &[String],
239 default_locale: &str,
240 base_url: &str,
241 strategy: &UrlPrefixStrategy,
242) -> Result<()> {
243 let base = base_url.trim_end_matches('/');
244 let mut count = 0usize;
245
246 for (rel_path, page_locales) in pages {
247 if page_locales.len() < 2 {
249 continue;
250 }
251
252 for locale in locales {
253 if !page_locales.contains(locale) {
254 continue;
255 }
256
257 let file = site_dir.join(locale).join(rel_path);
258 if !file.exists() {
259 continue;
260 }
261
262 let html = fs::read_to_string(&file).with_context(|| {
263 format!("Failed to read {}", file.display())
264 })?;
265
266 if html.contains(HREFLANG_MARKER) {
268 continue;
269 }
270
271 let links = build_hreflang_links(
272 rel_path,
273 page_locales,
274 default_locale,
275 base,
276 strategy,
277 );
278
279 let html = if let Some(injected) =
280 inject_before_head_close(&html, &links)
281 {
282 injected
283 } else {
284 html
285 };
286
287 let html = inject_lang_switcher(
289 &html,
290 locale,
291 rel_path,
292 &page_locales.iter().cloned().collect::<Vec<_>>(),
293 base,
294 strategy,
295 );
296
297 fs::write(&file, html).with_context(|| {
298 format!("Failed to write {}", file.display())
299 })?;
300 count += 1;
301 }
302 }
303
304 if count > 0 {
305 println!(
306 "[i18n] Injected hreflang + lang switcher into {count} HTML pages"
307 );
308 }
309
310 Ok(())
311}
312
313fn inject_lang_switcher(
317 html: &str,
318 current_locale: &str,
319 rel_path: &str,
320 locales: &[String],
321 base_url: &str,
322 strategy: &UrlPrefixStrategy,
323) -> String {
324 if !html.contains(LANG_SWITCHER_MARKER) {
325 return html.to_string();
326 }
327 let mut sorted = locales.to_vec();
328 sorted.sort();
329 let switcher = generate_lang_switcher_html(
330 &sorted,
331 current_locale,
332 rel_path,
333 base_url,
334 strategy,
335 );
336 html.replace(LANG_SWITCHER_MARKER, &switcher)
337}
338
339const LANG_SWITCHER_MARKER: &str = "<!-- ssg:lang-switcher -->";
342
343fn build_hreflang_links(
345 rel_path: &str,
346 page_locales: &HashSet<String>,
347 default_locale: &str,
348 base: &str,
349 strategy: &UrlPrefixStrategy,
350) -> String {
351 let mut links = String::new();
352
353 let mut sorted: Vec<&String> = page_locales.iter().collect();
354 sorted.sort();
355
356 for locale in &sorted {
357 let href = build_url(base, locale, rel_path, strategy);
358 links.push_str(&format!(
359 " <link rel=\"alternate\" hreflang=\"{locale}\" href=\"{href}\" />\n"
360 ));
361 }
362
363 let default_href = build_url(base, default_locale, rel_path, strategy);
365 links.push_str(&format!(
366 " <link rel=\"alternate\" hreflang=\"x-default\" href=\"{default_href}\" />\n"
367 ));
368
369 links
370}
371
372fn build_url(
374 base: &str,
375 locale: &str,
376 rel_path: &str,
377 strategy: &UrlPrefixStrategy,
378) -> String {
379 match strategy {
380 UrlPrefixStrategy::SubPath => {
381 format!("{base}/{locale}/{rel_path}")
382 }
383 UrlPrefixStrategy::SubDomain => {
384 if let Some(idx) = base.find("://") {
386 let (scheme, rest) = base.split_at(idx + 3);
387 format!("{scheme}{locale}.{rest}/{rel_path}")
388 } else {
389 format!("{base}/{locale}/{rel_path}")
391 }
392 }
393 }
394}
395
396fn inject_before_head_close(html: &str, links: &str) -> Option<String> {
398 let lower = html.to_ascii_lowercase();
399 let pos = lower.find("</head>")?;
400 let mut result = String::with_capacity(html.len() + links.len());
401 result.push_str(&html[..pos]);
402 result.push_str(links);
403 result.push_str(&html[pos..]);
404 Some(result)
405}
406
407fn generate_locale_sitemaps(
411 site_dir: &Path,
412 pages: &HashMap<String, HashSet<String>>,
413 locales: &[String],
414 default_locale: &str,
415 base_url: &str,
416 strategy: &UrlPrefixStrategy,
417) -> Result<()> {
418 let base = base_url.trim_end_matches('/');
419
420 for locale in locales {
421 let mut xml = String::from(
422 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
423 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\n\
424 xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n",
425 );
426
427 let mut paths: Vec<&String> = pages
428 .iter()
429 .filter(|(_, locs)| locs.contains(locale))
430 .map(|(p, _)| p)
431 .collect();
432 paths.sort();
433
434 for rel_path in &paths {
435 let loc = build_url(base, locale, rel_path, strategy);
436 xml.push_str(" <url>\n");
437 xml.push_str(&format!(" <loc>{loc}</loc>\n"));
438
439 if let Some(page_locales) = pages.get(*rel_path) {
441 let mut alts: Vec<&String> = page_locales.iter().collect();
442 alts.sort();
443 for alt_locale in &alts {
444 let alt_href =
445 build_url(base, alt_locale, rel_path, strategy);
446 xml.push_str(&format!(
447 " <xhtml:link rel=\"alternate\" hreflang=\"{alt_locale}\" href=\"{alt_href}\" />\n"
448 ));
449 }
450 let default_href =
452 build_url(base, default_locale, rel_path, strategy);
453 xml.push_str(&format!(
454 " <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{default_href}\" />\n"
455 ));
456 }
457
458 xml.push_str(" </url>\n");
459 }
460
461 xml.push_str("</urlset>\n");
462
463 let sitemap_path = site_dir.join(format!("sitemap-{locale}.xml"));
464 fs::write(&sitemap_path, &xml).with_context(|| {
465 format!("Failed to write {}", sitemap_path.display())
466 })?;
467 }
468
469 println!("[i18n] Generated {} locale sitemaps", locales.len());
470 Ok(())
471}
472
473#[must_use]
481pub fn parse_accept_language(header: &str) -> Vec<String> {
482 if header.trim().is_empty() {
483 return Vec::new();
484 }
485
486 let mut entries: Vec<(String, f64)> = header
487 .split(',')
488 .filter_map(|part| {
489 let part = part.trim();
490 if part.is_empty() {
491 return None;
492 }
493 let mut segments = part.splitn(2, ';');
494 let locale = segments.next()?.trim().to_string();
495 if locale.is_empty() {
496 return None;
497 }
498 let quality = segments
499 .next()
500 .and_then(|q| {
501 let q = q.trim();
502 q.strip_prefix("q=")
503 .and_then(|v| v.trim().parse::<f64>().ok())
504 })
505 .unwrap_or(1.0);
506 Some((locale, quality))
507 })
508 .collect();
509
510 entries.sort_by(|a, b| {
512 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
513 });
514
515 entries.into_iter().map(|(locale, _)| locale).collect()
516}
517
518#[must_use]
526pub fn negotiate_locale(
527 preferred: &[String],
528 available: &[String],
529 default_locale: &str,
530) -> String {
531 let available_lower: Vec<String> =
532 available.iter().map(|l| l.to_lowercase()).collect();
533
534 for pref in preferred {
535 if pref == "*" {
537 continue;
538 }
539 let pref_lower = pref.to_lowercase();
540
541 if let Some(idx) = available_lower.iter().position(|a| *a == pref_lower)
543 {
544 return available[idx].clone();
545 }
546
547 let prefix = pref_lower.split('-').next().unwrap_or(&pref_lower);
549 if let Some(idx) = available_lower.iter().position(|a| *a == prefix) {
550 return available[idx].clone();
551 }
552 }
553
554 default_locale.to_string()
555}
556
557#[must_use]
588pub fn generate_lang_switcher_html(
589 locales: &[String],
590 current_locale: &str,
591 current_path: &str,
592 base_url: &str,
593 strategy: &UrlPrefixStrategy,
594) -> String {
595 let base = base_url.trim_end_matches('/');
596 let mut html = String::from(
597 "<nav class=\"lang-switcher\" aria-label=\"Language\">\n <ul>\n",
598 );
599
600 for locale in locales {
601 let href = build_url(base, locale, current_path, strategy);
602 let aria = if locale == current_locale {
603 " aria-current=\"page\""
604 } else {
605 ""
606 };
607 html.push_str(&format!(
608 " <li><a href=\"{href}\" lang=\"{locale}\" hreflang=\"{locale}\"{aria}>{locale}</a></li>\n"
609 ));
610 }
611
612 html.push_str(" </ul>\n</nav>\n");
613 html
614}
615
616#[cfg(test)]
619#[allow(clippy::unwrap_used, clippy::expect_used)]
620mod tests {
621 use super::*;
622 use crate::plugin::PluginContext;
623 use std::path::Path;
624 use tempfile::tempdir;
625
626 fn make_ctx(site_dir: &Path) -> PluginContext {
627 let config = crate::cmd::SsgConfig::builder()
628 .site_name("test".to_string())
629 .base_url("https://example.com".to_string())
630 .build()
631 .expect("test config");
632 PluginContext::with_config(
633 Path::new("content"),
634 Path::new("build"),
635 site_dir,
636 Path::new("templates"),
637 config,
638 )
639 }
640
641 fn write_html(dir: &Path, rel: &str, body: &str) {
643 let path = dir.join(rel);
644 if let Some(parent) = path.parent() {
645 fs::create_dir_all(parent).expect("mkdir");
646 }
647 let html = format!(
648 "<!DOCTYPE html><html><head><title>Test</title></head><body>{body}</body></html>"
649 );
650 fs::write(&path, html).expect("write html");
651 }
652
653 #[test]
656 fn detect_finds_existing_locale_dirs() {
657 let tmp = tempdir().unwrap();
658 fs::create_dir(tmp.path().join("en")).unwrap();
659 fs::create_dir(tmp.path().join("fr")).unwrap();
660
661 let found = detect_locale_dirs(
662 tmp.path(),
663 &["en".into(), "fr".into(), "de".into()],
664 );
665 assert_eq!(found, vec!["en", "fr"]);
666 }
667
668 #[test]
669 fn detect_returns_empty_when_none_exist() {
670 let tmp = tempdir().unwrap();
671 let found = detect_locale_dirs(tmp.path(), &["en".into(), "fr".into()]);
672 assert!(found.is_empty());
673 }
674
675 #[test]
678 fn injects_hreflang_into_shared_pages() {
679 let tmp = tempdir().unwrap();
680 let site = tmp.path();
681
682 write_html(site, "en/index.html", "Hello");
683 write_html(site, "fr/index.html", "Bonjour");
684
685 let config = I18nConfig {
686 default_locale: "en".into(),
687 locales: vec!["en".into(), "fr".into()],
688 url_prefix: UrlPrefixStrategy::SubPath,
689 };
690
691 let ctx = make_ctx(site);
692 let plugin = I18nPlugin::new(config);
693 plugin.after_compile(&ctx).unwrap();
694
695 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
697 let fr = fs::read_to_string(site.join("fr/index.html")).unwrap();
698
699 assert!(en.contains(HREFLANG_MARKER), "en missing hreflang");
700 assert!(fr.contains(HREFLANG_MARKER), "fr missing hreflang");
701
702 assert!(
704 en.contains("hreflang=\"x-default\""),
705 "en missing x-default"
706 );
707 assert!(
708 en.contains("https://example.com/en/index.html"),
709 "en x-default wrong href"
710 );
711 }
712
713 #[test]
714 fn skips_pages_existing_in_only_one_locale() {
715 let tmp = tempdir().unwrap();
716 let site = tmp.path();
717
718 write_html(site, "en/index.html", "Hello");
719 write_html(site, "en/about.html", "About");
720 write_html(site, "fr/index.html", "Bonjour");
722
723 let config = I18nConfig {
724 default_locale: "en".into(),
725 locales: vec!["en".into(), "fr".into()],
726 url_prefix: UrlPrefixStrategy::SubPath,
727 };
728
729 let ctx = make_ctx(site);
730 I18nPlugin::new(config).after_compile(&ctx).unwrap();
731
732 let about = fs::read_to_string(site.join("en/about.html")).unwrap();
734 assert!(
735 !about.contains(HREFLANG_MARKER),
736 "about.html should not have hreflang"
737 );
738 }
739
740 #[test]
741 fn idempotent_injection() {
742 let tmp = tempdir().unwrap();
743 let site = tmp.path();
744
745 write_html(site, "en/index.html", "Hello");
746 write_html(site, "fr/index.html", "Bonjour");
747
748 let config = I18nConfig {
749 default_locale: "en".into(),
750 locales: vec!["en".into(), "fr".into()],
751 url_prefix: UrlPrefixStrategy::SubPath,
752 };
753
754 let ctx = make_ctx(site);
755 let plugin = I18nPlugin::new(config);
756
757 plugin.after_compile(&ctx).unwrap();
759 plugin.after_compile(&ctx).unwrap();
760
761 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
762 let count = en.matches(HREFLANG_MARKER).count();
763 assert_eq!(count, 3, "expected 3 hreflang links, got {count}");
765 }
766
767 #[test]
770 fn x_default_points_to_default_locale() {
771 let tmp = tempdir().unwrap();
772 let site = tmp.path();
773
774 write_html(site, "en/page.html", "EN");
775 write_html(site, "fr/page.html", "FR");
776 write_html(site, "de/page.html", "DE");
777
778 let config = I18nConfig {
779 default_locale: "fr".into(),
780 locales: vec!["en".into(), "fr".into(), "de".into()],
781 url_prefix: UrlPrefixStrategy::SubPath,
782 };
783
784 let ctx = make_ctx(site);
785 I18nPlugin::new(config).after_compile(&ctx).unwrap();
786
787 let en = fs::read_to_string(site.join("en/page.html")).unwrap();
788 assert!(
790 en.contains("hreflang=\"x-default\" href=\"https://example.com/fr/page.html\""),
791 "x-default should point to fr"
792 );
793 }
794
795 #[test]
798 fn three_locale_injection() {
799 let tmp = tempdir().unwrap();
800 let site = tmp.path();
801
802 write_html(site, "en/index.html", "EN");
803 write_html(site, "fr/index.html", "FR");
804 write_html(site, "de/index.html", "DE");
805
806 let config = I18nConfig {
807 default_locale: "en".into(),
808 locales: vec!["en".into(), "fr".into(), "de".into()],
809 url_prefix: UrlPrefixStrategy::SubPath,
810 };
811
812 let ctx = make_ctx(site);
813 I18nPlugin::new(config).after_compile(&ctx).unwrap();
814
815 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
816 let count = en.matches(HREFLANG_MARKER).count();
818 assert_eq!(
819 count, 4,
820 "expected 4 hreflang links for 3 locales + x-default"
821 );
822 }
823
824 #[test]
827 fn generates_per_locale_sitemaps() {
828 let tmp = tempdir().unwrap();
829 let site = tmp.path();
830
831 write_html(site, "en/index.html", "EN");
832 write_html(site, "fr/index.html", "FR");
833
834 let config = I18nConfig {
835 default_locale: "en".into(),
836 locales: vec!["en".into(), "fr".into()],
837 url_prefix: UrlPrefixStrategy::SubPath,
838 };
839
840 let ctx = make_ctx(site);
841 I18nPlugin::new(config).after_compile(&ctx).unwrap();
842
843 let en_sm = site.join("sitemap-en.xml");
844 let fr_sm = site.join("sitemap-fr.xml");
845 assert!(en_sm.exists(), "sitemap-en.xml should exist");
846 assert!(fr_sm.exists(), "sitemap-fr.xml should exist");
847
848 let en_content = fs::read_to_string(&en_sm).unwrap();
849 assert!(
850 en_content.contains("<loc>https://example.com/en/index.html</loc>")
851 );
852 assert!(en_content.contains("xhtml:link"));
853 assert!(en_content.contains("hreflang=\"x-default\""));
854 }
855
856 #[test]
859 fn subdomain_strategy_builds_correct_urls() {
860 let url = build_url(
861 "https://example.com",
862 "fr",
863 "about/index.html",
864 &UrlPrefixStrategy::SubDomain,
865 );
866 assert_eq!(url, "https://fr.example.com/about/index.html");
867 }
868
869 #[test]
870 fn subpath_strategy_builds_correct_urls() {
871 let url = build_url(
872 "https://example.com",
873 "fr",
874 "about/index.html",
875 &UrlPrefixStrategy::SubPath,
876 );
877 assert_eq!(url, "https://example.com/fr/about/index.html");
878 }
879
880 #[test]
883 fn lang_switcher_html() {
884 let html = generate_lang_switcher_html(
885 &["en".into(), "fr".into()],
886 "en",
887 "about/index.html",
888 "https://example.com",
889 &UrlPrefixStrategy::SubPath,
890 );
891 assert!(html.contains("lang=\"en\""));
892 assert!(html.contains("lang=\"fr\""));
893 assert!(html.contains("aria-current=\"page\""));
894 assert!(html.contains("class=\"lang-switcher\""));
895 }
896
897 #[test]
900 fn inject_before_head_close_works() {
901 let html = "<html><head><title>T</title></head><body></body></html>";
902 let result = inject_before_head_close(html, "INJECTED\n").unwrap();
903 assert!(result.contains("INJECTED\n</head>"));
904 }
905
906 #[test]
907 fn inject_before_head_close_returns_none_without_head() {
908 let html = "<html><body>no head</body></html>";
909 assert!(inject_before_head_close(html, "X").is_none());
910 }
911
912 #[test]
915 fn plugin_name() {
916 let p = I18nPlugin::new(I18nConfig::default());
917 assert_eq!(p.name(), "i18n");
918 }
919
920 #[test]
921 fn plugin_skips_nonexistent_site_dir() {
922 let ctx = PluginContext::new(
923 Path::new("c"),
924 Path::new("b"),
925 Path::new("/does/not/exist"),
926 Path::new("t"),
927 );
928 let p = I18nPlugin::new(I18nConfig {
929 default_locale: "en".into(),
930 locales: vec!["en".into(), "fr".into()],
931 url_prefix: UrlPrefixStrategy::SubPath,
932 });
933 assert!(p.after_compile(&ctx).is_ok());
934 }
935
936 #[test]
937 fn plugin_skips_single_locale() {
938 let tmp = tempdir().unwrap();
939 let ctx = make_ctx(tmp.path());
940 let p = I18nPlugin::new(I18nConfig::default());
941 assert!(p.after_compile(&ctx).is_ok());
943 }
944
945 #[test]
948 fn default_config() {
949 let cfg = I18nConfig::default();
950 assert_eq!(cfg.default_locale, "en");
951 assert_eq!(cfg.locales, vec!["en"]);
952 assert_eq!(cfg.url_prefix, UrlPrefixStrategy::SubPath);
953 }
954
955 #[test]
960 fn lang_switcher_empty_locales() {
961 let html = generate_lang_switcher_html(
962 &[],
963 "en",
964 "index.html",
965 "https://example.com",
966 &UrlPrefixStrategy::SubPath,
967 );
968 assert!(html.contains("<nav"));
969 assert!(html.contains("</nav>"));
970 assert!(!html.contains("<li>"));
972 }
973
974 #[test]
975 fn lang_switcher_single_locale() {
976 let html = generate_lang_switcher_html(
977 &["en".into()],
978 "en",
979 "index.html",
980 "https://example.com",
981 &UrlPrefixStrategy::SubPath,
982 );
983 assert!(html.contains("aria-current=\"page\""));
984 assert_eq!(html.matches("<li>").count(), 1);
986 }
987
988 #[test]
989 fn lang_switcher_subdomain_strategy() {
990 let html = generate_lang_switcher_html(
991 &["en".into(), "fr".into()],
992 "fr",
993 "about/index.html",
994 "https://example.com",
995 &UrlPrefixStrategy::SubDomain,
996 );
997 assert!(html.contains("https://en.example.com/about/index.html"));
998 assert!(html.contains("https://fr.example.com/about/index.html"));
999 }
1000
1001 #[test]
1004 fn sitemap_contains_xhtml_link_alternates() {
1005 let tmp = tempdir().unwrap();
1006 let site = tmp.path();
1007
1008 write_html(site, "en/index.html", "EN");
1009 write_html(site, "fr/index.html", "FR");
1010 write_html(site, "de/index.html", "DE");
1011
1012 let config = I18nConfig {
1013 default_locale: "en".into(),
1014 locales: vec!["en".into(), "fr".into(), "de".into()],
1015 url_prefix: UrlPrefixStrategy::SubPath,
1016 };
1017
1018 let ctx = make_ctx(site);
1019 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1020
1021 let en_sm = fs::read_to_string(site.join("sitemap-en.xml")).unwrap();
1022 assert!(en_sm.contains("hreflang=\"en\""));
1024 assert!(en_sm.contains("hreflang=\"fr\""));
1025 assert!(en_sm.contains("hreflang=\"de\""));
1026 assert!(en_sm.contains("hreflang=\"x-default\""));
1027 assert!(en_sm.contains(
1029 "hreflang=\"x-default\" href=\"https://example.com/en/index.html\""
1030 ));
1031 }
1032
1033 #[test]
1036 fn plugin_with_locale_dirs_but_no_shared_pages_skips_injection() {
1037 let tmp = tempdir().unwrap();
1038 let site = tmp.path();
1039
1040 write_html(site, "en/about.html", "EN About");
1042 write_html(site, "fr/contact.html", "FR Contact");
1043
1044 let config = I18nConfig {
1045 default_locale: "en".into(),
1046 locales: vec!["en".into(), "fr".into()],
1047 url_prefix: UrlPrefixStrategy::SubPath,
1048 };
1049
1050 let ctx = make_ctx(site);
1051 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1052
1053 let en = fs::read_to_string(site.join("en/about.html")).unwrap();
1055 let fr = fs::read_to_string(site.join("fr/contact.html")).unwrap();
1056 assert!(!en.contains(HREFLANG_MARKER));
1057 assert!(!fr.contains(HREFLANG_MARKER));
1058 }
1059
1060 #[test]
1061 fn plugin_skips_when_only_one_locale_dir_exists() {
1062 let tmp = tempdir().unwrap();
1063 let site = tmp.path();
1064
1065 write_html(site, "en/index.html", "EN");
1067
1068 let config = I18nConfig {
1069 default_locale: "en".into(),
1070 locales: vec!["en".into(), "fr".into()],
1071 url_prefix: UrlPrefixStrategy::SubPath,
1072 };
1073
1074 let ctx = make_ctx(site);
1075 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1076
1077 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
1078 assert!(!en.contains(HREFLANG_MARKER));
1079 }
1080
1081 #[test]
1084 fn subdomain_strategy_fallback_without_scheme() {
1085 let url = build_url(
1087 "example.com",
1088 "fr",
1089 "page.html",
1090 &UrlPrefixStrategy::SubDomain,
1091 );
1092 assert_eq!(url, "example.com/fr/page.html");
1093 }
1094
1095 #[test]
1096 fn nested_pages_get_hreflang() {
1097 let tmp = tempdir().unwrap();
1098 let site = tmp.path();
1099
1100 write_html(site, "en/docs/guide.html", "EN Guide");
1101 write_html(site, "fr/docs/guide.html", "FR Guide");
1102
1103 let config = I18nConfig {
1104 default_locale: "en".into(),
1105 locales: vec!["en".into(), "fr".into()],
1106 url_prefix: UrlPrefixStrategy::SubPath,
1107 };
1108
1109 let ctx = make_ctx(site);
1110 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1111
1112 let en = fs::read_to_string(site.join("en/docs/guide.html")).unwrap();
1113 assert!(en.contains(HREFLANG_MARKER));
1114 assert!(en.contains("https://example.com/en/docs/guide.html"));
1115 assert!(en.contains("https://example.com/fr/docs/guide.html"));
1116 }
1117
1118 #[test]
1121 fn parse_accept_language_basic() {
1122 let result = parse_accept_language("en, fr, de");
1123 assert_eq!(result, vec!["en", "fr", "de"]);
1124 }
1125
1126 #[test]
1127 fn parse_accept_language_with_quality() {
1128 let result = parse_accept_language(
1129 "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
1130 );
1131 assert_eq!(result, vec!["fr-CH", "fr", "en", "de", "*"]);
1132 }
1133
1134 #[test]
1135 fn parse_accept_language_with_whitespace() {
1136 let result = parse_accept_language(" en , fr ; q=0.8 , de ; q=0.5 ");
1137 assert_eq!(result, vec!["en", "fr", "de"]);
1138 }
1139
1140 #[test]
1141 fn parse_accept_language_empty() {
1142 let result = parse_accept_language("");
1143 assert!(result.is_empty());
1144 }
1145
1146 #[test]
1147 fn parse_accept_language_single() {
1148 let result = parse_accept_language("en");
1149 assert_eq!(result, vec!["en"]);
1150 }
1151
1152 #[test]
1153 fn parse_accept_language_wildcard_only() {
1154 let result = parse_accept_language("*");
1155 assert_eq!(result, vec!["*"]);
1156 }
1157
1158 #[test]
1161 fn negotiate_exact_match() {
1162 let preferred = vec!["fr".into()];
1163 let available = vec!["en".into(), "fr".into(), "de".into()];
1164 assert_eq!(negotiate_locale(&preferred, &available, "en"), "fr");
1165 }
1166
1167 #[test]
1168 fn negotiate_prefix_match() {
1169 let preferred = vec!["fr-CH".into()];
1170 let available = vec!["en".into(), "fr".into(), "de".into()];
1171 assert_eq!(negotiate_locale(&preferred, &available, "en"), "fr");
1172 }
1173
1174 #[test]
1175 fn negotiate_default_fallback() {
1176 let preferred = vec!["ja".into()];
1177 let available = vec!["en".into(), "fr".into()];
1178 assert_eq!(negotiate_locale(&preferred, &available, "en"), "en");
1179 }
1180
1181 #[test]
1182 fn negotiate_case_insensitive() {
1183 let preferred = vec!["FR".into()];
1184 let available = vec!["en".into(), "fr".into()];
1185 assert_eq!(negotiate_locale(&preferred, &available, "en"), "fr");
1186 }
1187
1188 #[test]
1189 fn negotiate_wildcard_ignored() {
1190 let preferred = vec!["*".into()];
1191 let available = vec!["en".into(), "fr".into()];
1192 assert_eq!(negotiate_locale(&preferred, &available, "en"), "en");
1193 }
1194
1195 #[test]
1196 fn negotiate_no_match_returns_default() {
1197 let preferred: Vec<String> = vec![];
1198 let available = vec!["en".into(), "fr".into()];
1199 assert_eq!(negotiate_locale(&preferred, &available, "fr"), "fr");
1200 }
1201
1202 #[test]
1205 fn locale_redirect_contains_all_locales() {
1206 let tmp = tempdir().unwrap();
1207 let site = tmp.path();
1208 fs::create_dir_all(site).unwrap();
1209
1210 let locales = vec!["en".into(), "fr".into(), "de".into()];
1211 crate::server::generate_locale_redirect(site, &locales, "en").unwrap();
1212
1213 let content = fs::read_to_string(site.join("index.html")).unwrap();
1214 assert!(content.contains("\"en\""), "missing en locale");
1215 assert!(content.contains("\"fr\""), "missing fr locale");
1216 assert!(content.contains("\"de\""), "missing de locale");
1217 }
1218
1219 #[test]
1220 fn locale_redirect_noscript_fallback() {
1221 let tmp = tempdir().unwrap();
1222 let site = tmp.path();
1223 fs::create_dir_all(site).unwrap();
1224
1225 crate::server::generate_locale_redirect(
1226 site,
1227 &["en".into(), "fr".into()],
1228 "en",
1229 )
1230 .unwrap();
1231
1232 let content = fs::read_to_string(site.join("index.html")).unwrap();
1233 assert!(content.contains("<noscript>"), "missing noscript tag");
1234 assert!(
1235 content.contains("url=/en/"),
1236 "noscript should redirect to default locale"
1237 );
1238 }
1239
1240 #[test]
1241 fn locale_redirect_preserves_existing_non_redirect_index() {
1242 let tmp = tempdir().unwrap();
1243 let site = tmp.path();
1244 fs::create_dir_all(site).unwrap();
1245
1246 fs::write(site.join("index.html"), "<html>Custom</html>").unwrap();
1248
1249 crate::server::generate_locale_redirect(
1250 site,
1251 &["en".into(), "fr".into()],
1252 "en",
1253 )
1254 .unwrap();
1255
1256 let content = fs::read_to_string(site.join("index.html")).unwrap();
1257 assert_eq!(content, "<html>Custom</html>");
1258 }
1259
1260 #[test]
1261 fn after_compile_generates_locale_redirect() {
1262 let tmp = tempdir().unwrap();
1263 let site = tmp.path();
1264
1265 write_html(site, "en/index.html", "EN");
1266 write_html(site, "fr/index.html", "FR");
1267
1268 let config = I18nConfig {
1269 default_locale: "en".into(),
1270 locales: vec!["en".into(), "fr".into()],
1271 url_prefix: UrlPrefixStrategy::SubPath,
1272 };
1273
1274 let ctx = make_ctx(site);
1275 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1276
1277 let index = site.join("index.html");
1278 assert!(index.exists(), "root index.html should be generated");
1279 let content = fs::read_to_string(&index).unwrap();
1280 assert!(content.contains("ssg-locale-redirect"));
1281 assert!(content.contains("\"en\""));
1282 assert!(content.contains("\"fr\""));
1283 }
1284}