1use crate::error::{PathErrorExt, SsgError};
74use crate::plugin::{Plugin, PluginContext};
75use crate::util::head_dom::inject_before_head_close as inject_head;
76pub(crate) use ssg_i18n::{
120 build_hreflang_links, build_url, find_lang_switcher_element,
121 generate_lang_switcher_html_with_self_lang, rewrite_ap_lang_items,
122 sidecar_candidates, HREFLANG_MARKER, LANG_SWITCHER_MARKER,
123};
124pub use ssg_i18n::{
125 generate_lang_switcher_html, negotiate_locale, parse_accept_language,
126 I18nConfig, UrlPrefixStrategy,
127};
128use std::{
129 collections::{BTreeMap, HashMap},
130 fs,
131 path::{Path, PathBuf},
132 sync::RwLock,
133};
134
135#[derive(Debug, Default)]
143struct LocaleMatrixCache {
144 site_dir: Option<PathBuf>,
145 present_locales: Vec<String>,
146 root_locale: Option<String>,
149 pages: LocaleMatrix,
156 keys: HashMap<(String, String), String>,
158}
159
160type LocaleKeyIndex = HashMap<(String, String), String>;
162
163type LocaleMatrix = HashMap<String, BTreeMap<String, String>>;
169
170#[derive(Debug)]
181pub struct I18nPlugin {
182 config: I18nConfig,
183 matrix: RwLock<LocaleMatrixCache>,
191}
192
193impl I18nPlugin {
194 #[must_use]
207 pub fn new(config: I18nConfig) -> Self {
208 Self {
209 config,
210 matrix: RwLock::new(LocaleMatrixCache::default()),
211 }
212 }
213
214 fn ensure_matrix(&self, ctx: &PluginContext) -> Result<(), SsgError> {
218 let site_dir = ctx.site_dir.as_path();
219 {
223 let cache = self
224 .matrix
225 .read()
226 .unwrap_or_else(std::sync::PoisonError::into_inner);
227 if cache.site_dir.as_deref() == Some(site_dir) {
228 return Ok(());
229 }
230 }
231
232 let mut cache = self
233 .matrix
234 .write()
235 .unwrap_or_else(std::sync::PoisonError::into_inner);
236 if cache.site_dir.as_deref() == Some(site_dir) {
239 return Ok(());
240 }
241 let (present_locales, root_locale) = detect_locales(
242 site_dir,
243 &self.config.locales,
244 &self.config.default_locale,
245 );
246 let (pages, keys) = if present_locales.len() >= 2 {
247 collect_locale_pages(
248 site_dir,
249 &resolve_sidecar_dir(ctx),
250 &present_locales,
251 root_locale.as_deref(),
252 )
253 .map_err(|e| SsgError::io(e, site_dir))?
254 } else {
255 (HashMap::new(), HashMap::new())
256 };
257 cache.site_dir = Some(site_dir.to_path_buf());
258 cache.present_locales = present_locales;
259 cache.root_locale = root_locale;
260 cache.pages = pages;
261 cache.keys = keys;
262 Ok(())
263 }
264}
265
266impl Plugin for I18nPlugin {
267 fn name(&self) -> &'static str {
268 "i18n"
269 }
270
271 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
272 if !ctx.site_dir.exists() {
273 return Ok(());
274 }
275
276 if self.config.locales.len() < 2 {
278 return Ok(());
279 }
280
281 {
286 let mut cache = self
287 .matrix
288 .write()
289 .unwrap_or_else(std::sync::PoisonError::into_inner);
290 cache.site_dir = None;
291 }
292 self.ensure_matrix(ctx)?;
293
294 let (present_locales, root_locale, pages) = {
295 let cache = self
296 .matrix
297 .read()
298 .unwrap_or_else(std::sync::PoisonError::into_inner);
299 (
300 cache.present_locales.clone(),
301 cache.root_locale.clone(),
302 cache.pages.clone(),
303 )
304 };
305
306 if present_locales.len() < 2 {
307 return Ok(());
308 }
309
310 let base_url = ctx.config.as_ref().map_or_else(
312 || "https://example.com".to_string(),
313 |c| c.base_url.clone(),
314 );
315
316 inject_hreflang_all(
318 ctx,
319 &pages,
320 &present_locales,
321 &self.config.default_locale,
322 &base_url,
323 &self.config.url_prefix,
324 root_locale.as_deref(),
325 )
326 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
327
328 generate_locale_sitemaps(
330 ctx,
331 &pages,
332 &present_locales,
333 &self.config.default_locale,
334 &base_url,
335 &self.config.url_prefix,
336 root_locale.as_deref(),
337 )
338 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
339
340 if root_locale.is_none() {
347 crate::server::generate_locale_redirect(
348 &ctx.site_dir,
349 &present_locales,
350 &self.config.default_locale,
351 )
352 .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
353 }
354
355 Ok(())
356 }
357
358 fn has_transform(&self) -> bool {
359 true
360 }
361
362 fn transform_html(
370 &self,
371 html: &str,
372 path: &Path,
373 ctx: &PluginContext,
374 ) -> Result<String, SsgError> {
375 if self.config.locales.len() < 2 {
376 return Ok(html.to_string());
377 }
378 if html.contains(HREFLANG_MARKER) {
379 return Ok(html.to_string());
380 }
381 self.ensure_matrix(ctx)?;
382
383 let (locale_for_file, locale_map, root_locale) = {
385 let cache = self
386 .matrix
387 .read()
388 .unwrap_or_else(std::sync::PoisonError::into_inner);
389 if cache.present_locales.len() < 2 {
390 return Ok(html.to_string());
391 }
392 let Some((locale, rel)) = resolve_locale_and_rel(
393 path,
394 &ctx.site_dir,
395 &cache.present_locales,
396 cache.root_locale.as_deref(),
397 ) else {
398 return Ok(html.to_string());
399 };
400 let Some(key) = cache.keys.get(&(locale.clone(), rel.clone()))
401 else {
402 return Ok(html.to_string());
403 };
404 let Some(locale_map) = cache.pages.get(key).cloned() else {
405 return Ok(html.to_string());
406 };
407 (locale, locale_map, cache.root_locale.clone())
408 };
409
410 if locale_map.len() < 2 {
413 return Ok(html.to_string());
414 }
415
416 let base_url = ctx.config.as_ref().map_or_else(
417 || "https://example.com".to_string(),
418 |c| c.base_url.clone(),
419 );
420 let base = base_url.trim_end_matches('/');
421
422 let self_lang = crate::seo::lang::resolve_page_lang(html, path, ctx);
426
427 let labels = hreflang_labels(
428 ctx,
429 &locale_map,
430 &locale_for_file,
431 &self_lang,
432 root_locale.as_deref(),
433 );
434
435 let links = build_hreflang_links(
436 &locale_map,
437 &labels,
438 &self.config.default_locale,
439 base,
440 &self.config.url_prefix,
441 root_locale.as_deref(),
442 );
443
444 let Some(mut out) = inject_before_head_close(html, &links) else {
445 return Ok(html.to_string());
446 };
447
448 out = inject_lang_switcher(
451 &out,
452 &locale_for_file,
453 &locale_map,
454 &labels,
455 base,
456 &self.config.url_prefix,
457 root_locale.as_deref(),
458 );
459 out = rewrite_ap_lang_items(
460 &out,
461 &locale_map,
462 base,
463 &self.config.url_prefix,
464 root_locale.as_deref(),
465 );
466
467 Ok(out)
468 }
469}
470
471fn resolve_locale_and_rel(
479 path: &Path,
480 site_dir: &Path,
481 locales: &[String],
482 root_locale: Option<&str>,
483) -> Option<(String, String)> {
484 let rel = path.strip_prefix(site_dir).ok()?;
485 let mut comps = rel.components();
486 let first = comps.next()?.as_os_str().to_string_lossy().into_owned();
487
488 if locales.contains(&first) && root_locale != Some(first.as_str()) {
491 let remaining: PathBuf = comps.as_path().to_path_buf();
492 if remaining.as_os_str().is_empty() {
493 return None;
494 }
495 let rel_str = remaining.to_string_lossy().replace('\\', "/");
496 return Some((first, rel_str));
497 }
498
499 let root = root_locale?;
500 let rel_str = rel.to_string_lossy().replace('\\', "/");
501 if rel_str.is_empty() {
502 return None;
503 }
504 Some((root.to_string(), rel_str))
505}
506
507fn detect_locales(
522 site_dir: &Path,
523 locales: &[String],
524 default_locale: &str,
525) -> (Vec<String>, Option<String>) {
526 let dir_locales: Vec<String> = locales
527 .iter()
528 .filter(|l| site_dir.join(l).is_dir())
529 .cloned()
530 .collect();
531
532 let root_locale = if locales.iter().any(|l| l == default_locale)
533 && !dir_locales.iter().any(|l| l == default_locale)
534 && root_holds_html_outside(site_dir, &dir_locales)
535 {
536 Some(default_locale.to_string())
537 } else {
538 None
539 };
540
541 let present = locales
542 .iter()
543 .filter(|l| {
544 dir_locales.contains(l)
545 || root_locale.as_deref() == Some(l.as_str())
546 })
547 .cloned()
548 .collect();
549
550 (present, root_locale)
551}
552
553fn root_holds_html_outside(site_dir: &Path, locale_dirs: &[String]) -> bool {
560 let mut found = Vec::new();
561 collect_root_html_files(site_dir, site_dir, locale_dirs, "", &mut found)
562 .is_ok()
563 && !found.is_empty()
564}
565
566#[derive(Debug)]
570struct LocalePage {
571 locale: String,
573 rel: String,
577 site_rel: String,
580}
581
582fn collect_locale_pages(
595 site_dir: &Path,
596 sidecar_dir: &Path,
597 locales: &[String],
598 root_locale: Option<&str>,
599) -> Result<(LocaleMatrix, LocaleKeyIndex), SsgError> {
600 let locale_dirs: Vec<String> = locales
601 .iter()
602 .filter(|l| root_locale != Some(l.as_str()))
603 .cloned()
604 .collect();
605
606 let mut found: Vec<LocalePage> = Vec::new();
607 for locale in locales {
608 if root_locale == Some(locale.as_str()) {
609 collect_root_html_files(
610 site_dir,
611 site_dir,
612 &locale_dirs,
613 locale,
614 &mut found,
615 )?;
616 continue;
617 }
618 let locale_dir = site_dir.join(locale);
619 if !locale_dir.is_dir() {
620 continue;
621 }
622 collect_html_files_recursive(
623 &locale_dir,
624 &locale_dir,
625 locale,
626 locale,
627 &mut found,
628 )?;
629 }
630
631 let mut matrix: LocaleMatrix = HashMap::new();
632 let mut keys: LocaleKeyIndex = HashMap::new();
633 for page in found {
634 let key = translation_key_for(sidecar_dir, &page.site_rel)
635 .unwrap_or_else(|| page.rel.clone());
636 let _ = matrix
637 .entry(key.clone())
638 .or_default()
639 .insert(page.locale.clone(), page.rel.clone());
640 let _ = keys.insert((page.locale, page.rel), key);
641 }
642
643 Ok((matrix, keys))
644}
645
646fn translation_key_for(sidecar_dir: &Path, site_rel: &str) -> Option<String> {
655 for candidate in sidecar_candidates(site_rel) {
656 let path = sidecar_dir.join(candidate);
657 let Ok(raw) = fs::read_to_string(&path) else {
658 continue;
659 };
660 let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
661 continue;
662 };
663 if let Some(key) = value
664 .get("translation_key")
665 .and_then(serde_json::Value::as_str)
666 .map(str::trim)
667 .filter(|k| !k.is_empty())
668 {
669 return Some(key.to_string());
670 }
671 }
672 None
673}
674
675fn resolve_sidecar_dir(ctx: &PluginContext) -> PathBuf {
680 let build_meta = ctx.build_dir.join(".meta");
681 if build_meta.is_dir() {
682 build_meta
683 } else {
684 ctx.site_dir.join(".meta")
685 }
686}
687
688fn collect_root_html_files(
692 root: &Path,
693 current: &Path,
694 locale_dirs: &[String],
695 locale: &str,
696 out: &mut Vec<LocalePage>,
697) -> Result<(), SsgError> {
698 let entries = fs::read_dir(current).with_path(current)?;
699
700 for entry in entries {
701 let entry = entry.with_path(current)?;
702 let path = entry.path();
703 let name = entry.file_name().to_string_lossy().into_owned();
704 if path.is_dir() {
705 if name.starts_with('.') {
706 continue;
707 }
708 if current == root && locale_dirs.contains(&name) {
710 continue;
711 }
712 collect_root_html_files(root, &path, locale_dirs, locale, out)?;
713 } else if path.extension().is_some_and(|e| e == "html") {
714 let rel = rel_of(&path, root);
715 out.push(LocalePage {
716 locale: locale.to_string(),
717 site_rel: rel.clone(),
718 rel,
719 });
720 }
721 }
722
723 Ok(())
724}
725
726fn collect_html_files_recursive(
728 root: &Path,
729 current: &Path,
730 locale: &str,
731 locale_dir_name: &str,
732 out: &mut Vec<LocalePage>,
733) -> Result<(), SsgError> {
734 let entries = fs::read_dir(current).with_path(current)?;
735
736 for entry in entries {
737 let entry = entry.with_path(current)?;
738 let path = entry.path();
739 if path.is_dir() {
740 collect_html_files_recursive(
741 root,
742 &path,
743 locale,
744 locale_dir_name,
745 out,
746 )?;
747 } else if path.extension().is_some_and(|e| e == "html") {
748 let rel = rel_of(&path, root);
749 out.push(LocalePage {
750 locale: locale.to_string(),
751 site_rel: format!("{locale_dir_name}/{rel}"),
752 rel,
753 });
754 }
755 }
756
757 Ok(())
758}
759
760fn rel_of(path: &Path, root: &Path) -> String {
762 path.strip_prefix(root)
763 .unwrap_or(path)
764 .to_string_lossy()
765 .replace('\\', "/")
766}
767
768fn page_file_path(
771 site_dir: &Path,
772 locale: &str,
773 rel_path: &str,
774 root_locale: Option<&str>,
775) -> PathBuf {
776 if root_locale == Some(locale) {
777 site_dir.join(rel_path)
778 } else {
779 site_dir.join(locale).join(rel_path)
780 }
781}
782
783fn inject_hreflang_all(
794 ctx: &PluginContext,
795 pages: &LocaleMatrix,
796 locales: &[String],
797 default_locale: &str,
798 base_url: &str,
799 strategy: &UrlPrefixStrategy,
800 root_locale: Option<&str>,
801) -> Result<(), SsgError> {
802 let site_dir = ctx.site_dir.as_path();
803 let base = base_url.trim_end_matches('/');
804 let mut count = 0usize;
805
806 for locale_map in pages.values() {
807 if locale_map.len() < 2 {
809 continue;
810 }
811
812 for locale in locales {
813 let Some(rel_path) = locale_map.get(locale) else {
814 continue;
815 };
816
817 let file = page_file_path(site_dir, locale, rel_path, root_locale);
818 if !file.exists() {
819 continue;
820 }
821
822 let html = fs::read_to_string(&file).with_path(&file)?;
823
824 if html.contains(HREFLANG_MARKER) {
826 continue;
827 }
828
829 let self_lang =
832 crate::seo::lang::resolve_page_lang(&html, &file, ctx);
833
834 let labels = hreflang_labels(
835 ctx,
836 locale_map,
837 locale,
838 &self_lang,
839 root_locale,
840 );
841
842 let links = build_hreflang_links(
843 locale_map,
844 &labels,
845 default_locale,
846 base,
847 strategy,
848 root_locale,
849 );
850
851 let html = if let Some(injected) =
852 inject_before_head_close(&html, &links)
853 {
854 injected
855 } else {
856 html
857 };
858
859 let html = inject_lang_switcher(
861 &html,
862 locale,
863 locale_map,
864 &labels,
865 base,
866 strategy,
867 root_locale,
868 );
869
870 let html = rewrite_ap_lang_items(
872 &html,
873 locale_map,
874 base,
875 strategy,
876 root_locale,
877 );
878
879 fs::write(&file, html).with_path(&file)?;
880 count += 1;
881 }
882 }
883
884 if count > 0 {
885 println!(
886 "[i18n] Injected hreflang + lang switcher into {count} HTML pages"
887 );
888 }
889
890 Ok(())
891}
892
893fn hreflang_labels(
910 ctx: &PluginContext,
911 locale_map: &BTreeMap<String, String>,
912 self_locale: &str,
913 self_lang: &str,
914 root_locale: Option<&str>,
915) -> BTreeMap<String, String> {
916 let mut out = BTreeMap::new();
917 for (locale, rel) in locale_map {
918 let resolved = if locale == self_locale {
919 self_lang.to_string()
920 } else {
921 resolved_page_lang_for(ctx, locale, rel, root_locale)
922 };
923 let label = if resolved.eq_ignore_ascii_case(locale) {
924 locale.clone()
925 } else {
926 resolved
927 };
928 let _ = out.insert(locale.clone(), label);
929 }
930 out
931}
932
933fn inject_lang_switcher(
942 html: &str,
943 current_locale: &str,
944 locale_map: &BTreeMap<String, String>,
945 labels: &BTreeMap<String, String>,
946 base_url: &str,
947 strategy: &UrlPrefixStrategy,
948 root_locale: Option<&str>,
949) -> String {
950 let has_comment = html.contains(LANG_SWITCHER_MARKER);
951 let element = find_lang_switcher_element(html);
952 if !has_comment && element.is_none() {
953 return html.to_string();
954 }
955 let switcher = generate_lang_switcher_html_with_self_lang(
956 locale_map,
957 labels,
958 current_locale,
959 base_url,
960 strategy,
961 root_locale,
962 );
963 let out = if let Some((start, end)) = element {
964 let mut s = String::with_capacity(html.len() + switcher.len());
965 s.push_str(&html[..start]);
966 s.push_str(&switcher);
967 s.push_str(&html[end..]);
968 s
969 } else {
970 html.to_string()
971 };
972 out.replace(LANG_SWITCHER_MARKER, &switcher)
973}
974
975fn inject_before_head_close(html: &str, links: &str) -> Option<String> {
981 if !html.to_ascii_lowercase().contains("</head>") {
982 return None;
983 }
984 let result = inject_head(html, links);
985 if result == html {
986 None
987 } else {
988 Some(result)
989 }
990}
991
992fn generate_locale_sitemaps(
1004 ctx: &PluginContext,
1005 pages: &LocaleMatrix,
1006 locales: &[String],
1007 default_locale: &str,
1008 base_url: &str,
1009 strategy: &UrlPrefixStrategy,
1010 root_locale: Option<&str>,
1011) -> Result<(), SsgError> {
1012 let site_dir = ctx.site_dir.as_path();
1013 let base = base_url.trim_end_matches('/');
1014
1015 for locale in locales {
1016 let mut xml = String::from(
1017 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
1018 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\n\
1019 xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n",
1020 );
1021
1022 let mut entries: Vec<(&String, &BTreeMap<String, String>)> = pages
1025 .iter()
1026 .filter(|(_, locale_map)| locale_map.contains_key(locale))
1027 .collect();
1028 entries.sort_by(|a, b| a.1.get(locale).cmp(&b.1.get(locale)));
1029
1030 for (_key, locale_map) in entries {
1031 let Some(rel_path) = locale_map.get(locale) else {
1033 continue;
1034 };
1035 let loc = build_url(base, locale, rel_path, strategy, root_locale);
1036 xml.push_str(" <url>\n");
1037 xml.push_str(&format!(" <loc>{loc}</loc>\n"));
1038
1039 let self_lang =
1043 resolved_page_lang_for(ctx, locale, rel_path, root_locale);
1044 let labels = hreflang_labels(
1045 ctx,
1046 locale_map,
1047 locale,
1048 &self_lang,
1049 root_locale,
1050 );
1051
1052 for (alt_locale, alt_rel) in locale_map {
1053 let alt_href =
1054 build_url(base, alt_locale, alt_rel, strategy, root_locale);
1055 let hreflang = labels.get(alt_locale).unwrap_or(alt_locale);
1056 xml.push_str(&format!(
1057 " <xhtml:link rel=\"alternate\" hreflang=\"{hreflang}\" href=\"{alt_href}\" />\n"
1058 ));
1059 }
1060 if let Some(default_rel) = locale_map.get(default_locale) {
1062 let default_href = build_url(
1063 base,
1064 default_locale,
1065 default_rel,
1066 strategy,
1067 root_locale,
1068 );
1069 xml.push_str(&format!(
1070 " <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{default_href}\" />\n"
1071 ));
1072 }
1073
1074 xml.push_str(" </url>\n");
1075 }
1076
1077 xml.push_str("</urlset>\n");
1078
1079 let sitemap_path = site_dir.join(format!("sitemap-{locale}.xml"));
1080 fs::write(&sitemap_path, &xml).with_path(&sitemap_path)?;
1081 }
1082
1083 println!("[i18n] Generated {} locale sitemaps", locales.len());
1084 Ok(())
1085}
1086
1087fn resolved_page_lang_for(
1094 ctx: &PluginContext,
1095 locale: &str,
1096 rel_path: &str,
1097 root_locale: Option<&str>,
1098) -> String {
1099 let file = page_file_path(&ctx.site_dir, locale, rel_path, root_locale);
1100 fs::read_to_string(&file).map_or_else(
1101 |_| locale.to_string(),
1102 |html| crate::seo::lang::resolve_page_lang(&html, &file, ctx),
1103 )
1104}
1105
1106#[cfg(test)]
1111mod tests {
1112 use super::*;
1113 use crate::plugin::PluginContext;
1114 use std::path::Path;
1115 use tempfile::tempdir;
1116
1117 fn locale_map(locales: &[&str], rel: &str) -> BTreeMap<String, String> {
1120 locales
1121 .iter()
1122 .map(|l| ((*l).to_string(), rel.to_string()))
1123 .collect()
1124 }
1125
1126 #[test]
1127 fn test_rewrite_ap_lang_items() {
1128 let input = r#"<a class="ap-lang-item" href="/fr/" data-lang="fr" role="menuitem">Français</a>"#;
1129 let locales = locale_map(&["en", "fr"], "posts/hello.html");
1130 let output = rewrite_ap_lang_items(
1131 input,
1132 &locales,
1133 "https://sebastienrousseau.com",
1134 &UrlPrefixStrategy::SubPath,
1135 None,
1136 );
1137 assert!(output.contains(r#"href="/fr/posts/hello.html""#));
1138 }
1139
1140 fn make_ctx(site_dir: &Path) -> PluginContext {
1141 let config = crate::cmd::SsgConfig::builder()
1142 .site_name("test".to_string())
1143 .base_url("https://example.com".to_string())
1144 .build()
1145 .expect("test config");
1146 PluginContext::with_config(
1147 Path::new("content"),
1148 Path::new("build"),
1149 site_dir,
1150 Path::new("templates"),
1151 config,
1152 )
1153 }
1154
1155 fn make_ctx_with_build(site_dir: &Path, build_dir: &Path) -> PluginContext {
1158 let config = crate::cmd::SsgConfig::builder()
1159 .site_name("test".to_string())
1160 .base_url("https://example.com".to_string())
1161 .build()
1162 .expect("test config");
1163 PluginContext::with_config(
1164 Path::new("content"),
1165 build_dir,
1166 site_dir,
1167 Path::new("templates"),
1168 config,
1169 )
1170 }
1171
1172 fn write_lang_sidecar(build_dir: &Path, rel_html: &str, json: &str) {
1174 let sidecar = build_dir
1175 .join(".meta")
1176 .join(rel_html)
1177 .with_extension("meta.json");
1178 fs::create_dir_all(sidecar.parent().expect("parent")).expect("mkdir");
1179 fs::write(sidecar, json).expect("write sidecar");
1180 }
1181
1182 fn write_html(dir: &Path, rel: &str, body: &str) {
1184 let path = dir.join(rel);
1185 let parent = path.parent().expect("joined path has a parent");
1188 fs::create_dir_all(parent).expect("mkdir");
1189 let html = format!(
1190 "<!DOCTYPE html><html><head><title>Test</title></head><body>{body}</body></html>"
1191 );
1192 fs::write(&path, html).expect("write html");
1193 }
1194
1195 #[test]
1198 fn detect_finds_existing_locale_dirs() {
1199 let tmp = tempdir().unwrap();
1200 fs::create_dir(tmp.path().join("en")).unwrap();
1201 fs::create_dir(tmp.path().join("fr")).unwrap();
1202
1203 let (found, root) = detect_locales(
1204 tmp.path(),
1205 &["en".into(), "fr".into(), "de".into()],
1206 "en",
1207 );
1208 assert_eq!(found, vec!["en", "fr"]);
1209 assert_eq!(root, None, "en has its own directory — not root-hosted");
1210 }
1211
1212 #[test]
1213 fn detect_returns_empty_when_none_exist() {
1214 let tmp = tempdir().unwrap();
1215 let (found, root) =
1216 detect_locales(tmp.path(), &["en".into(), "fr".into()], "en");
1217 assert!(found.is_empty());
1218 assert_eq!(root, None, "an empty site root hosts no default locale");
1219 }
1220
1221 #[test]
1224 fn injects_hreflang_into_shared_pages() {
1225 let tmp = tempdir().unwrap();
1226 let site = tmp.path();
1227
1228 write_html(site, "en/index.html", "Hello");
1229 write_html(site, "fr/index.html", "Bonjour");
1230
1231 let config = I18nConfig {
1232 default_locale: "en".into(),
1233 locales: vec!["en".into(), "fr".into()],
1234 url_prefix: UrlPrefixStrategy::SubPath,
1235 };
1236
1237 let ctx = make_ctx(site);
1238 let plugin = I18nPlugin::new(config);
1239 plugin.after_compile(&ctx).unwrap();
1240
1241 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
1243 let fr = fs::read_to_string(site.join("fr/index.html")).unwrap();
1244
1245 assert!(en.contains(HREFLANG_MARKER), "en missing hreflang");
1246 assert!(fr.contains(HREFLANG_MARKER), "fr missing hreflang");
1247
1248 assert!(
1250 en.contains("hreflang=\"x-default\""),
1251 "en missing x-default"
1252 );
1253 assert!(
1254 en.contains("https://example.com/en/index.html"),
1255 "en x-default wrong href"
1256 );
1257 }
1258
1259 #[test]
1260 fn skips_pages_existing_in_only_one_locale() {
1261 let tmp = tempdir().unwrap();
1262 let site = tmp.path();
1263
1264 write_html(site, "en/index.html", "Hello");
1265 write_html(site, "en/about.html", "About");
1266 write_html(site, "fr/index.html", "Bonjour");
1268
1269 let config = I18nConfig {
1270 default_locale: "en".into(),
1271 locales: vec!["en".into(), "fr".into()],
1272 url_prefix: UrlPrefixStrategy::SubPath,
1273 };
1274
1275 let ctx = make_ctx(site);
1276 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1277
1278 let about = fs::read_to_string(site.join("en/about.html")).unwrap();
1280 assert!(
1281 !about.contains(HREFLANG_MARKER),
1282 "about.html should not have hreflang"
1283 );
1284 }
1285
1286 #[test]
1287 fn idempotent_injection() {
1288 let tmp = tempdir().unwrap();
1289 let site = tmp.path();
1290
1291 write_html(site, "en/index.html", "Hello");
1292 write_html(site, "fr/index.html", "Bonjour");
1293
1294 let config = I18nConfig {
1295 default_locale: "en".into(),
1296 locales: vec!["en".into(), "fr".into()],
1297 url_prefix: UrlPrefixStrategy::SubPath,
1298 };
1299
1300 let ctx = make_ctx(site);
1301 let plugin = I18nPlugin::new(config);
1302
1303 plugin.after_compile(&ctx).unwrap();
1305 plugin.after_compile(&ctx).unwrap();
1306
1307 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
1308 let count = en.matches(HREFLANG_MARKER).count();
1309 assert_eq!(count, 3, "expected 3 hreflang links, got {count}");
1311 }
1312
1313 #[test]
1316 fn x_default_points_to_default_locale() {
1317 let tmp = tempdir().unwrap();
1318 let site = tmp.path();
1319
1320 write_html(site, "en/page.html", "EN");
1321 write_html(site, "fr/page.html", "FR");
1322 write_html(site, "de/page.html", "DE");
1323
1324 let config = I18nConfig {
1325 default_locale: "fr".into(),
1326 locales: vec!["en".into(), "fr".into(), "de".into()],
1327 url_prefix: UrlPrefixStrategy::SubPath,
1328 };
1329
1330 let ctx = make_ctx(site);
1331 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1332
1333 let en = fs::read_to_string(site.join("en/page.html")).unwrap();
1334 assert!(
1336 en.contains("hreflang=\"x-default\" href=\"https://example.com/fr/page.html\""),
1337 "x-default should point to fr"
1338 );
1339 }
1340
1341 #[test]
1344 fn three_locale_injection() {
1345 let tmp = tempdir().unwrap();
1346 let site = tmp.path();
1347
1348 write_html(site, "en/index.html", "EN");
1349 write_html(site, "fr/index.html", "FR");
1350 write_html(site, "de/index.html", "DE");
1351
1352 let config = I18nConfig {
1353 default_locale: "en".into(),
1354 locales: vec!["en".into(), "fr".into(), "de".into()],
1355 url_prefix: UrlPrefixStrategy::SubPath,
1356 };
1357
1358 let ctx = make_ctx(site);
1359 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1360
1361 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
1362 let count = en.matches(HREFLANG_MARKER).count();
1364 assert_eq!(
1365 count, 4,
1366 "expected 4 hreflang links for 3 locales + x-default"
1367 );
1368 }
1369
1370 #[test]
1373 fn generates_per_locale_sitemaps() {
1374 let tmp = tempdir().unwrap();
1375 let site = tmp.path();
1376
1377 write_html(site, "en/index.html", "EN");
1378 write_html(site, "fr/index.html", "FR");
1379
1380 let config = I18nConfig {
1381 default_locale: "en".into(),
1382 locales: vec!["en".into(), "fr".into()],
1383 url_prefix: UrlPrefixStrategy::SubPath,
1384 };
1385
1386 let ctx = make_ctx(site);
1387 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1388
1389 let en_sm = site.join("sitemap-en.xml");
1390 let fr_sm = site.join("sitemap-fr.xml");
1391 assert!(en_sm.exists(), "sitemap-en.xml should exist");
1392 assert!(fr_sm.exists(), "sitemap-fr.xml should exist");
1393
1394 let en_content = fs::read_to_string(&en_sm).unwrap();
1395 assert!(
1396 en_content.contains("<loc>https://example.com/en/index.html</loc>")
1397 );
1398 assert!(en_content.contains("xhtml:link"));
1399 assert!(en_content.contains("hreflang=\"x-default\""));
1400 }
1401
1402 #[test]
1405 fn subdomain_strategy_builds_correct_urls() {
1406 let url = build_url(
1407 "https://example.com",
1408 "fr",
1409 "about/index.html",
1410 &UrlPrefixStrategy::SubDomain,
1411 None,
1412 );
1413 assert_eq!(url, "https://fr.example.com/about/index.html");
1414 }
1415
1416 #[test]
1417 fn subpath_strategy_builds_correct_urls() {
1418 let url = build_url(
1419 "https://example.com",
1420 "fr",
1421 "about/index.html",
1422 &UrlPrefixStrategy::SubPath,
1423 None,
1424 );
1425 assert_eq!(url, "https://example.com/fr/about/index.html");
1426 }
1427
1428 #[test]
1431 fn lang_switcher_html() {
1432 let html = generate_lang_switcher_html(
1433 &["en".into(), "fr".into()],
1434 "en",
1435 "about/index.html",
1436 "https://example.com",
1437 &UrlPrefixStrategy::SubPath,
1438 );
1439 assert!(html.contains("lang=\"en\""));
1440 assert!(html.contains("lang=\"fr\""));
1441 assert!(html.contains("aria-current=\"page\""));
1442 assert!(html.contains("class=\"lang-switcher\""));
1443 }
1444
1445 #[test]
1448 fn inject_before_head_close_works() {
1449 let html = "<html><head><title>T</title></head><body></body></html>";
1450 let result = inject_before_head_close(html, "INJECTED\n").unwrap();
1451 assert!(result.contains("INJECTED\n</head>"));
1452 }
1453
1454 #[test]
1455 fn inject_before_head_close_returns_none_without_head() {
1456 let html = "<html><body>no head</body></html>";
1457 assert!(inject_before_head_close(html, "X").is_none());
1458 }
1459
1460 #[test]
1463 fn plugin_name() {
1464 let p = I18nPlugin::new(I18nConfig::default());
1465 assert_eq!(p.name(), "i18n");
1466 }
1467
1468 #[test]
1469 fn plugin_skips_nonexistent_site_dir() {
1470 let ctx = PluginContext::new(
1471 Path::new("c"),
1472 Path::new("b"),
1473 Path::new("/does/not/exist"),
1474 Path::new("t"),
1475 );
1476 let p = I18nPlugin::new(I18nConfig {
1477 default_locale: "en".into(),
1478 locales: vec!["en".into(), "fr".into()],
1479 url_prefix: UrlPrefixStrategy::SubPath,
1480 });
1481 assert!(p.after_compile(&ctx).is_ok());
1482 }
1483
1484 #[test]
1485 fn plugin_skips_single_locale() {
1486 let tmp = tempdir().unwrap();
1487 let ctx = make_ctx(tmp.path());
1488 let p = I18nPlugin::new(I18nConfig::default());
1489 assert!(p.after_compile(&ctx).is_ok());
1491 }
1492
1493 #[test]
1496 fn default_config() {
1497 let cfg = I18nConfig::default();
1498 assert_eq!(cfg.default_locale, "en");
1499 assert_eq!(cfg.locales, vec!["en"]);
1500 assert_eq!(cfg.url_prefix, UrlPrefixStrategy::SubPath);
1501 }
1502
1503 #[test]
1508 fn lang_switcher_empty_locales() {
1509 let html = generate_lang_switcher_html(
1510 &[],
1511 "en",
1512 "index.html",
1513 "https://example.com",
1514 &UrlPrefixStrategy::SubPath,
1515 );
1516 assert!(html.contains("<nav"));
1517 assert!(html.contains("</nav>"));
1518 assert!(!html.contains("<li>"));
1520 }
1521
1522 #[test]
1523 fn lang_switcher_single_locale() {
1524 let html = generate_lang_switcher_html(
1525 &["en".into()],
1526 "en",
1527 "index.html",
1528 "https://example.com",
1529 &UrlPrefixStrategy::SubPath,
1530 );
1531 assert!(html.contains("aria-current=\"page\""));
1532 assert_eq!(html.matches("<li>").count(), 1);
1534 }
1535
1536 #[test]
1537 fn lang_switcher_subdomain_strategy() {
1538 let html = generate_lang_switcher_html(
1539 &["en".into(), "fr".into()],
1540 "fr",
1541 "about/index.html",
1542 "https://example.com",
1543 &UrlPrefixStrategy::SubDomain,
1544 );
1545 assert!(html.contains("https://en.example.com/about/index.html"));
1546 assert!(html.contains("https://fr.example.com/about/index.html"));
1547 }
1548
1549 #[test]
1552 fn sitemap_contains_xhtml_link_alternates() {
1553 let tmp = tempdir().unwrap();
1554 let site = tmp.path();
1555
1556 write_html(site, "en/index.html", "EN");
1557 write_html(site, "fr/index.html", "FR");
1558 write_html(site, "de/index.html", "DE");
1559
1560 let config = I18nConfig {
1561 default_locale: "en".into(),
1562 locales: vec!["en".into(), "fr".into(), "de".into()],
1563 url_prefix: UrlPrefixStrategy::SubPath,
1564 };
1565
1566 let ctx = make_ctx(site);
1567 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1568
1569 let en_sm = fs::read_to_string(site.join("sitemap-en.xml")).unwrap();
1570 assert!(en_sm.contains("hreflang=\"en\""));
1572 assert!(en_sm.contains("hreflang=\"fr\""));
1573 assert!(en_sm.contains("hreflang=\"de\""));
1574 assert!(en_sm.contains("hreflang=\"x-default\""));
1575 assert!(en_sm.contains(
1577 "hreflang=\"x-default\" href=\"https://example.com/en/index.html\""
1578 ));
1579 }
1580
1581 fn a5_fixture(tmp: &Path) -> PluginContext {
1586 let site = tmp.join("site");
1587 let build = tmp.join("build");
1588 fs::create_dir_all(&site).expect("mkdir site");
1589 write_html(&site, "en/about.html", "EN About");
1590 write_html(&site, "hi/about.html", "HI About");
1591 write_lang_sidecar(&build, "hi/about.html", r#"{"language":"hi-IN"}"#);
1592 make_ctx_with_build(&site, &build)
1593 }
1594
1595 #[test]
1596 fn hreflang_self_reference_uses_resolved_page_language() {
1597 let tmp = tempdir().unwrap();
1598 let ctx = a5_fixture(tmp.path());
1599 let site = ctx.site_dir.clone();
1600
1601 let config = I18nConfig {
1602 default_locale: "en".into(),
1603 locales: vec!["en".into(), "hi".into()],
1604 url_prefix: UrlPrefixStrategy::SubPath,
1605 };
1606 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1607
1608 let hi = fs::read_to_string(site.join("hi/about.html")).unwrap();
1609 assert!(
1612 hi.contains(
1613 "hreflang=\"hi-IN\" href=\"https://example.com/hi/about.html\""
1614 ),
1615 "hi self-reference should be resolved to hi-IN: {hi}"
1616 );
1617 assert!(
1619 hi.contains(
1620 "hreflang=\"en\" href=\"https://example.com/en/about.html\""
1621 ),
1622 "alternate to en must keep its label: {hi}"
1623 );
1624
1625 let en = fs::read_to_string(site.join("en/about.html")).unwrap();
1626 assert!(
1633 en.contains(
1634 "hreflang=\"hi-IN\" href=\"https://example.com/hi/about.html\""
1635 ),
1636 "en page's alternate to hi must carry hi's resolved language: {en}"
1637 );
1638 assert!(
1639 en.contains(
1640 "hreflang=\"en\" href=\"https://example.com/en/about.html\""
1641 ),
1642 "en self-reference resolves to en: {en}"
1643 );
1644 }
1645
1646 #[test]
1647 fn locale_sitemap_self_reference_uses_resolved_page_language() {
1648 let tmp = tempdir().unwrap();
1649 let ctx = a5_fixture(tmp.path());
1650 let site = ctx.site_dir.clone();
1651
1652 let config = I18nConfig {
1653 default_locale: "en".into(),
1654 locales: vec!["en".into(), "hi".into()],
1655 url_prefix: UrlPrefixStrategy::SubPath,
1656 };
1657 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1658
1659 let hi_sm = fs::read_to_string(site.join("sitemap-hi.xml")).unwrap();
1660 assert!(
1663 hi_sm.contains(
1664 "hreflang=\"hi-IN\" href=\"https://example.com/hi/about.html\""
1665 ),
1666 "sitemap-hi self alternate should be hi-IN: {hi_sm}"
1667 );
1668 assert!(
1669 hi_sm.contains(
1670 "hreflang=\"en\" href=\"https://example.com/en/about.html\""
1671 ),
1672 "sitemap-hi alternate to en keeps its label: {hi_sm}"
1673 );
1674
1675 let en_sm = fs::read_to_string(site.join("sitemap-en.xml")).unwrap();
1676 assert!(
1680 en_sm.contains(
1681 "hreflang=\"hi-IN\" href=\"https://example.com/hi/about.html\""
1682 ),
1683 "sitemap-en alternate to hi must carry hi's resolved language: {en_sm}"
1684 );
1685 }
1686
1687 #[test]
1688 fn lang_switcher_self_entry_uses_resolved_page_language() {
1689 let tmp = tempdir().unwrap();
1690 let site = tmp.path().join("site");
1691 let build = tmp.path().join("build");
1692 fs::create_dir_all(&site).unwrap();
1693 write_html(&site, "en/page.html", LANG_SWITCHER_MARKER);
1695 write_html(&site, "hi/page.html", LANG_SWITCHER_MARKER);
1696 write_lang_sidecar(&build, "hi/page.html", r#"{"language":"hi-IN"}"#);
1697 let ctx = make_ctx_with_build(&site, &build);
1698
1699 let config = I18nConfig {
1700 default_locale: "en".into(),
1701 locales: vec!["en".into(), "hi".into()],
1702 url_prefix: UrlPrefixStrategy::SubPath,
1703 };
1704 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1705
1706 let hi = fs::read_to_string(site.join("hi/page.html")).unwrap();
1707 assert!(
1710 hi.contains(
1711 "lang=\"hi-IN\" hreflang=\"hi-IN\" aria-current=\"page\">hi</a>"
1712 ),
1713 "switcher self entry should use the resolved language: {hi}"
1714 );
1715 assert!(
1717 hi.contains("lang=\"en\" hreflang=\"en\">en</a>"),
1718 "switcher alternate entry keeps the locale label: {hi}"
1719 );
1720 }
1721
1722 #[test]
1723 fn transform_html_self_reference_uses_resolved_page_language() {
1724 let tmp = tempdir().unwrap();
1725 let ctx = a5_fixture(tmp.path());
1726 let site = ctx.site_dir.clone();
1727
1728 let config = I18nConfig {
1729 default_locale: "en".into(),
1730 locales: vec!["en".into(), "hi".into()],
1731 url_prefix: UrlPrefixStrategy::SubPath,
1732 };
1733 let plugin = I18nPlugin::new(config);
1734
1735 let hi_path = site.join("hi/about.html");
1736 let html = fs::read_to_string(&hi_path).unwrap();
1737 let out = plugin.transform_html(&html, &hi_path, &ctx).unwrap();
1738 assert!(
1739 out.contains(
1740 "hreflang=\"hi-IN\" href=\"https://example.com/hi/about.html\""
1741 ),
1742 "fused-transform self-reference should be resolved: {out}"
1743 );
1744 assert!(
1745 out.contains(
1746 "hreflang=\"en\" href=\"https://example.com/en/about.html\""
1747 ),
1748 "fused-transform alternate keeps its label: {out}"
1749 );
1750 }
1751
1752 #[test]
1755 fn plugin_with_locale_dirs_but_no_shared_pages_skips_injection() {
1756 let tmp = tempdir().unwrap();
1757 let site = tmp.path();
1758
1759 write_html(site, "en/about.html", "EN About");
1761 write_html(site, "fr/contact.html", "FR Contact");
1762
1763 let config = I18nConfig {
1764 default_locale: "en".into(),
1765 locales: vec!["en".into(), "fr".into()],
1766 url_prefix: UrlPrefixStrategy::SubPath,
1767 };
1768
1769 let ctx = make_ctx(site);
1770 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1771
1772 let en = fs::read_to_string(site.join("en/about.html")).unwrap();
1774 let fr = fs::read_to_string(site.join("fr/contact.html")).unwrap();
1775 assert!(!en.contains(HREFLANG_MARKER));
1776 assert!(!fr.contains(HREFLANG_MARKER));
1777 }
1778
1779 #[test]
1780 fn plugin_skips_when_only_one_locale_dir_exists() {
1781 let tmp = tempdir().unwrap();
1782 let site = tmp.path();
1783
1784 write_html(site, "en/index.html", "EN");
1786
1787 let config = I18nConfig {
1788 default_locale: "en".into(),
1789 locales: vec!["en".into(), "fr".into()],
1790 url_prefix: UrlPrefixStrategy::SubPath,
1791 };
1792
1793 let ctx = make_ctx(site);
1794 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1795
1796 let en = fs::read_to_string(site.join("en/index.html")).unwrap();
1797 assert!(!en.contains(HREFLANG_MARKER));
1798 }
1799
1800 #[test]
1803 fn root_hosted_default_locale_gets_reciprocal_hreflang() {
1804 let tmp = tempdir().unwrap();
1813 let site = tmp.path();
1814
1815 write_html(site, "index.html", "Hello");
1816 write_html(site, "about/index.html", "About");
1817 write_html(site, "fr/index.html", "Bonjour");
1818 write_html(site, "fr/about/index.html", "À propos");
1819
1820 let config = I18nConfig {
1821 default_locale: "en".into(),
1822 locales: vec!["en".into(), "fr".into()],
1823 url_prefix: UrlPrefixStrategy::SubPath,
1824 };
1825
1826 let ctx = make_ctx(site);
1827 I18nPlugin::new(config).after_compile(&ctx).unwrap();
1828
1829 let en = fs::read_to_string(site.join("index.html")).unwrap();
1830 let fr = fs::read_to_string(site.join("fr/index.html")).unwrap();
1831
1832 assert!(
1833 en.contains(HREFLANG_MARKER),
1834 "root-hosted en home page must receive hreflang: {en}"
1835 );
1836 assert!(
1837 fr.contains(HREFLANG_MARKER),
1838 "fr home page must receive hreflang: {fr}"
1839 );
1840
1841 assert!(
1844 en.contains("href=\"https://example.com/index.html\""),
1845 "root-hosted self URL must omit the locale segment: {en}"
1846 );
1847 assert!(
1848 en.contains("href=\"https://example.com/fr/index.html\""),
1849 "en must point at the fr alternate: {en}"
1850 );
1851 assert!(
1853 fr.contains("href=\"https://example.com/index.html\""),
1854 "fr must link back to the root-hosted en page: {fr}"
1855 );
1856 assert!(
1857 en.contains(
1858 "hreflang=\"x-default\" href=\"https://example.com/index.html\""
1859 ),
1860 "x-default must resolve through the root-hosted default: {en}"
1861 );
1862 for (name, page) in [("en", &en), ("fr", &fr)] {
1863 assert!(
1864 !page.contains("https://example.com/en/"),
1865 "{name} page must never emit an /en/ URL: {page}"
1866 );
1867 }
1868
1869 let en_about =
1871 fs::read_to_string(site.join("about/index.html")).unwrap();
1872 assert!(
1873 en_about
1874 .contains("href=\"https://example.com/fr/about/index.html\""),
1875 "nested root-hosted page must find its fr alternate: {en_about}"
1876 );
1877 assert!(
1878 en_about.contains("href=\"https://example.com/about/index.html\""),
1879 "nested root-hosted self URL must omit the segment: {en_about}"
1880 );
1881
1882 assert!(
1885 !en.contains("ssg-locale-redirect"),
1886 "root-hosted home page must not be replaced by the redirect shim"
1887 );
1888 }
1889
1890 #[test]
1891 fn detect_locales_reports_root_hosted_default_locale() {
1892 let tmp = tempdir().unwrap();
1893 fs::create_dir(tmp.path().join("fr")).unwrap();
1894 write_html(tmp.path(), "index.html", "Hello");
1895
1896 let (present, root) =
1897 detect_locales(tmp.path(), &["en".into(), "fr".into()], "en");
1898
1899 assert_eq!(present, vec!["en", "fr"]);
1900 assert_eq!(root.as_deref(), Some("en"));
1901 }
1902
1903 #[test]
1904 fn detect_locales_ignores_root_html_when_default_has_its_own_dir() {
1905 let tmp = tempdir().unwrap();
1908 fs::create_dir(tmp.path().join("en")).unwrap();
1909 fs::create_dir(tmp.path().join("fr")).unwrap();
1910 write_html(tmp.path(), "index.html", "redirect");
1911
1912 let (present, root) =
1913 detect_locales(tmp.path(), &["en".into(), "fr".into()], "en");
1914
1915 assert_eq!(present, vec!["en", "fr"]);
1916 assert_eq!(root, None);
1917 }
1918
1919 #[test]
1920 fn detect_locales_needs_root_html_to_call_the_default_root_hosted() {
1921 let tmp = tempdir().unwrap();
1925 fs::create_dir(tmp.path().join("fr")).unwrap();
1926
1927 let (present, root) =
1928 detect_locales(tmp.path(), &["en".into(), "fr".into()], "en");
1929
1930 assert_eq!(present, vec!["fr"]);
1931 assert_eq!(root, None);
1932 }
1933
1934 #[test]
1935 fn build_url_root_hosted_locale_omits_the_locale_segment() {
1936 for strategy in
1937 [UrlPrefixStrategy::SubPath, UrlPrefixStrategy::SubDomain]
1938 {
1939 assert_eq!(
1940 build_url(
1941 "https://example.com",
1942 "en",
1943 "about/index.html",
1944 &strategy,
1945 Some("en"),
1946 ),
1947 "https://example.com/about/index.html",
1948 "root-hosted locale keeps no segment under {strategy:?}"
1949 );
1950 }
1951 assert_eq!(
1953 build_url(
1954 "https://example.com",
1955 "fr",
1956 "about/index.html",
1957 &UrlPrefixStrategy::SubPath,
1958 Some("en"),
1959 ),
1960 "https://example.com/fr/about/index.html"
1961 );
1962 }
1963
1964 #[test]
1965 fn resolve_locale_and_rel_assigns_root_pages_to_the_root_locale() {
1966 let tmp = tempdir().unwrap();
1967 let site = tmp.path();
1968 let locales = vec!["en".to_string(), "fr".to_string()];
1969
1970 let (locale, rel) = resolve_locale_and_rel(
1972 &site.join("about/index.html"),
1973 site,
1974 &locales,
1975 Some("en"),
1976 )
1977 .unwrap();
1978 assert_eq!((locale.as_str(), rel.as_str()), ("en", "about/index.html"));
1979
1980 let (locale, rel) = resolve_locale_and_rel(
1982 &site.join("fr/about/index.html"),
1983 site,
1984 &locales,
1985 Some("en"),
1986 )
1987 .unwrap();
1988 assert_eq!((locale.as_str(), rel.as_str()), ("fr", "about/index.html"));
1989
1990 assert!(resolve_locale_and_rel(
1992 &site.join("about/index.html"),
1993 site,
1994 &locales,
1995 None
1996 )
1997 .is_none());
1998 }
1999
2000 #[test]
2001 fn collect_locale_pages_root_walk_skips_locale_and_dot_dirs() {
2002 let tmp = tempdir().unwrap();
2003 let site = tmp.path();
2004 write_html(site, "index.html", "EN");
2005 write_html(site, "guides/one/index.html", "EN nested");
2006 write_html(site, "fr/index.html", "FR");
2007 write_html(site, ".meta/leak.html", "meta");
2009 write_html(site, ".ssg-cache/leak.html", "cache");
2010
2011 let (pages, keys) = collect_locale_pages(
2012 site,
2013 &site.join(".meta"),
2014 &["en".to_string(), "fr".to_string()],
2015 Some("en"),
2016 )
2017 .unwrap();
2018
2019 let mut en_paths: Vec<&String> = pages
2020 .values()
2021 .filter_map(|locale_map| locale_map.get("en"))
2022 .collect();
2023 en_paths.sort();
2024 assert_eq!(
2025 en_paths,
2026 vec!["guides/one/index.html", "index.html"],
2027 "root walk must skip fr/ and every dot-directory"
2028 );
2029 assert!(
2030 keys.contains_key(&("en".to_string(), "index.html".to_string())),
2031 "the reverse index must resolve every collected page"
2032 );
2033 assert_eq!(
2034 pages.get("index.html").map(BTreeMap::len),
2035 Some(2),
2036 "index.html is served by both locales"
2037 );
2038 }
2039
2040 fn write_content_sidecar(build_dir: &Path, content_rel: &str, json: &str) {
2047 let sidecar = build_dir
2048 .join(".meta")
2049 .join(format!("{content_rel}.meta.json"));
2050 fs::create_dir_all(sidecar.parent().expect("parent")).expect("mkdir");
2051 fs::write(sidecar, json).expect("write sidecar");
2052 }
2053
2054 #[test]
2055 fn translation_key_links_pages_with_translated_slugs() {
2056 let tmp = tempdir().unwrap();
2061 let site = tmp.path().join("site");
2062 let build = tmp.path().join("build");
2063 fs::create_dir_all(&site).unwrap();
2064
2065 write_html(&site, "index.html", "Home");
2066 write_html(&site, "about/index.html", "About");
2067 write_html(&site, "fr/index.html", "Accueil");
2068 write_html(&site, "fr/a-propos/index.html", "À propos");
2069
2070 write_content_sidecar(&build, "index", r#"{"translation_key":"home"}"#);
2071 write_content_sidecar(
2072 &build,
2073 "about",
2074 r#"{"translation_key":"about"}"#,
2075 );
2076 write_content_sidecar(
2077 &build,
2078 "fr/index",
2079 r#"{"translation_key":"home"}"#,
2080 );
2081 write_content_sidecar(
2082 &build,
2083 "fr/a-propos",
2084 r#"{"translation_key":"about"}"#,
2085 );
2086
2087 let config = I18nConfig {
2088 default_locale: "en".into(),
2089 locales: vec!["en".into(), "fr".into()],
2090 url_prefix: UrlPrefixStrategy::SubPath,
2091 };
2092
2093 let ctx = make_ctx_with_build(&site, &build);
2094 I18nPlugin::new(config).after_compile(&ctx).unwrap();
2095
2096 let en_about =
2097 fs::read_to_string(site.join("about/index.html")).unwrap();
2098 let fr_about =
2099 fs::read_to_string(site.join("fr/a-propos/index.html")).unwrap();
2100
2101 assert!(
2102 en_about.contains(HREFLANG_MARKER),
2103 "/about/ must be linked to its translation: {en_about}"
2104 );
2105 assert!(
2106 en_about.contains(
2107 "href=\"https://example.com/fr/a-propos/index.html\""
2108 ),
2109 "/about/ must point at the TRANSLATED fr slug: {en_about}"
2110 );
2111 assert!(
2112 !en_about.contains("https://example.com/fr/about/"),
2113 "the untranslated fr slug must never be emitted: {en_about}"
2114 );
2115 assert!(
2116 fr_about.contains("href=\"https://example.com/about/index.html\""),
2117 "/fr/a-propos/ must link back to the en slug: {fr_about}"
2118 );
2119
2120 let en_home = fs::read_to_string(site.join("index.html")).unwrap();
2122 assert!(
2123 en_home.contains("href=\"https://example.com/fr/index.html\""),
2124 "home pages must still pair up: {en_home}"
2125 );
2126 }
2127
2128 #[test]
2129 fn pages_without_translation_key_keep_path_matching() {
2130 let tmp = tempdir().unwrap();
2133 let site = tmp.path().join("site");
2134 let build = tmp.path().join("build");
2135 fs::create_dir_all(&site).unwrap();
2136 write_html(&site, "en/about.html", "About");
2137 write_html(&site, "fr/about.html", "À propos");
2138
2139 let config = I18nConfig {
2140 default_locale: "en".into(),
2141 locales: vec!["en".into(), "fr".into()],
2142 url_prefix: UrlPrefixStrategy::SubPath,
2143 };
2144
2145 let ctx = make_ctx_with_build(&site, &build);
2146 I18nPlugin::new(config).after_compile(&ctx).unwrap();
2147
2148 let en = fs::read_to_string(site.join("en/about.html")).unwrap();
2149 assert!(
2150 en.contains("href=\"https://example.com/fr/about.html\""),
2151 "path matching must survive as the fallback: {en}"
2152 );
2153 }
2154
2155 #[test]
2158 fn subdomain_strategy_fallback_without_scheme() {
2159 let url = build_url(
2161 "example.com",
2162 "fr",
2163 "page.html",
2164 &UrlPrefixStrategy::SubDomain,
2165 None,
2166 );
2167 assert_eq!(url, "example.com/fr/page.html");
2168 }
2169
2170 #[test]
2171 fn nested_pages_get_hreflang() {
2172 let tmp = tempdir().unwrap();
2173 let site = tmp.path();
2174
2175 write_html(site, "en/docs/guide.html", "EN Guide");
2176 write_html(site, "fr/docs/guide.html", "FR Guide");
2177
2178 let config = I18nConfig {
2179 default_locale: "en".into(),
2180 locales: vec!["en".into(), "fr".into()],
2181 url_prefix: UrlPrefixStrategy::SubPath,
2182 };
2183
2184 let ctx = make_ctx(site);
2185 I18nPlugin::new(config).after_compile(&ctx).unwrap();
2186
2187 let en = fs::read_to_string(site.join("en/docs/guide.html")).unwrap();
2188 assert!(en.contains(HREFLANG_MARKER));
2189 assert!(en.contains("https://example.com/en/docs/guide.html"));
2190 assert!(en.contains("https://example.com/fr/docs/guide.html"));
2191 }
2192
2193 #[test]
2196 fn parse_accept_language_basic() {
2197 let result = parse_accept_language("en, fr, de");
2198 assert_eq!(result, vec!["en", "fr", "de"]);
2199 }
2200
2201 #[test]
2202 fn parse_accept_language_with_quality() {
2203 let result = parse_accept_language(
2204 "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
2205 );
2206 assert_eq!(result, vec!["fr-CH", "fr", "en", "de", "*"]);
2207 }
2208
2209 #[test]
2210 fn parse_accept_language_with_whitespace() {
2211 let result = parse_accept_language(" en , fr ; q=0.8 , de ; q=0.5 ");
2212 assert_eq!(result, vec!["en", "fr", "de"]);
2213 }
2214
2215 #[test]
2216 fn parse_accept_language_empty() {
2217 let result = parse_accept_language("");
2218 assert!(result.is_empty());
2219 }
2220
2221 #[test]
2222 fn parse_accept_language_single() {
2223 let result = parse_accept_language("en");
2224 assert_eq!(result, vec!["en"]);
2225 }
2226
2227 #[test]
2228 fn parse_accept_language_wildcard_only() {
2229 let result = parse_accept_language("*");
2230 assert_eq!(result, vec!["*"]);
2231 }
2232
2233 #[test]
2236 fn negotiate_exact_match() {
2237 let preferred = vec!["fr".into()];
2238 let available = vec!["en".into(), "fr".into(), "de".into()];
2239 assert_eq!(negotiate_locale(&preferred, &available, "en"), "fr");
2240 }
2241
2242 #[test]
2243 fn negotiate_prefix_match() {
2244 let preferred = vec!["fr-CH".into()];
2245 let available = vec!["en".into(), "fr".into(), "de".into()];
2246 assert_eq!(negotiate_locale(&preferred, &available, "en"), "fr");
2247 }
2248
2249 #[test]
2250 fn negotiate_default_fallback() {
2251 let preferred = vec!["ja".into()];
2252 let available = vec!["en".into(), "fr".into()];
2253 assert_eq!(negotiate_locale(&preferred, &available, "en"), "en");
2254 }
2255
2256 #[test]
2257 fn negotiate_case_insensitive() {
2258 let preferred = vec!["FR".into()];
2259 let available = vec!["en".into(), "fr".into()];
2260 assert_eq!(negotiate_locale(&preferred, &available, "en"), "fr");
2261 }
2262
2263 #[test]
2264 fn negotiate_wildcard_ignored() {
2265 let preferred = vec!["*".into()];
2266 let available = vec!["en".into(), "fr".into()];
2267 assert_eq!(negotiate_locale(&preferred, &available, "en"), "en");
2268 }
2269
2270 #[test]
2271 fn negotiate_no_match_returns_default() {
2272 let preferred: Vec<String> = vec![];
2273 let available = vec!["en".into(), "fr".into()];
2274 assert_eq!(negotiate_locale(&preferred, &available, "fr"), "fr");
2275 }
2276
2277 #[test]
2280 fn locale_redirect_contains_all_locales() {
2281 let tmp = tempdir().unwrap();
2282 let site = tmp.path();
2283 fs::create_dir_all(site).unwrap();
2284
2285 let locales = vec!["en".into(), "fr".into(), "de".into()];
2286 crate::server::generate_locale_redirect(site, &locales, "en").unwrap();
2287
2288 let content = fs::read_to_string(site.join("index.html")).unwrap();
2289 assert!(content.contains("\"en\""), "missing en locale");
2290 assert!(content.contains("\"fr\""), "missing fr locale");
2291 assert!(content.contains("\"de\""), "missing de locale");
2292 }
2293
2294 #[test]
2295 fn locale_redirect_noscript_fallback() {
2296 let tmp = tempdir().unwrap();
2297 let site = tmp.path();
2298 fs::create_dir_all(site).unwrap();
2299
2300 crate::server::generate_locale_redirect(
2301 site,
2302 &["en".into(), "fr".into()],
2303 "en",
2304 )
2305 .unwrap();
2306
2307 let content = fs::read_to_string(site.join("index.html")).unwrap();
2308 assert!(content.contains("<noscript>"), "missing noscript tag");
2309 assert!(
2310 content.contains("url=/en/"),
2311 "noscript should redirect to default locale"
2312 );
2313 }
2314
2315 #[test]
2316 fn locale_redirect_preserves_existing_non_redirect_index() {
2317 let tmp = tempdir().unwrap();
2318 let site = tmp.path();
2319 fs::create_dir_all(site).unwrap();
2320
2321 fs::write(site.join("index.html"), "<html>Custom</html>").unwrap();
2323
2324 crate::server::generate_locale_redirect(
2325 site,
2326 &["en".into(), "fr".into()],
2327 "en",
2328 )
2329 .unwrap();
2330
2331 let content = fs::read_to_string(site.join("index.html")).unwrap();
2332 assert_eq!(content, "<html>Custom</html>");
2333 }
2334
2335 #[test]
2336 fn after_compile_generates_locale_redirect() {
2337 let tmp = tempdir().unwrap();
2338 let site = tmp.path();
2339
2340 write_html(site, "en/index.html", "EN");
2341 write_html(site, "fr/index.html", "FR");
2342
2343 let config = I18nConfig {
2344 default_locale: "en".into(),
2345 locales: vec!["en".into(), "fr".into()],
2346 url_prefix: UrlPrefixStrategy::SubPath,
2347 };
2348
2349 let ctx = make_ctx(site);
2350 I18nPlugin::new(config).after_compile(&ctx).unwrap();
2351
2352 let index = site.join("index.html");
2353 assert!(index.exists(), "root index.html should be generated");
2354 let content = fs::read_to_string(&index).unwrap();
2355 assert!(content.contains("ssg-locale-redirect"));
2356 assert!(content.contains("\"en\""));
2357 assert!(content.contains("\"fr\""));
2358 }
2359
2360 #[test]
2361 fn test_collect_html_files_recursive_missing_dir_returns_io_error() {
2362 let tmp = tempdir().unwrap();
2363 let missing = tmp.path().join("missing");
2364 let mut found = Vec::new();
2365 let res = collect_html_files_recursive(
2366 &missing, &missing, "en", "en", &mut found,
2367 );
2368 assert!(res.is_err());
2369 let dbg = format!("{:?}", res.unwrap_err());
2370 assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
2371 assert!(
2372 dbg.contains("missing"),
2373 "error should carry the missing path: {dbg}"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_generate_locale_sitemaps_invalid_dir_returns_io_error() {
2379 let tmp = tempdir().unwrap();
2380 let file_path = tmp.path().join("file");
2381 fs::write(&file_path, "").unwrap();
2382
2383 let mut pages: LocaleMatrix = HashMap::new();
2384 let _ = pages.insert(
2385 "index.html".to_string(),
2386 locale_map(&["en"], "index.html"),
2387 );
2388
2389 let ctx = PluginContext::new(
2390 Path::new("content"),
2391 Path::new("build"),
2392 &file_path,
2393 Path::new("templates"),
2394 );
2395 let res = generate_locale_sitemaps(
2396 &ctx,
2397 &pages,
2398 &["en".to_string()],
2399 "en",
2400 "https://example.com",
2401 &UrlPrefixStrategy::SubPath,
2402 None,
2403 );
2404 assert!(res.is_err());
2405 let dbg = format!("{:?}", res.unwrap_err());
2406 assert!(dbg.contains("Io"), "expected Io variant, got: {dbg}");
2407 }
2408
2409 #[test]
2412 fn has_transform_is_true() {
2413 let p = I18nPlugin::new(I18nConfig::default());
2414 assert!(p.has_transform());
2415 }
2416
2417 #[test]
2418 fn transform_html_single_locale_returns_unchanged() {
2419 let tmp = tempdir().unwrap();
2420 let ctx = make_ctx(tmp.path());
2421 let p = I18nPlugin::new(I18nConfig::default());
2422 let out = p
2423 .transform_html("<html><head></head></html>", tmp.path(), &ctx)
2424 .unwrap();
2425 assert_eq!(out, "<html><head></head></html>");
2426 }
2427
2428 #[test]
2429 fn transform_html_already_injected_is_idempotent() {
2430 let tmp = tempdir().unwrap();
2431 let ctx = make_ctx(tmp.path());
2432 let cfg = I18nConfig {
2433 default_locale: "en".into(),
2434 locales: vec!["en".into(), "fr".into()],
2435 url_prefix: UrlPrefixStrategy::SubPath,
2436 };
2437 let p = I18nPlugin::new(cfg);
2438 let html = "<html><head><link rel=\"alternate\" hreflang=\"en\" href=\"x\" /></head></html>";
2439 let out = p.transform_html(html, tmp.path(), &ctx).unwrap();
2440 assert_eq!(out, html);
2441 }
2442
2443 #[test]
2444 fn transform_html_fewer_than_two_locales_on_disk_returns_unchanged() {
2445 let tmp = tempdir().unwrap();
2446 let site = tmp.path();
2447 write_html(site, "en/index.html", "EN");
2448 let cfg = I18nConfig {
2450 default_locale: "en".into(),
2451 locales: vec!["en".into(), "fr".into()],
2452 url_prefix: UrlPrefixStrategy::SubPath,
2453 };
2454 let p = I18nPlugin::new(cfg);
2455 let ctx = make_ctx(site);
2456 let path = site.join("en/index.html");
2457 let out = p
2458 .transform_html("<html><head></head></html>", &path, &ctx)
2459 .unwrap();
2460 assert_eq!(out, "<html><head></head></html>");
2461 }
2462
2463 #[test]
2464 fn transform_html_path_outside_locale_returns_unchanged() {
2465 let tmp = tempdir().unwrap();
2466 let site = tmp.path();
2467 write_html(site, "en/index.html", "EN");
2468 write_html(site, "fr/index.html", "FR");
2469
2470 let cfg = I18nConfig {
2471 default_locale: "en".into(),
2472 locales: vec!["en".into(), "fr".into()],
2473 url_prefix: UrlPrefixStrategy::SubPath,
2474 };
2475 let p = I18nPlugin::new(cfg);
2476 let ctx = make_ctx(site);
2477 let path = site.join("untracked.html");
2479 let out = p
2480 .transform_html("<html><head></head></html>", &path, &ctx)
2481 .unwrap();
2482 assert_eq!(out, "<html><head></head></html>");
2483 }
2484
2485 #[test]
2486 fn transform_html_page_missing_from_matrix_returns_unchanged() {
2487 let tmp = tempdir().unwrap();
2488 let site = tmp.path();
2489 write_html(site, "en/index.html", "EN");
2490 write_html(site, "fr/index.html", "FR");
2491
2492 let cfg = I18nConfig {
2493 default_locale: "en".into(),
2494 locales: vec!["en".into(), "fr".into()],
2495 url_prefix: UrlPrefixStrategy::SubPath,
2496 };
2497 let p = I18nPlugin::new(cfg);
2498 let ctx = make_ctx(site);
2499 let path = site.join("en/missing.html");
2502 let out = p
2503 .transform_html("<html><head></head></html>", &path, &ctx)
2504 .unwrap();
2505 assert_eq!(out, "<html><head></head></html>");
2506 }
2507
2508 #[test]
2509 fn transform_html_injects_hreflang_for_shared_page() {
2510 let tmp = tempdir().unwrap();
2511 let site = tmp.path();
2512 write_html(site, "en/index.html", "EN");
2513 write_html(site, "fr/index.html", "FR");
2514
2515 let cfg = I18nConfig {
2516 default_locale: "en".into(),
2517 locales: vec!["en".into(), "fr".into()],
2518 url_prefix: UrlPrefixStrategy::SubPath,
2519 };
2520 let p = I18nPlugin::new(cfg);
2521 let ctx = make_ctx(site);
2522 let path = site.join("en/index.html");
2523 let html = "<html><head><title>T</title></head><body>x</body></html>";
2524 let out = p.transform_html(html, &path, &ctx).unwrap();
2525 assert!(out.contains(HREFLANG_MARKER), "missing hreflang: {out}");
2526 assert!(out.contains("hreflang=\"x-default\""));
2527 assert!(out.contains("https://example.com/en/index.html"));
2529 }
2530
2531 #[test]
2532 fn transform_html_single_locale_page_returns_unchanged() {
2533 let tmp = tempdir().unwrap();
2536 let site = tmp.path();
2537 write_html(site, "en/only.html", "EN");
2538 write_html(site, "fr/other.html", "FR");
2539
2540 let cfg = I18nConfig {
2541 default_locale: "en".into(),
2542 locales: vec!["en".into(), "fr".into()],
2543 url_prefix: UrlPrefixStrategy::SubPath,
2544 };
2545 let p = I18nPlugin::new(cfg);
2546 let ctx = make_ctx(site);
2547 let path = site.join("en/only.html");
2548 let html = "<html><head></head></html>";
2549 let out = p.transform_html(html, &path, &ctx).unwrap();
2550 assert_eq!(out, html);
2551 }
2552
2553 #[test]
2554 fn transform_html_no_head_close_returns_unchanged() {
2555 let tmp = tempdir().unwrap();
2556 let site = tmp.path();
2557 write_html(site, "en/index.html", "EN");
2558 write_html(site, "fr/index.html", "FR");
2559
2560 let cfg = I18nConfig {
2561 default_locale: "en".into(),
2562 locales: vec!["en".into(), "fr".into()],
2563 url_prefix: UrlPrefixStrategy::SubPath,
2564 };
2565 let p = I18nPlugin::new(cfg);
2566 let ctx = make_ctx(site);
2567 let path = site.join("en/index.html");
2568 let html = "<html><body>no head close</body></html>";
2570 let out = p.transform_html(html, &path, &ctx).unwrap();
2571 assert_eq!(out, html);
2572 }
2573
2574 #[test]
2577 fn resolve_locale_and_rel_extracts_locale_and_rel() {
2578 let site = PathBuf::from("/site");
2579 let path = PathBuf::from("/site/en/about/index.html");
2580 let locales = vec!["en".to_string(), "fr".to_string()];
2581 let res = resolve_locale_and_rel(&path, &site, &locales, None).unwrap();
2582 assert_eq!(res.0, "en");
2583 assert_eq!(res.1, "about/index.html");
2584 }
2585
2586 #[test]
2587 fn resolve_locale_and_rel_returns_none_when_not_under_site_dir() {
2588 let site = PathBuf::from("/site");
2589 let path = PathBuf::from("/somewhere-else/en/index.html");
2590 let locales = vec!["en".to_string()];
2591 assert!(resolve_locale_and_rel(&path, &site, &locales, None).is_none());
2592 }
2593
2594 #[test]
2595 fn resolve_locale_and_rel_returns_none_for_unknown_locale_segment() {
2596 let site = PathBuf::from("/site");
2597 let path = PathBuf::from("/site/de/page.html");
2598 let locales = vec!["en".to_string(), "fr".to_string()];
2599 assert!(resolve_locale_and_rel(&path, &site, &locales, None).is_none());
2600 }
2601
2602 #[test]
2603 fn resolve_locale_and_rel_returns_none_for_bare_locale_dir() {
2604 let site = PathBuf::from("/site");
2606 let path = PathBuf::from("/site/en");
2607 let locales = vec!["en".to_string()];
2608 assert!(resolve_locale_and_rel(&path, &site, &locales, None).is_none());
2609 }
2610
2611 #[test]
2619 fn lang_switcher_element_marker_is_found_and_replaced() {
2620 let html =
2621 r#"<html><body><div data-ssg-lang-switcher></div></body></html>"#;
2622 let found = find_lang_switcher_element(html).expect("element found");
2623 assert_eq!(
2624 &html[found.0..found.1],
2625 "<div data-ssg-lang-switcher></div>"
2626 );
2627 }
2628
2629 #[test]
2632 fn lang_switcher_element_marker_survives_reformatting() {
2633 for html in [
2634 r#"<nav data-ssg-lang-switcher></nav>"#,
2635 r#"<div class=x data-ssg-lang-switcher ></div>"#,
2636 "<div data-ssg-lang-switcher>\n </div>",
2637 ] {
2638 assert!(
2639 find_lang_switcher_element(html).is_some(),
2640 "should match: {html}"
2641 );
2642 }
2643 }
2644
2645 #[test]
2648 fn lang_switcher_element_marker_ignores_non_empty_elements() {
2649 let html = r#"<div data-ssg-lang-switcher>keep me</div>"#;
2650 assert!(find_lang_switcher_element(html).is_none());
2651 }
2652
2653 #[test]
2655 fn lang_switcher_element_marker_ignores_a_bare_mention() {
2656 let html = "<p>use data-ssg-lang-switcher in your template</p>";
2657 assert!(find_lang_switcher_element(html).is_none());
2658 }
2659
2660 #[test]
2661 fn inject_lang_switcher_replaces_marker_when_present() {
2662 let html = "<html><body><!-- ssg:lang-switcher --></body></html>";
2663 let out = inject_lang_switcher(
2664 html,
2665 "en",
2666 &locale_map(&["en", "fr"], "index.html"),
2667 &locale_map(&["en", "fr"], "")
2668 .keys()
2669 .map(|l| (l.clone(), l.clone()))
2670 .collect(),
2671 "https://example.com",
2672 &UrlPrefixStrategy::SubPath,
2673 None,
2674 );
2675 assert!(!out.contains("<!-- ssg:lang-switcher -->"));
2676 assert!(out.contains("lang-switcher"));
2677 assert!(out.contains("lang=\"fr\""));
2678 }
2679
2680 #[test]
2681 fn inject_lang_switcher_without_marker_returns_unchanged() {
2682 let html = "<html><body>nothing here</body></html>";
2683 let out = inject_lang_switcher(
2684 html,
2685 "en",
2686 &locale_map(&["en"], "index.html"),
2687 &locale_map(&["en"], "en"),
2688 "https://example.com",
2689 &UrlPrefixStrategy::SubPath,
2690 None,
2691 );
2692 assert_eq!(out, html);
2693 }
2694
2695 #[test]
2698 fn rewrite_ap_lang_items_returns_unchanged_when_marker_absent() {
2699 let html = "<a href=\"/whatever\">link</a>";
2700 let locales = locale_map(&["en"], "page.html");
2701 let out = rewrite_ap_lang_items(
2702 html,
2703 &locales,
2704 "https://example.com",
2705 &UrlPrefixStrategy::SubPath,
2706 None,
2707 );
2708 assert_eq!(out, html);
2709 }
2710
2711 #[test]
2712 fn rewrite_ap_lang_items_skips_unknown_lang() {
2713 let input =
2716 "<a class=\"ap-lang-item\" href=\"/de/\" data-lang=\"de\">DE</a>";
2717 let locales = locale_map(&["en", "fr"], "page.html");
2718 let out = rewrite_ap_lang_items(
2719 input,
2720 &locales,
2721 "https://example.com",
2722 &UrlPrefixStrategy::SubPath,
2723 None,
2724 );
2725 assert_eq!(out, input);
2726 }
2727
2728 #[test]
2729 fn rewrite_ap_lang_items_handles_unterminated_anchor() {
2730 let input = "<a class=\"ap-lang-item\" data-lang=\"fr\"";
2733 let locales = locale_map(&["fr"], "page.html");
2734 let out = rewrite_ap_lang_items(
2735 input,
2736 &locales,
2737 "https://example.com",
2738 &UrlPrefixStrategy::SubPath,
2739 None,
2740 );
2741 assert_eq!(out, input);
2742 }
2743
2744 #[test]
2745 fn rewrite_ap_lang_items_with_subdomain_strategy_strips_host() {
2746 let input =
2747 "<a class=\"ap-lang-item\" href=\"/fr/\" data-lang=\"fr\">F</a>";
2748 let locales = locale_map(&["fr"], "page.html");
2749 let out = rewrite_ap_lang_items(
2750 input,
2751 &locales,
2752 "https://example.com",
2753 &UrlPrefixStrategy::SubDomain,
2754 None,
2755 );
2756 assert!(out.contains("href=\"/page.html\""), "out={out}");
2759 }
2760
2761 #[test]
2764 fn parse_accept_language_skips_empty_parts() {
2765 let out = parse_accept_language("en,,fr,");
2767 assert_eq!(out, vec!["en", "fr"]);
2768 }
2769
2770 #[test]
2771 fn parse_accept_language_skips_empty_locale_with_quality() {
2772 let out = parse_accept_language(";q=0.5, en");
2774 assert_eq!(out, vec!["en"]);
2775 }
2776
2777 #[test]
2778 fn parse_accept_language_quality_zero_sorts_last() {
2779 let out = parse_accept_language("fr;q=0.0, en;q=0.5, de;q=1.0");
2780 assert_eq!(out, vec!["de", "en", "fr"]);
2781 }
2782
2783 #[test]
2784 fn parse_accept_language_unparseable_quality_defaults_to_one() {
2785 let out = parse_accept_language("en;q=foo, fr;q=0.5");
2786 assert_eq!(out, vec!["en", "fr"]);
2788 }
2789
2790 #[test]
2791 fn parse_accept_language_missing_q_prefix_defaults_to_one() {
2792 let out = parse_accept_language("en;0.1, fr");
2796 assert_eq!(out.len(), 2);
2797 assert!(out.contains(&"en".to_string()));
2798 assert!(out.contains(&"fr".to_string()));
2799 }
2800
2801 #[test]
2802 fn parse_accept_language_nan_quality_uses_equal_fallback_in_sort() {
2803 let out = parse_accept_language("en;q=nan, fr;q=0.5");
2807 assert_eq!(out.len(), 2);
2808 assert!(out.contains(&"en".to_string()));
2809 assert!(out.contains(&"fr".to_string()));
2810 }
2811
2812 #[test]
2815 fn ensure_matrix_short_circuits_when_site_dir_unchanged() {
2816 let tmp = tempdir().unwrap();
2817 write_html(tmp.path(), "en/index.html", "EN");
2818 write_html(tmp.path(), "fr/index.html", "FR");
2819
2820 let cfg = I18nConfig {
2821 default_locale: "en".into(),
2822 locales: vec!["en".into(), "fr".into()],
2823 url_prefix: UrlPrefixStrategy::SubPath,
2824 };
2825 let p = I18nPlugin::new(cfg);
2826 let ctx = make_ctx(tmp.path());
2827 p.ensure_matrix(&ctx).unwrap();
2828 p.ensure_matrix(&ctx).unwrap();
2830 let cache = p.matrix.read().unwrap();
2831 assert_eq!(cache.present_locales, vec!["en", "fr"]);
2832 }
2833
2834 #[test]
2837 fn ensure_matrix_rebuilds_when_site_dir_changes() {
2838 let tmp1 = tempdir().unwrap();
2839 let tmp2 = tempdir().unwrap();
2840 write_html(tmp1.path(), "en/index.html", "EN1");
2841 write_html(tmp1.path(), "fr/index.html", "FR1");
2842 write_html(tmp2.path(), "en/about.html", "EN2");
2843 write_html(tmp2.path(), "fr/about.html", "FR2");
2844
2845 let cfg = I18nConfig {
2846 default_locale: "en".into(),
2847 locales: vec!["en".into(), "fr".into()],
2848 url_prefix: UrlPrefixStrategy::SubPath,
2849 };
2850 let p = I18nPlugin::new(cfg);
2851 p.ensure_matrix(&make_ctx(tmp1.path())).unwrap();
2853 p.ensure_matrix(&make_ctx(tmp2.path())).unwrap();
2855 let cache = p.matrix.read().unwrap();
2856 assert_eq!(cache.site_dir.as_deref(), Some(tmp2.path()));
2857 assert!(cache.pages.contains_key("about.html"));
2858 }
2859
2860 fn cfg_en_fr() -> I18nConfig {
2864 I18nConfig {
2865 default_locale: "en".into(),
2866 locales: vec!["en".into(), "fr".into()],
2867 url_prefix: UrlPrefixStrategy::SubPath,
2868 }
2869 }
2870
2871 #[test]
2872 fn ensure_matrix_double_check_returns_early_for_racing_fillers() {
2873 use std::sync::Arc;
2874
2875 let tmp = tempdir().unwrap();
2880 write_html(tmp.path(), "en/index.html", "EN");
2881 write_html(tmp.path(), "fr/index.html", "FR");
2882
2883 let plugin = Arc::new(I18nPlugin::new(cfg_en_fr()));
2884 let read_guard = plugin
2885 .matrix
2886 .read()
2887 .unwrap_or_else(std::sync::PoisonError::into_inner);
2888
2889 let mut handles = Vec::new();
2890 for _ in 0..2 {
2891 let p = Arc::clone(&plugin);
2892 let site = tmp.path().to_path_buf();
2893 handles.push(std::thread::spawn(move || {
2894 p.ensure_matrix(&make_ctx(&site)).expect("fill succeeds");
2895 }));
2896 }
2897 std::thread::sleep(std::time::Duration::from_millis(100));
2900 drop(read_guard);
2901 for h in handles {
2902 h.join().expect("worker thread");
2903 }
2904
2905 let cache = plugin
2906 .matrix
2907 .read()
2908 .unwrap_or_else(std::sync::PoisonError::into_inner);
2909 assert_eq!(cache.site_dir.as_deref(), Some(tmp.path()));
2910 assert_eq!(cache.present_locales, vec!["en", "fr"]);
2911 }
2912
2913 #[test]
2914 #[cfg(unix)]
2915 fn after_compile_propagates_unreadable_locale_dir_error() {
2916 use std::os::unix::fs::PermissionsExt;
2917
2918 let tmp = tempdir().unwrap();
2919 write_html(tmp.path(), "en/index.html", "EN");
2920 write_html(tmp.path(), "fr/index.html", "FR");
2921 let fr = tmp.path().join("fr");
2922 fs::set_permissions(&fr, fs::Permissions::from_mode(0o000)).unwrap();
2923
2924 let ctx = make_ctx(tmp.path());
2925 let result = I18nPlugin::new(cfg_en_fr()).after_compile(&ctx);
2926 fs::set_permissions(&fr, fs::Permissions::from_mode(0o755)).unwrap();
2927 assert!(result.is_err(), "unreadable locale dir must be an Err");
2928 }
2929
2930 #[test]
2931 #[cfg(unix)]
2932 fn transform_html_propagates_unreadable_locale_dir_error() {
2933 use std::os::unix::fs::PermissionsExt;
2934
2935 let tmp = tempdir().unwrap();
2936 write_html(tmp.path(), "en/index.html", "EN");
2937 write_html(tmp.path(), "fr/index.html", "FR");
2938 let fr = tmp.path().join("fr");
2939 fs::set_permissions(&fr, fs::Permissions::from_mode(0o000)).unwrap();
2940
2941 let ctx = make_ctx(tmp.path());
2942 let result = I18nPlugin::new(cfg_en_fr()).transform_html(
2943 "<html><head></head><body>EN</body></html>",
2944 &tmp.path().join("en/index.html"),
2945 &ctx,
2946 );
2947 fs::set_permissions(&fr, fs::Permissions::from_mode(0o755)).unwrap();
2948 assert!(result.is_err(), "unreadable locale dir must be an Err");
2949 }
2950
2951 #[test]
2952 fn after_compile_without_config_falls_back_to_example_base_url() {
2953 let tmp = tempdir().unwrap();
2954 write_html(tmp.path(), "en/index.html", "EN");
2955 write_html(tmp.path(), "fr/index.html", "FR");
2956
2957 let ctx = PluginContext::new(
2960 Path::new("content"),
2961 Path::new("build"),
2962 tmp.path(),
2963 Path::new("templates"),
2964 );
2965 I18nPlugin::new(cfg_en_fr()).after_compile(&ctx).unwrap();
2966
2967 let html =
2968 fs::read_to_string(tmp.path().join("en/index.html")).unwrap();
2969 assert!(
2970 html.contains("https://example.com/en/index.html"),
2971 "fallback base url expected: {html}"
2972 );
2973 }
2974
2975 #[test]
2976 fn transform_html_without_config_falls_back_to_example_base_url() {
2977 let tmp = tempdir().unwrap();
2978 write_html(tmp.path(), "en/index.html", "EN");
2979 write_html(tmp.path(), "fr/index.html", "FR");
2980
2981 let ctx = PluginContext::new(
2982 Path::new("content"),
2983 Path::new("build"),
2984 tmp.path(),
2985 Path::new("templates"),
2986 );
2987 let out = I18nPlugin::new(cfg_en_fr())
2988 .transform_html(
2989 "<html><head><title>T</title></head><body>EN</body></html>",
2990 &tmp.path().join("en/index.html"),
2991 &ctx,
2992 )
2993 .unwrap();
2994 assert!(
2995 out.contains("https://example.com/en/index.html"),
2996 "fallback base url expected: {out}"
2997 );
2998 }
2999
3000 #[test]
3001 #[cfg(unix)]
3002 fn after_compile_propagates_page_read_error() {
3003 use std::os::unix::fs::PermissionsExt;
3004
3005 let tmp = tempdir().unwrap();
3006 write_html(tmp.path(), "en/index.html", "EN");
3007 write_html(tmp.path(), "fr/index.html", "FR");
3008 let en_page = tmp.path().join("en/index.html");
3009 fs::set_permissions(&en_page, fs::Permissions::from_mode(0o000))
3010 .unwrap();
3011
3012 let ctx = make_ctx(tmp.path());
3013 let result = I18nPlugin::new(cfg_en_fr()).after_compile(&ctx);
3014 fs::set_permissions(&en_page, fs::Permissions::from_mode(0o644))
3015 .unwrap();
3016 assert!(result.is_err(), "unreadable page must surface as Err");
3017 }
3018
3019 #[test]
3020 #[cfg(unix)]
3021 fn after_compile_propagates_page_write_error() {
3022 use std::os::unix::fs::PermissionsExt;
3023
3024 let tmp = tempdir().unwrap();
3025 write_html(tmp.path(), "en/index.html", "EN");
3026 write_html(tmp.path(), "fr/index.html", "FR");
3027 let en_page = tmp.path().join("en/index.html");
3029 fs::set_permissions(&en_page, fs::Permissions::from_mode(0o444))
3030 .unwrap();
3031
3032 let ctx = make_ctx(tmp.path());
3033 let result = I18nPlugin::new(cfg_en_fr()).after_compile(&ctx);
3034 fs::set_permissions(&en_page, fs::Permissions::from_mode(0o644))
3035 .unwrap();
3036 assert!(result.is_err(), "read-only page must surface as Err");
3037 }
3038
3039 #[test]
3040 fn after_compile_propagates_sitemap_write_error() {
3041 let tmp = tempdir().unwrap();
3044 write_html(tmp.path(), "en/index.html", "EN");
3045 write_html(tmp.path(), "fr/index.html", "FR");
3046 fs::create_dir(tmp.path().join("sitemap-en.xml")).unwrap();
3047
3048 let ctx = make_ctx(tmp.path());
3049 let err = I18nPlugin::new(cfg_en_fr())
3050 .after_compile(&ctx)
3051 .expect_err("sitemap write over a directory must fail");
3052 assert!(format!("{err:?}").contains("Io"));
3053 }
3054
3055 #[test]
3056 #[cfg(unix)]
3057 fn after_compile_propagates_locale_redirect_write_error() {
3058 use std::os::unix::fs::PermissionsExt;
3059
3060 let tmp = tempdir().unwrap();
3064 write_html(tmp.path(), "en/index.html", "EN");
3065 write_html(tmp.path(), "fr/index.html", "FR");
3066 let index = tmp.path().join("index.html");
3067 fs::write(&index, "<!-- ssg-locale-redirect -->").unwrap();
3068 fs::set_permissions(&index, fs::Permissions::from_mode(0o444)).unwrap();
3069
3070 let ctx = make_ctx(tmp.path());
3071 let result = I18nPlugin::new(cfg_en_fr()).after_compile(&ctx);
3072 fs::set_permissions(&index, fs::Permissions::from_mode(0o644)).unwrap();
3073 assert!(result.is_err(), "redirect rewrite must surface as Err");
3074 }
3075
3076 #[test]
3077 fn resolve_locale_and_rel_site_dir_itself_returns_none() {
3078 let site = Path::new("/site");
3081 assert!(
3082 resolve_locale_and_rel(site, site, &["en".into()], None).is_none()
3083 );
3084 }
3085
3086 #[test]
3087 fn collect_locale_pages_skips_locale_without_directory() {
3088 let tmp = tempdir().unwrap();
3089 write_html(tmp.path(), "en/index.html", "EN");
3090
3091 let (map, _keys) = collect_locale_pages(
3092 tmp.path(),
3093 &tmp.path().join(".meta"),
3094 &["en".into(), "ghost".into()],
3095 None,
3096 )
3097 .unwrap();
3098 assert_eq!(map.len(), 1);
3099 assert!(map["index.html"].contains_key("en"));
3100 }
3101
3102 #[test]
3103 #[cfg(unix)]
3104 fn collect_html_files_recursive_nested_unreadable_dir_errors() {
3105 use std::os::unix::fs::PermissionsExt;
3106
3107 let tmp = tempdir().unwrap();
3108 let root = tmp.path().join("en");
3109 let nested = root.join("locked");
3110 fs::create_dir_all(&nested).unwrap();
3111 fs::set_permissions(&nested, fs::Permissions::from_mode(0o000))
3112 .unwrap();
3113
3114 let mut found = Vec::new();
3115 let res =
3116 collect_html_files_recursive(&root, &root, "en", "en", &mut found);
3117 fs::set_permissions(&nested, fs::Permissions::from_mode(0o755))
3118 .unwrap();
3119 assert!(res.is_err(), "nested unreadable dir must be an Err");
3120 }
3121
3122 #[test]
3123 fn collect_locale_pages_ignores_non_html_files() {
3124 let tmp = tempdir().unwrap();
3125 write_html(tmp.path(), "en/index.html", "EN");
3126 fs::write(tmp.path().join("en/style.css"), "body{}").unwrap();
3127
3128 let (map, _keys) = collect_locale_pages(
3129 tmp.path(),
3130 &tmp.path().join(".meta"),
3131 &["en".into()],
3132 None,
3133 )
3134 .unwrap();
3135 assert_eq!(map.len(), 1, "css files must not be collected");
3136 }
3137
3138 #[test]
3139 fn inject_hreflang_all_skips_locale_missing_from_page_set() {
3140 let tmp = tempdir().unwrap();
3143 write_html(tmp.path(), "en/index.html", "EN");
3144 write_html(tmp.path(), "fr/index.html", "FR");
3145
3146 let mut pages: LocaleMatrix = HashMap::new();
3147 let _ = pages.insert(
3148 "index.html".to_string(),
3149 locale_map(&["en", "fr"], "index.html"),
3150 );
3151
3152 let ctx = make_ctx(tmp.path());
3153 inject_hreflang_all(
3154 &ctx,
3155 &pages,
3156 &["en".into(), "fr".into(), "de".into()],
3157 "en",
3158 "https://example.com",
3159 &UrlPrefixStrategy::SubPath,
3160 None,
3161 )
3162 .unwrap();
3163
3164 let html =
3165 fs::read_to_string(tmp.path().join("en/index.html")).unwrap();
3166 assert!(html.contains(HREFLANG_MARKER));
3167 }
3168
3169 #[test]
3170 fn inject_hreflang_all_skips_page_file_missing_on_disk() {
3171 let tmp = tempdir().unwrap();
3174 write_html(tmp.path(), "en/ghost.html", "EN");
3175
3176 let mut pages: LocaleMatrix = HashMap::new();
3177 let _ = pages.insert(
3178 "ghost.html".to_string(),
3179 locale_map(&["en", "fr"], "ghost.html"),
3180 );
3181
3182 let ctx = make_ctx(tmp.path());
3183 inject_hreflang_all(
3184 &ctx,
3185 &pages,
3186 &["en".into(), "fr".into()],
3187 "en",
3188 "https://example.com",
3189 &UrlPrefixStrategy::SubPath,
3190 None,
3191 )
3192 .unwrap();
3193
3194 let html =
3195 fs::read_to_string(tmp.path().join("en/ghost.html")).unwrap();
3196 assert!(html.contains(HREFLANG_MARKER));
3197 }
3198
3199 #[test]
3200 fn inject_hreflang_all_keeps_headless_page_unrewritten_inline() {
3201 let tmp = tempdir().unwrap();
3204 fs::create_dir_all(tmp.path().join("en")).unwrap();
3205 fs::create_dir_all(tmp.path().join("fr")).unwrap();
3206 let raw = "<html><body>no head</body></html>";
3207 fs::write(tmp.path().join("en/nohead.html"), raw).unwrap();
3208 fs::write(tmp.path().join("fr/nohead.html"), raw).unwrap();
3209
3210 let ctx = make_ctx(tmp.path());
3211 I18nPlugin::new(cfg_en_fr()).after_compile(&ctx).unwrap();
3212
3213 let html =
3214 fs::read_to_string(tmp.path().join("en/nohead.html")).unwrap();
3215 assert!(
3216 !html.contains(HREFLANG_MARKER),
3217 "headless page must not receive hreflang links: {html}"
3218 );
3219 }
3220
3221 #[test]
3222 fn inject_before_head_close_stray_end_tag_returns_none() {
3223 assert!(
3227 inject_before_head_close("stray closer</head>", "<x/>").is_none()
3228 );
3229 }
3230
3231 #[test]
3232 fn rewrite_ap_lang_items_single_quoted_attrs_and_relative_base() {
3233 let locales = locale_map(&["fr"], "index.html");
3234 let input = "<a class='ap-lang-item' data-lang='fr' href='/old'>F</a>";
3238 let out = rewrite_ap_lang_items(
3239 input,
3240 &locales,
3241 "",
3242 &UrlPrefixStrategy::SubPath,
3243 None,
3244 );
3245 assert!(out.contains("href='/fr/index.html'"), "got: {out}");
3246 }
3247
3248 #[test]
3249 fn rewrite_ap_lang_items_unterminated_data_lang_left_unchanged() {
3250 let locales = locale_map(&["fr"], "index.html");
3251 let input = "<a class=\"ap-lang-item\" data-lang=\"fr>F</a>";
3254 let out = rewrite_ap_lang_items(
3255 input,
3256 &locales,
3257 "https://example.com",
3258 &UrlPrefixStrategy::SubPath,
3259 None,
3260 );
3261 assert_eq!(out, input);
3262 }
3263
3264 #[test]
3265 fn rewrite_ap_lang_items_without_data_lang_left_unchanged() {
3266 let locales = locale_map(&["fr"], "index.html");
3267 let input = "<a class=\"ap-lang-item\" href=\"/x\">F</a>";
3268 let out = rewrite_ap_lang_items(
3269 input,
3270 &locales,
3271 "https://example.com",
3272 &UrlPrefixStrategy::SubPath,
3273 None,
3274 );
3275 assert_eq!(out, input);
3276 }
3277
3278 #[test]
3279 fn rewrite_ap_lang_items_unterminated_href_left_unchanged() {
3280 let locales = locale_map(&["fr"], "index.html");
3281 let input =
3284 "<a class=\"ap-lang-item\" data-lang=\"fr\" href=\"/old>F</a>";
3285 let out = rewrite_ap_lang_items(
3286 input,
3287 &locales,
3288 "https://example.com",
3289 &UrlPrefixStrategy::SubPath,
3290 None,
3291 );
3292 assert_eq!(out, input);
3293 }
3294
3295 #[test]
3296 fn rewrite_ap_lang_items_ignores_plain_anchor_tags() {
3297 let locales = locale_map(&["fr"], "index.html");
3298 let input = "<span>ap-lang-item</span><a href=\"/plain\">keep me</a>";
3301 let out = rewrite_ap_lang_items(
3302 input,
3303 &locales,
3304 "https://example.com",
3305 &UrlPrefixStrategy::SubPath,
3306 None,
3307 );
3308 assert_eq!(out, input);
3309 }
3310}