Skip to main content

ssg/
i18n.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Internationalisation (i18n) routing primitives
5//!
6//! Provides hreflang link injection, per-locale sitemap generation,
7//! and a language switcher HTML helper.
8//!
9//! ## Overview
10//!
11//! The `I18nPlugin` scans the site output directory for locale-prefixed
12//! subdirectories (e.g. `/en/`, `/fr/`) and:
13//!
14//! 1. Injects `<link rel="alternate" hreflang="…">` tags into every HTML
15//!    page that exists in multiple locales.
16//! 2. Adds an `x-default` alternate pointing to the default locale.
17//! 3. Generates per-locale sitemaps (`sitemap-en.xml`, `sitemap-fr.xml`, …)
18//!    with `xhtml:link` alternates.
19//!
20//! The injection is **idempotent** — pages that already contain hreflang
21//! links are skipped.
22
23use 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// ── Configuration ────────────────────────────────────────────────────
33
34/// Strategy for constructing locale-specific URLs.
35///
36/// Marked `#[non_exhaustive]` so future strategies (e.g. query-string,
37/// custom plugin-driven mapping) can be added non-breakingly.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40#[derive(Default)]
41#[non_exhaustive]
42pub enum UrlPrefixStrategy {
43    /// Locale appears as a path prefix: `https://example.com/fr/about`
44    #[default]
45    SubPath,
46    /// Locale appears as a subdomain: `https://fr.example.com/about`
47    SubDomain,
48}
49
50/// Parsed `[i18n]` configuration section.
51///
52/// # Example (TOML)
53///
54/// ```toml
55/// [i18n]
56/// default_locale = "en"
57/// locales = ["en", "fr", "de"]
58/// url_prefix = "sub_path"
59/// ```
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct I18nConfig {
62    /// The default / fallback locale (used for `x-default`).
63    pub default_locale: String,
64    /// All supported locales.
65    pub locales: Vec<String>,
66    /// How locale URLs are constructed.
67    #[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// ── Plugin ───────────────────────────────────────────────────────────
82
83/// I18n plugin that injects hreflang links and generates per-locale sitemaps.
84#[derive(Debug)]
85pub struct I18nPlugin {
86    config: I18nConfig,
87}
88
89impl I18nPlugin {
90    /// Creates a new `I18nPlugin` with the given i18n configuration.
91    #[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        // Only operate when more than one locale is configured.
108        if self.config.locales.len() < 2 {
109            return Ok(());
110        }
111
112        // Detect which locale directories actually exist on disk.
113        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        // Collect the set of relative page paths per locale.
120        let pages = collect_locale_pages(&ctx.site_dir, &present_locales)?;
121
122        // Determine the base URL (needed for sitemaps).
123        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 into each HTML page.
129        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 per-locale sitemaps.
139        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        // Generate locale redirect index.html at site root.
149        crate::server::generate_locale_redirect(
150            &ctx.site_dir,
151            &present_locales,
152            &self.config.default_locale,
153        )?;
154
155        Ok(())
156    }
157}
158
159// ── Locale detection ─────────────────────────────────────────────────
160
161/// Returns the subset of `locales` that have a matching directory inside
162/// `site_dir`.
163fn 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
171// ── Page collection ──────────────────────────────────────────────────
172
173/// For each relative page path (e.g. `about/index.html`), records which
174/// locales provide that page.
175///
176/// Returns `path -> set-of-locales`.
177fn 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
199/// Recursively walk `current` under `root`, recording relative HTML paths.
200fn 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
228// ── Hreflang injection ───────────────────────────────────────────────
229
230/// Sentinel substring used for idempotency checks.
231const HREFLANG_MARKER: &str = "rel=\"alternate\" hreflang=";
232
233/// Inject hreflang `<link>` tags into every HTML page that exists in at
234/// least two locales.
235fn 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        // Only inject when the page exists in more than one locale.
248        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            // Idempotency: skip if already injected.
267            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            // Also inject visible language switcher at the marker
288            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
313/// Replaces the `<!-- ssg:lang-switcher -->` marker with a full language
314/// switcher listing every available locale. Called by the i18n plugin
315/// only when multiple locales are present on disk.
316fn 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
339/// Marker comment embedded in templates where the language switcher
340/// should be injected. Kept invisible in single-locale sites.
341const LANG_SWITCHER_MARKER: &str = "<!-- ssg:lang-switcher -->";
342
343/// Build the hreflang `<link>` block for a single page.
344fn 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    // x-default points to the default locale.
364    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
372/// Construct a full URL for a given locale + relative path.
373fn 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            // Replace scheme://host with scheme://locale.host
385            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                // Fallback: treat as sub-path.
390                format!("{base}/{locale}/{rel_path}")
391            }
392        }
393    }
394}
395
396/// Insert `links` just before the first `</head>` tag, if present.
397fn 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
407// ── Per-locale sitemaps ──────────────────────────────────────────────
408
409/// Generate `sitemap-{locale}.xml` for every present locale.
410fn 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            // xhtml:link alternates for all locales that share this page.
440            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                // x-default
451                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// ── Accept-Language parsing ─────────────────────────────────────────
474
475/// Parses an Accept-Language header value into a sorted list of locale
476/// preferences (highest quality first).
477///
478/// Example: "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5"
479/// Returns: `["fr-CH", "fr", "en", "de", "*"]`
480#[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    // Sort by quality descending; stable sort preserves order for equal quality.
511    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/// Given a list of preferred locales (from Accept-Language) and a list
519/// of available locales (directories on disk), returns the best match.
520///
521/// Matching rules:
522/// 1. Exact match (e.g., "fr-CH" matches "fr-CH")
523/// 2. Prefix match (e.g., "fr-CH" matches "fr")
524/// 3. Default locale fallback
525#[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        // Skip wildcard
536        if pref == "*" {
537            continue;
538        }
539        let pref_lower = pref.to_lowercase();
540
541        // Exact match
542        if let Some(idx) = available_lower.iter().position(|a| *a == pref_lower)
543        {
544            return available[idx].clone();
545        }
546
547        // Prefix match: preferred "fr-CH" matches available "fr"
548        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// ── Language switcher helper ─────────────────────────────────────────
558
559/// Generates an HTML snippet for a language switcher navigation.
560///
561/// This is a pure function that can be called from any plugin or template
562/// helper to produce a `<nav>` block with links to all locale variants
563/// of the current page.
564///
565/// # Arguments
566///
567/// * `locales` — All available locales.
568/// * `current_locale` — The locale of the page being rendered.
569/// * `current_path` — The relative path of the page (e.g. `about/index.html`).
570/// * `base_url` — The site base URL.
571/// * `strategy` — How locale URLs are constructed.
572///
573/// # Example
574///
575/// ```rust
576/// use ssg::i18n::{generate_lang_switcher_html, UrlPrefixStrategy};
577///
578/// let html = generate_lang_switcher_html(
579///     &["en".into(), "fr".into(), "de".into()],
580///     "en",
581///     "about/index.html",
582///     "https://example.com",
583///     &UrlPrefixStrategy::SubPath,
584/// );
585/// assert!(html.contains("lang=\"fr\""));
586/// ```
587#[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// ── Tests ────────────────────────────────────────────────────────────
617
618#[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    /// Helper: create an HTML file with a `</head>` tag.
642    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    // ── detect_locale_dirs ───────────────────────────────────────
654
655    #[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    // ── hreflang injection ───────────────────────────────────────
676
677    #[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        // Both files should contain hreflang links.
696        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        // Check x-default points to en.
703        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        // fr only has index
721        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        // about.html only exists in en — should NOT have hreflang.
733        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        // Run twice.
758        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        // en + fr + x-default = 3 links, and only one run should inject.
764        assert_eq!(count, 3, "expected 3 hreflang links, got {count}");
765    }
766
767    // ── x-default ────────────────────────────────────────────────
768
769    #[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        // x-default should point to fr (the configured default).
789        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    // ── multi-locale detection ───────────────────────────────────
796
797    #[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        // Should have de, en, fr + x-default = 4 links.
817        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    // ── sitemap generation ───────────────────────────────────────
825
826    #[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    // ── SubDomain strategy ───────────────────────────────────────
857
858    #[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    // ── Language switcher ────────────────────────────────────────
881
882    #[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    // ── inject_before_head_close ─────────────────────────────────
898
899    #[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    // ── Plugin basics ────────────────────────────────────────────
913
914    #[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        // Default has only "en" — should be a no-op.
942        assert!(p.after_compile(&ctx).is_ok());
943    }
944
945    // ── I18nConfig defaults ──────────────────────────────────────
946
947    #[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    // ── Nested page paths ────────────────────────────────────────
956
957    // ── Language switcher edge cases ────────────────────────────────
958
959    #[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        // No <li> items
971        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        // Only one <li>
985        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    // ── Per-locale sitemap with xhtml:link alternates ────────────
1002
1003    #[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        // Should contain xhtml:link alternates for all 3 locales + x-default
1023        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        // x-default should point to en (default locale)
1028        assert!(en_sm.contains(
1029            "hreflang=\"x-default\" href=\"https://example.com/en/index.html\""
1030        ));
1031    }
1032
1033    // ── I18nPlugin with actual locale directories ───────────────
1034
1035    #[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        // en has page A, fr has page B — no overlap
1041        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        // No hreflang should be injected since no pages are shared
1054        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        // Only en directory exists, fr is configured but missing
1066        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    // ── build_url subdomain fallback ────────────────────────────
1082
1083    #[test]
1084    fn subdomain_strategy_fallback_without_scheme() {
1085        // When base has no "://" it falls back to sub-path style
1086        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    // ── parse_accept_language ───────────────────────────────────
1119
1120    #[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    // ── negotiate_locale ────────────────────────────────────────
1159
1160    #[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    // ── generate_locale_redirect ────────────────────────────────
1203
1204    #[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        // Write a custom index.html first
1247        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}