Skip to main content

ssg/plugins/
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//!
23//! ## Pairing pages across locales
24//!
25//! Before anything can be injected, the plugin has to decide which
26//! pages in different locales are translations of one another. It
27//! builds a matrix of `key -> {locale -> path}` and treats every page
28//! sharing a key as one document.
29//!
30//! A page's key is its `translation_key` front-matter value when it
31//! declares one, and its locale-relative path otherwise:
32//!
33//! ```yaml
34//! ---
35//! title: "À propos"
36//! translation_key: "about"
37//! ---
38//! ```
39//!
40//! Path matching alone cannot pair `/about/` with `/fr/a-propos/` —
41//! the paths differ, so each is a singleton and **neither receives any
42//! hreflang at all**. Because that failure is silent, it is easy to
43//! ship. A shared `translation_key` pairs them regardless of slug.
44//!
45//! Pages without a key keep pairing by path, so a site with no
46//! `translation_key` anywhere produces exactly the matrix it produced
47//! before the field existed.
48//!
49//! The value is read from the front-matter sidecars written by
50//! [`crate::frontmatter::emit_sidecars`], because the plugin runs
51//! after compilation and can no longer see the source front matter.
52//!
53//! ## Where the default locale lives
54//!
55//! The default locale may occupy the site root, with only the other
56//! locales taking a URL segment (`/about/` alongside
57//! `/fr/a-propos/`) — the default in Hugo, Astro and Next.js. This is
58//! detected, not configured — the root locale is used when the default
59//! locale has no output directory of its own and HTML exists outside
60//! the other locale directories.
61//!
62//! ## Reciprocity
63//!
64//! Each alternate link is labelled with the resolved language of the
65//! document it points *at*, not with the bare locale directory name.
66//! Labelling by directory lets the two halves of a pair disagree — an
67//! English page calling its Hindi alternate `hi` while the Hindi page
68//! calls itself `hi-IN` — which fails Google's reciprocity requirement
69//! and the `hreflang` audit gate with it. An authored locale code is
70//! preserved byte-for-byte (`zh-tw` stays `zh-tw`); a resolved
71//! language replaces it only on a genuine front-matter override.
72
73use crate::error::{PathErrorExt, SsgError};
74use crate::plugin::{Plugin, PluginContext};
75use crate::util::head_dom::inject_before_head_close as inject_head;
76// The locale/URL/hreflang logic now lives in `ssg-i18n`, a leaf crate
77// with no knowledge of plugins or the filesystem (#588). Re-exported
78// below so `crate::i18n::I18nConfig` and the rest keep working.
79// Public before the move, so public after it: ssg's API is unchanged.
80/// Re-exports of items that were crate-private before #588.
81///
82/// They have to be `pub` in `ssg-i18n` to cross the crate boundary, but
83/// re-exporting them publicly here would widen ssg's API, which a
84/// mechanical move must not do. These `compile_fail` doctests are the
85/// assertion that it did not happen — if one of them ever compiles,
86/// something private has escaped:
87///
88/// ```compile_fail
89/// use ssg::i18n::build_url;
90/// ```
91///
92/// ```compile_fail
93/// use ssg::i18n::build_hreflang_links;
94/// ```
95///
96/// ```compile_fail
97/// use ssg::i18n::find_lang_switcher_element;
98/// ```
99///
100/// ```compile_fail
101/// use ssg::i18n::generate_lang_switcher_html_with_self_lang;
102/// ```
103///
104/// ```compile_fail
105/// use ssg::i18n::rewrite_ap_lang_items;
106/// ```
107///
108/// ```compile_fail
109/// use ssg::i18n::sidecar_candidates;
110/// ```
111///
112/// ```compile_fail
113/// use ssg::i18n::HREFLANG_MARKER;
114/// ```
115///
116/// ```compile_fail
117/// use ssg::i18n::LANG_SWITCHER_MARKER;
118/// ```
119pub(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// ── Configuration ────────────────────────────────────────────────────
136
137/// Cached locale matrix shared between `after_compile` and `transform_html`.
138///
139/// Built lazily on first invocation per `(site_dir, locales)` pairing so
140/// that the per-file `transform_html` hook does not re-walk the locale
141/// directories for every HTML file processed in the fused transform pass.
142#[derive(Debug, Default)]
143struct LocaleMatrixCache {
144    site_dir: Option<PathBuf>,
145    present_locales: Vec<String>,
146    /// The locale served from the site root with no URL segment, if
147    /// any — see [`detect_locales`].
148    root_locale: Option<String>,
149    /// Translation matrix: `key -> {locale -> rel_path}`.
150    ///
151    /// The key is the page's `translation_key` front-matter value when
152    /// it declares one, and its locale-relative path otherwise (the
153    /// pre-`translation_key` path-matching behaviour, kept so existing
154    /// sites are unaffected).
155    pages: LocaleMatrix,
156    /// Reverse index: `(locale, rel_path) -> key`.
157    keys: HashMap<(String, String), String>,
158}
159
160/// Reverse index of [`LocaleMatrix`]: `(locale, rel_path) -> key`.
161type LocaleKeyIndex = HashMap<(String, String), String>;
162
163/// `key -> {locale -> rel_path}`.
164///
165/// `BTreeMap` for the inner map so hreflang links, sitemap alternates
166/// and the language switcher all emit locales in one stable order
167/// without a sort at every call site.
168type LocaleMatrix = HashMap<String, BTreeMap<String, String>>;
169
170/// I18n plugin that injects hreflang links and generates per-locale sitemaps.
171///
172/// Implements two complementary phases:
173///
174/// 1. **`transform_html`** — per-file hreflang `<link>` injection that runs
175///    inside the fused transform pass, ensuring Taxonomy/Pagination output
176///    is covered alongside template-engine pages.
177/// 2. **`after_compile`** — per-locale sitemap generation and the root-level
178///    locale-redirect index page (whole-site artefacts that cannot be
179///    produced from a per-file hook).
180#[derive(Debug)]
181pub struct I18nPlugin {
182    config: I18nConfig,
183    /// Lazily-populated locale matrix shared by hooks.
184    ///
185    /// `RwLock` rather than `Mutex` (plan §4 3.4): after warm-up the
186    /// matrix is read-mostly — parallel `transform_html` workers only
187    /// take the shared read lock, so lookups no longer serialise. The
188    /// write lock is taken only on first fill and on the deliberate
189    /// `after_compile` invalidation.
190    matrix: RwLock<LocaleMatrixCache>,
191}
192
193impl I18nPlugin {
194    /// Creates a new `I18nPlugin` with the given i18n configuration.
195    ///
196    /// # Examples
197    ///
198    /// ```rust
199    /// use ssg::i18n::{I18nConfig, I18nPlugin};
200    /// use ssg::plugin::Plugin;
201    ///
202    /// let cfg = I18nConfig::default();
203    /// let p = I18nPlugin::new(cfg);
204    /// assert_eq!(p.name(), "i18n");
205    /// ```
206    #[must_use]
207    pub fn new(config: I18nConfig) -> Self {
208        Self {
209            config,
210            matrix: RwLock::new(LocaleMatrixCache::default()),
211        }
212    }
213
214    /// Ensures the locale matrix cache is populated for the given site
215    /// directory. Cheap on subsequent calls — the directory walk only
216    /// executes once per `site_dir`.
217    fn ensure_matrix(&self, ctx: &PluginContext) -> Result<(), SsgError> {
218        let site_dir = ctx.site_dir.as_path();
219        // Fast path: shared read lock. After warm-up every caller
220        // (including parallel `transform_html` workers) takes only this
221        // branch (plan §4 3.4).
222        {
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        // Double-check under the write lock — another thread may have
237        // filled the cache while we waited for it.
238        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        // Only operate when more than one locale is configured.
277        if self.config.locales.len() < 2 {
278            return Ok(());
279        }
280
281        // Re-walk the locale matrix here so that pages emitted by
282        // Taxonomy/Pagination during their own `after_compile` hooks are
283        // picked up. Always rebuild on `after_compile` to guarantee the
284        // matrix reflects the final on-disk state.
285        {
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        // Determine the base URL (needed for sitemaps).
311        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 into each HTML page.
317        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 per-locale sitemaps.
329        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        // Generate locale redirect index.html at site root — but only
341        // when every locale lives in its own directory. With a
342        // root-hosted default locale the site root already IS that
343        // locale's home page; a redirect there would either be skipped
344        // (the page carries no marker) or, on a rebuild over a previous
345        // redirect, shadow a real page.
346        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    /// Per-file hreflang injection.
363    ///
364    /// Runs inside the fused transform pass so it covers HTML produced by
365    /// any plugin (Taxonomy, Pagination, template engine). Idempotent — a
366    /// page that already carries hreflang `<link>` tags is returned
367    /// unchanged. Pages without parallel translations are left untouched
368    /// so that missing translations never yield broken hreflang entries.
369    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        // Snapshot the cached matrix data we need.
384        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        // Skip pages that only exist in one locale — AC4 (no broken
411        // hreflangs).
412        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        // Resolve this page's language once (spec A5, plan §2 1.5) so
423        // the hreflang self-reference and the switcher's self entry
424        // agree with `<html lang>` / `inLanguage` / `og:locale`.
425        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        // Lang switcher + ap-lang-item rewrite (kept consistent with the
449        // `after_compile` injection path).
450        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
471/// Splits an absolute HTML path inside `site_dir` into (locale, rel-path).
472///
473/// A path whose first component names a locale *directory* belongs to
474/// that locale, and `rel-path` is what follows it. Any other path
475/// belongs to `root_locale` when one is configured — that locale has no
476/// directory of its own and is served straight from the site root, so
477/// its `rel-path` is the whole site-relative path.
478fn 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    // The root-hosted locale owns no directory, so its name must never
489    // be treated as a locale-directory segment.
490    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
507// ── Locale detection ─────────────────────────────────────────────────
508
509/// Returns `(present_locales, root_locale)`.
510///
511/// A configured locale is present when `site_dir/<locale>` exists.
512/// Additionally, the **default** locale is present without a directory
513/// of its own when the site root holds HTML outside the other locale
514/// directories — the root-hosted-default-locale convention shared by
515/// Hugo (`defaultContentLanguageInSubdir = false`), Astro
516/// (`prefixDefaultLocale: false`) and Next.js. In that case it is also
517/// returned as `root_locale`, and everything downstream renders its
518/// URLs without a locale segment.
519///
520/// The returned list preserves the configured order.
521fn 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
553/// `true` when at least one HTML file sits at the site root or in a
554/// directory that is not one of `locale_dirs`.
555///
556/// Walk errors are treated as "no HTML" here: detection must not fail a
557/// build on its own. The same directories are re-walked by
558/// [`collect_locale_pages`], which does propagate I/O errors.
559fn 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// ── Page collection ──────────────────────────────────────────────────
567
568/// One built page, as seen by the locale walker.
569#[derive(Debug)]
570struct LocalePage {
571    /// Locale serving the page.
572    locale: String,
573    /// Path relative to the locale's URL root — `about/index.html`
574    /// both for `<site>/en/about/index.html` and, when `en` is the
575    /// root-hosted locale, for `<site>/about/index.html`.
576    rel: String,
577    /// Path relative to the site root, used to find the page's
578    /// front-matter sidecar.
579    site_rel: String,
580}
581
582/// Builds the translation matrix and its reverse index.
583///
584/// Returns `(key -> {locale -> rel_path}, (locale, rel_path) -> key)`.
585///
586/// The key of a page is its `translation_key` front-matter value when
587/// it declares one, and its locale-relative path otherwise. Path
588/// matching therefore survives untouched as the fallback: a site with
589/// no `translation_key` anywhere produces exactly the matrix the
590/// pre-`translation_key` implementation produced.
591///
592/// `root_locale`, when set, names the locale whose pages live at the
593/// site root rather than in a directory of their own.
594fn 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
646/// Reads the `translation_key` front-matter value for the built page at
647/// site-relative `site_rel`, if it declares one.
648///
649/// `emit_sidecars` keys sidecars by the *content* path
650/// (`content/fr/a-propos.md` → `.meta/fr/a-propos.meta.json`), while
651/// the compiler publishes that page at `fr/a-propos/index.html`. Both
652/// spellings are tried, plus the `<page>.html → <page>.meta.json` form
653/// for pages that are not directory-indexed.
654fn 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
675/// Locates the front-matter sidecar directory, mirroring
676/// `template_plugin::resolve_sidecar_dir`: `<build>/.meta` while the
677/// build directory still exists, `<site>/.meta` after it has been
678/// promoted to the site directory.
679fn 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
688/// Walks the site root for the root-hosted locale's pages, skipping the
689/// other locales' directories and every dot-directory (`.meta`,
690/// `.ssg-cache`, …) — those hold build metadata, not pages.
691fn 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            // Only top-level directories can be locale directories.
709            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
726/// Recursively walk `current` under `root`, recording relative HTML paths.
727fn 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
760/// Slash-normalised path of `path` relative to `root`.
761fn rel_of(path: &Path, root: &Path) -> String {
762    path.strip_prefix(root)
763        .unwrap_or(path)
764        .to_string_lossy()
765        .replace('\\', "/")
766}
767
768/// Resolves the on-disk path of a page: `site_dir/<locale>/<rel>` for a
769/// directory-hosted locale, `site_dir/<rel>` for the root-hosted one.
770fn 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
783// ── Hreflang injection ───────────────────────────────────────────────
784
785/// Inject hreflang `<link>` tags into every HTML page that exists in at
786/// least two locales.
787///
788/// Each page's SELF-reference `hreflang` (and the language-switcher
789/// self entry) carries the language resolved by
790/// `seo::lang::resolve_page_lang` for that page — the same value the
791/// `<html lang>`, JSON-LD `inLanguage`, and `og:locale` sinks publish
792/// (spec A5 acceptance: four sinks, one value).
793fn 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        // Only inject when the page exists in more than one locale.
808        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            // Idempotency: skip if already injected.
825            if html.contains(HREFLANG_MARKER) {
826                continue;
827            }
828
829            // Resolve this page's language once so every self-labelled
830            // emission below agrees with the other language sinks.
831            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            // Also inject visible language switcher at the marker
860            let html = inject_lang_switcher(
861                &html,
862                locale,
863                locale_map,
864                &labels,
865                base,
866                strategy,
867                root_locale,
868            );
869
870            // Rewrite existing ap-lang-item links to the exact localized page path
871            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
893/// Resolves the `hreflang` label to advertise for every locale serving
894/// a page.
895///
896/// The SELF entry uses `self_lang` — the value
897/// `seo::lang::resolve_page_lang` gave the page being written, i.e. the
898/// same value its `<html lang>`, JSON-LD `inLanguage` and `og:locale`
899/// publish (spec A5). Every OTHER entry describes a *different*
900/// document, so it carries **that** page's resolved language rather
901/// than its bare locale directory name. Without this the two sides of a
902/// pair can disagree (`/` labelling itself `en-GB` while `/fr/` labels
903/// it `en`), and Google's reciprocity requirement — enforced by the
904/// `hreflang` audit gate — is not met.
905///
906/// Issue #522 AC5: an authored locale code is preserved byte-for-byte
907/// (`zh-tw` stays `zh-tw`); the resolved language only replaces it when
908/// it differs beyond case, i.e. on a genuine front-matter override.
909fn 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
933/// Replaces the `<!-- ssg:lang-switcher -->` marker with a full language
934/// switcher listing every available locale. Called by the i18n plugin
935/// only when multiple locales are present on disk.
936///
937/// `self_lang` is the current page's resolved language
938/// (`seo::lang::resolve_page_lang`); the switcher's self entry
939/// advertises it in `lang=`/`hreflang=` so the switcher agrees with
940/// the page's other language sinks (spec A5).
941fn 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
975/// Insert `links` just before the first `</head>` tag, if present.
976///
977/// Thin shim over the shared [`inject_head`] helper that returns `None`
978/// when the document has no `<head>` — keeping the historical
979/// `Option<String>` signature for the test suite that asserts on it.
980fn 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
992// ── Per-locale sitemaps ──────────────────────────────────────────────
993
994/// Generate `sitemap-{locale}.xml` for every present locale.
995///
996/// Inside `sitemap-{L}.xml`, each `<url>` names the `L`-locale copy of
997/// a page, so the `xhtml:link` whose `hreflang` matches `L` is that
998/// page's SELF-reference. That entry is routed through
999/// [`resolved_page_lang_for`] (spec A5, plan §2 1.5) so it carries the
1000/// same value as `<html lang>`, JSON-LD `inLanguage`, `og:locale`, and
1001/// the in-page hreflang self-reference. Alternates for other locales
1002/// keep their per-target-locale labels.
1003fn 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        // Sort by this locale's own path so the file is deterministic
1023        // even though translated slugs differ between locales.
1024        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            // `filter` above guarantees this locale is present.
1032            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            // The page this <url> entry describes — its resolved
1040            // language labels the self-referencing xhtml:link, and each
1041            // alternate carries the language of the page it names.
1042            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            // x-default, when the default locale actually serves it.
1061            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
1087/// Resolves the language of the built page at
1088/// `<site_dir>/<locale>/<rel_path>` via
1089/// `seo::lang::resolve_page_lang`, falling back to the locale
1090/// directory name when the file cannot be read (deleted mid-build,
1091/// permissions) — the pre-A5 label, so output never regresses below
1092/// the historic behaviour.
1093fn 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// ── Accept-Language parsing ─────────────────────────────────────────
1107
1108// ── Tests ────────────────────────────────────────────────────────────
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113    use crate::plugin::PluginContext;
1114    use std::path::Path;
1115    use tempfile::tempdir;
1116
1117    /// Builds a translation-matrix row where every locale serves the
1118    /// same `rel` path — the shape a site without translated slugs has.
1119    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    /// Like [`make_ctx`] but with a real build dir so
1156    /// `seo::lang::resolve_page_lang` can find `.meta` sidecars.
1157    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    /// Writes a front-matter sidecar under `<build>/.meta/<rel>.meta.json`.
1173    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    /// Helper: create an HTML file with a `</head>` tag.
1183    fn write_html(dir: &Path, rel: &str, body: &str) {
1184        let path = dir.join(rel);
1185        // dir.join(rel) always has a parent; expect avoids an
1186        // uncoverable `if let` fallthrough region.
1187        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    // ── detect_locale_dirs ───────────────────────────────────────
1196
1197    #[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    // ── hreflang injection ───────────────────────────────────────
1222
1223    #[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        // Both files should contain hreflang links.
1242        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        // Check x-default points to en.
1249        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        // fr only has index
1267        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        // about.html only exists in en — should NOT have hreflang.
1279        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        // Run twice.
1304        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        // en + fr + x-default = 3 links, and only one run should inject.
1310        assert_eq!(count, 3, "expected 3 hreflang links, got {count}");
1311    }
1312
1313    // ── x-default ────────────────────────────────────────────────
1314
1315    #[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        // x-default should point to fr (the configured default).
1335        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    // ── multi-locale detection ───────────────────────────────────
1342
1343    #[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        // Should have de, en, fr + x-default = 4 links.
1363        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    // ── sitemap generation ───────────────────────────────────────
1371
1372    #[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    // ── SubDomain strategy ───────────────────────────────────────
1403
1404    #[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    // ── Language switcher ────────────────────────────────────────
1429
1430    #[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    // ── inject_before_head_close ─────────────────────────────────
1446
1447    #[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    // ── Plugin basics ────────────────────────────────────────────
1461
1462    #[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        // Default has only "en" — should be a no-op.
1490        assert!(p.after_compile(&ctx).is_ok());
1491    }
1492
1493    // ── I18nConfig defaults ──────────────────────────────────────
1494
1495    #[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    // ── Nested page paths ────────────────────────────────────────
1504
1505    // ── Language switcher edge cases ────────────────────────────────
1506
1507    #[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        // No <li> items
1519        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        // Only one <li>
1533        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    // ── Per-locale sitemap with xhtml:link alternates ────────────
1550
1551    #[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        // Should contain xhtml:link alternates for all 3 locales + x-default
1571        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        // x-default should point to en (default locale)
1576        assert!(en_sm.contains(
1577            "hreflang=\"x-default\" href=\"https://example.com/en/index.html\""
1578        ));
1579    }
1580
1581    // ── Resolved self-reference language (spec A5, plan §2 1.5) ──
1582
1583    /// Full fixture: `/en/about.html` + `/hi/about.html` where the hi
1584    /// page's front-matter sidecar declares `language: hi-IN`.
1585    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        // Self-reference carries the resolver's value, not the
1610        // directory name — one value across all four sinks.
1611        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        // Alternate to the OTHER locale keeps its per-target label.
1618        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        // From the en page, the link *to* the hi page describes the hi
1627        // DOCUMENT, so it carries that document's resolved language —
1628        // the same `hi-IN` the hi page advertises for itself. The two
1629        // sides of the pair must agree on the tag or Google's
1630        // reciprocity requirement (and the `hreflang` audit gate) is
1631        // not met.
1632        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        // sitemap-hi.xml describes the /hi/ copies: the self xhtml:link
1661        // is resolver-labelled.
1662        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        // From sitemap-en.xml, the hi entry names the hi document, so
1677        // it carries that document's resolved language — matching the
1678        // in-page alternates above.
1679        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        // Pages carry the switcher marker so injection kicks in.
1694        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        // Self entry: resolved language on lang=/hreflang=, visible
1708        // label still the locale directory name.
1709        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        // Other-locale entry unchanged.
1716        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    // ── I18nPlugin with actual locale directories ───────────────
1753
1754    #[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        // en has page A, fr has page B — no overlap
1760        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        // No hreflang should be injected since no pages are shared
1773        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        // Only en directory exists, fr is configured but missing
1785        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    // ── Root-hosted default locale (blocker 2) ───────────────────
1801
1802    #[test]
1803    fn root_hosted_default_locale_gets_reciprocal_hreflang() {
1804        // Hosting the default locale at the site root — Hugo's
1805        // `defaultContentLanguageInSubdir = false`, Astro's
1806        // `prefixDefaultLocale: false`, Next.js's default — is the
1807        // prevailing convention and keeps clean URLs for the locale
1808        // that usually carries most of the traffic.
1809        //
1810        // `en` therefore has NO `en/` directory: its pages sit at the
1811        // site root, alongside the `fr/` directory.
1812        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        // The root-hosted locale's own URL carries no locale segment,
1842        // and nothing anywhere emits an `/en/` path.
1843        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        // …and reciprocally, so the hreflang audit gate passes.
1852        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        // Nested pages resolve the same way.
1870        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        // The root index is a real page — the locale-redirect shim must
1883        // not be written over it.
1884        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        // The site root's index.html here is the locale-redirect shim,
1906        // not a page — `en/` exists, so nothing is root-hosted.
1907        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        // `fr/` alone with an empty root is a single-locale site, not a
1922        // root-hosted default — otherwise every partially-built site
1923        // would claim two locales.
1924        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        // Non-root locales are unaffected.
1952        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        // Root page -> the root-hosted locale, whole path as rel.
1971        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        // A real locale directory still wins.
1981        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        // Without a root locale, root pages stay unclaimed.
1991        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        // Build metadata, not pages.
2008        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    // ── Translated slugs via `translation_key` (blocker 1) ───────
2041
2042    /// Writes a front-matter sidecar keyed the way `emit_sidecars`
2043    /// keys them: by the *content* path, not the compiled HTML path.
2044    /// `content/fr/a-propos.md` → `.meta/fr/a-propos.meta.json`, which
2045    /// backs the page compiled to `fr/a-propos/index.html`.
2046    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        // `/about/` and `/fr/a-propos/` are the same logical page.
2057        // Nothing in their paths says so — only a shared
2058        // `translation_key` in front matter does, exactly as Hugo's
2059        // `translationKey` works.
2060        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        // Home pages share a key too, with identical slugs.
2121        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        // Existing single- and multi-locale sites carry no
2131        // `translation_key`; identical paths must go on pairing.
2132        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    // ── build_url subdomain fallback ────────────────────────────
2156
2157    #[test]
2158    fn subdomain_strategy_fallback_without_scheme() {
2159        // When base has no "://" it falls back to sub-path style
2160        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    // ── parse_accept_language ───────────────────────────────────
2194
2195    #[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    // ── negotiate_locale ────────────────────────────────────────
2234
2235    #[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    // ── generate_locale_redirect ────────────────────────────────
2278
2279    #[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        // Write a custom index.html first
2322        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    // ── transform_html (issue #522 fused-transform pass) ────────────
2410
2411    #[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        // Only one locale dir → cache.present_locales.len() < 2 path.
2449        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        // Path has no recognised locale prefix segment.
2478        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        // Path under a known locale dir but not present in either matrix
2500        // (we never wrote `en/missing.html`).
2501        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        // SubPath strategy default base_url.
2528        assert!(out.contains("https://example.com/en/index.html"));
2529    }
2530
2531    #[test]
2532    fn transform_html_single_locale_page_returns_unchanged() {
2533        // Page exists in only one locale even though two locale dirs are
2534        // present on disk — `page_locales.len() < 2` early-out.
2535        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        // No </head> tag at all — inject_before_head_close returns None.
2569        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    // ── resolve_locale_and_rel direct unit tests ────────────────────
2575
2576    #[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        // `/site/en` with no further path segment.
2605        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    // ── inject_lang_switcher (replace marker path) ──────────────────
2612
2613    /// html-generator minifies some pages during generation, before any
2614    /// plugin runs, and minification strips comments — so the comment
2615    /// marker was gone by the time the switcher was injected, and the
2616    /// switcher silently vanished from every minified page. The element
2617    /// form survives.
2618    #[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    /// Minifiers reformat attributes and drop whitespace; the marker has
2630    /// to survive both.
2631    #[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    /// A non-empty element is content, not a placeholder — replacing it
2646    /// would destroy the author's markup.
2647    #[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    /// The attribute must belong to the tag it was found inside.
2654    #[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    // ── rewrite_ap_lang_items edge cases ────────────────────────────
2696
2697    #[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        // The data-lang attribute references a locale not in the page
2714        // matrix — link should be left alone.
2715        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        // Anchor open `<a ` with no closing `>` — must not panic, must
2731        // return early.
2732        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        // SubDomain produces https://fr.example.com/page.html, so href
2757        // becomes the path part only.
2758        assert!(out.contains("href=\"/page.html\""), "out={out}");
2759    }
2760
2761    // ── parse_accept_language edge cases ────────────────────────────
2762
2763    #[test]
2764    fn parse_accept_language_skips_empty_parts() {
2765        // Trailing comma / double comma produce empty parts after split.
2766        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        // A part that's just `;q=0.5` (no locale) must be filtered out.
2773        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        // `en` has invalid q, defaults to 1.0 → sorts ahead of fr.
2787        assert_eq!(out, vec!["en", "fr"]);
2788    }
2789
2790    #[test]
2791    fn parse_accept_language_missing_q_prefix_defaults_to_one() {
2792        // The segment after `;` doesn't start with `q=`, so
2793        // `strip_prefix("q=")` fails and quality falls back to 1.0
2794        // rather than being parsed from the bare number.
2795        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        // "nan" parses successfully via f64::from_str to NaN. NaN's
2804        // partial_cmp always returns None, exercising the
2805        // `unwrap_or(Ordering::Equal)` fallback in the sort comparator.
2806        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    // ── ensure_matrix cache short-circuit ───────────────────────────
2813
2814    #[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        // Second call against the same dir hits the cache fast-path.
2829        p.ensure_matrix(&ctx).unwrap();
2830        let cache = p.matrix.read().unwrap();
2831        assert_eq!(cache.present_locales, vec!["en", "fr"]);
2832    }
2833
2834    // ── ensure_matrix re-entry on different site_dir ────────────────
2835
2836    #[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        // Populate cache against tmp1.
2852        p.ensure_matrix(&make_ctx(tmp1.path())).unwrap();
2853        // Then re-populate against tmp2 — must NOT short-circuit.
2854        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    // ── coverage: error propagation + rarely-taken branches ─────────
2861
2862    /// Builds a two-locale config (`en` default).
2863    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        // Two threads race to fill the matrix while the test pins the
2876        // read side: both pass the read-lock fast path (cache empty),
2877        // queue on the write lock, and whichever loses the race takes
2878        // the double-checked early return.
2879        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        // Let both workers pass the read check and block on the write
2898        // lock before releasing it.
2899        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        // PluginContext::new leaves `config` unset, driving the
2958        // map_or_else fallback closure.
2959        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        // Read succeeds, the write-back of the injected page does not.
3028        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        // A directory squatting on sitemap-en.xml makes the sitemap
3042        // write fail after hreflang injection succeeded.
3043        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        // Injection + sitemaps succeed; the root redirect (an existing
3061        // ssg-generated redirect page, now read-only) cannot be
3062        // rewritten and must surface as Err.
3063        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        // strip_prefix leaves an empty relative path, so the first
3079        // component lookup takes the `?` None branch.
3080        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        // Page shared by en+fr; `de` is in the locale list but not in
3141        // the page's locale set, taking the first `continue`.
3142        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        // The pages map claims an `fr` copy that does not exist; the
3172        // `!file.exists()` continue must skip it without error.
3173        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        // A shared page without a real <head> element: the injection
3202        // shim returns None and the original html flows through.
3203        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        // The lowercase substring check passes but lol_html never sees
3224        // a real <head> element, so the rewrite is a no-op and the
3225        // shim reports None.
3226        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        // Single-quoted attributes drive the second quote-candidate
3235        // iteration for both data-lang and href; the empty base yields
3236        // a non-http URL that is used verbatim.
3237        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        // The data-lang value quote never closes inside the tag, so no
3252        // language is extracted and the tag is left as-is.
3253        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        // data-lang parses but the href value quote never closes, so
3282        // the href rewrite loop exhausts both quote candidates.
3283        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        // The document mentions ap-lang-item (so the fast path does
3299        // not bail) but the anchor itself is a plain link.
3300        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}