Skip to main content

ssg_i18n/
lib.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Locale negotiation, hreflang and language switchers for multi-locale
5//! sites.
6//!
7//! Extracted from `ssg`'s i18n plugin (#588). Everything here is a pure
8//! function of its arguments — strings, maps and a strategy in, a string
9//! out. Nothing reads a file or knows what a plugin is, which is what
10//! lets it live outside the generator: `ssg-search` and `ssg-a11y` are
11//! the same shape, and it is the only shape that avoids a dependency
12//! cycle, since a crate implementing `Plugin` would have to depend on
13//! the crate that wants to call it.
14//!
15//! What stayed behind in `ssg` is everything that walks or writes the
16//! filesystem: locale detection, page collection, sitemap emission, and
17//! the `Plugin` implementation that drives them.
18//!
19//! ```
20//! use ssg_i18n::{negotiate_locale, parse_accept_language};
21//!
22//! let wanted = parse_accept_language("fr-CA,fr;q=0.9,en;q=0.5");
23//! let have = ["en".to_string(), "fr".to_string()];
24//! assert_eq!(negotiate_locale(&wanted, &have, "en"), "fr");
25//! ```
26
27use serde::{Deserialize, Serialize};
28use std::collections::BTreeMap;
29
30/// The README's examples are real doctests: every assertion in it is
31/// compiled and run, so the documented output cannot drift from the code.
32#[cfg(doctest)]
33#[doc = include_str!("../README.md")]
34struct ReadmeDoctests;
35
36/// Strategy for constructing locale-specific URLs.
37///
38/// Marked `#[non_exhaustive]` so future strategies (e.g. query-string,
39/// custom plugin-driven mapping) can be added non-breakingly.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[derive(Default)]
43#[non_exhaustive]
44pub enum UrlPrefixStrategy {
45    /// Locale appears as a path prefix: `https://example.com/fr/about`
46    #[default]
47    SubPath,
48    /// Locale appears as a subdomain: `https://fr.example.com/about`
49    SubDomain,
50}
51
52/// Parsed `[i18n]` configuration section.
53///
54/// # Example (TOML)
55///
56/// ```toml
57/// [i18n]
58/// default_locale = "en"
59/// locales = ["en", "fr", "de"]
60/// url_prefix = "sub_path"
61/// ```
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct I18nConfig {
64    /// The default / fallback locale (used for `x-default`).
65    pub default_locale: String,
66    /// All supported locales.
67    pub locales: Vec<String>,
68    /// How locale URLs are constructed.
69    #[serde(default)]
70    pub url_prefix: UrlPrefixStrategy,
71}
72
73impl Default for I18nConfig {
74    fn default() -> Self {
75        Self {
76            default_locale: "en".to_string(),
77            locales: vec!["en".to_string()],
78            url_prefix: UrlPrefixStrategy::default(),
79        }
80    }
81}
82
83// ── Plugin ───────────────────────────────────────────────────────────
84
85/// Sidecar file names that could carry `site_rel`'s front matter, most
86/// likely first.
87pub fn sidecar_candidates(site_rel: &str) -> Vec<String> {
88    let Some(stem) = site_rel.strip_suffix(".html") else {
89        return Vec::new();
90    };
91    let mut out = Vec::with_capacity(2);
92    // `about/index.html` is compiled from `about.md` in the common
93    // case, and from `about/index.md` when both spellings exist.
94    if let Some(dir) = stem.strip_suffix("/index") {
95        out.push(format!("{dir}.meta.json"));
96    }
97    out.push(format!("{stem}.meta.json"));
98    out
99}
100
101/// Sentinel substring used for idempotency checks.
102pub const HREFLANG_MARKER: &str = "rel=\"alternate\" hreflang=";
103
104/// Rewrites existing ap-lang-item links in the page to point to the exact localized path
105pub fn rewrite_ap_lang_items(
106    html: &str,
107    locale_map: &BTreeMap<String, String>,
108    base: &str,
109    strategy: &UrlPrefixStrategy,
110    root_locale: Option<&str>,
111) -> String {
112    if !html.contains("ap-lang-item") {
113        return html.to_string();
114    }
115
116    let mut result = String::with_capacity(html.len());
117    let mut remaining = html;
118
119    while let Some(start_idx) = remaining.find("<a ") {
120        result.push_str(&remaining[..start_idx]);
121        let tag_content = &remaining[start_idx..];
122
123        let Some(end_idx) = tag_content.find('>') else {
124            result.push_str(remaining);
125            return result;
126        };
127
128        let tag_inner = &tag_content[..end_idx + 1];
129        let mut rewritten_tag = tag_inner.to_string();
130
131        if tag_inner.contains("ap-lang-item") {
132            let mut data_lang = None;
133            for quote in ['"', '\''] {
134                let pattern = format!("data-lang={quote}");
135                if let Some(pos) = tag_inner.find(&pattern) {
136                    let val_start = pos + pattern.len();
137                    if let Some(val_end) = tag_inner[val_start..].find(quote) {
138                        data_lang = Some(
139                            tag_inner[val_start..val_start + val_end]
140                                .trim()
141                                .to_string(),
142                        );
143                        break;
144                    }
145                }
146            }
147
148            if let Some(lang) = data_lang {
149                if let Some(rel_path) = locale_map.get(&lang) {
150                    let full_url =
151                        build_url(base, &lang, rel_path, strategy, root_locale);
152                    let new_href = if full_url.starts_with("http://")
153                        || full_url.starts_with("https://")
154                    {
155                        let after_scheme =
156                            full_url.split("://").nth(1).unwrap_or("");
157                        if let Some(slash_idx) = after_scheme.find('/') {
158                            after_scheme[slash_idx..].to_string()
159                        } else {
160                            "/".to_string()
161                        }
162                    } else {
163                        full_url
164                    };
165
166                    for quote in ['"', '\''] {
167                        let href_pattern = format!("href={quote}");
168                        if let Some(pos) = tag_inner.find(&href_pattern) {
169                            let val_start = pos + href_pattern.len();
170                            if let Some(val_end) =
171                                tag_inner[val_start..].find(quote)
172                            {
173                                let before = &rewritten_tag[..val_start];
174                                let after =
175                                    &rewritten_tag[val_start + val_end..];
176                                rewritten_tag =
177                                    format!("{before}{new_href}{after}");
178                                break;
179                            }
180                        }
181                    }
182                }
183            }
184        }
185
186        result.push_str(&rewritten_tag);
187        remaining = &tag_content[end_idx + 1..];
188    }
189
190    result.push_str(remaining);
191    result
192}
193
194/// Marker comment embedded in templates where the language switcher
195/// should be injected. Kept invisible in single-locale sites.
196///
197/// Prefer the element form below. HTML minifiers strip comments, and
198/// `html-generator` minifies some pages during generation — before any
199/// plugin runs — so a comment marker on those pages is gone by the time
200/// this plugin looks for it. That is not a hypothetical: it silently
201/// removed the language switcher from every minified page.
202pub const LANG_SWITCHER_MARKER: &str = "<!-- ssg:lang-switcher -->";
203
204/// Attribute that marks an element as the language-switcher placeholder.
205/// Survives minification, because a minifier may reformat an element but
206/// will not delete it.
207pub const LANG_SWITCHER_ATTR: &str = "data-ssg-lang-switcher";
208
209/// Finds the placeholder element carrying [`LANG_SWITCHER_ATTR`] and
210/// returns its byte range, including the closing tag.
211///
212/// Deliberately not a regex: this crate has no regex dependency, and the
213/// shape being matched is a single empty element, not a grammar.
214pub fn find_lang_switcher_element(html: &str) -> Option<(usize, usize)> {
215    let attr_at = html.find(LANG_SWITCHER_ATTR)?;
216    // Walk back to the '<' that opens this element.
217    let start = html[..attr_at].rfind('<')?;
218    let name_start = start + 1;
219    let name_end = html[name_start..]
220        .find(|c: char| !c.is_ascii_alphanumeric())
221        .map(|i| name_start + i)?;
222    let name = &html[name_start..name_end];
223    if name.is_empty() {
224        return None;
225    }
226    // The attribute must belong to this tag, not to a later one.
227    let open_end = html[start..].find('>')? + start + 1;
228    if attr_at > open_end {
229        return None;
230    }
231    let close = format!("</{name}>");
232    let close_at = html[open_end..].find(&close)? + open_end;
233    // Only an *empty* placeholder is replaced; anything else is content.
234    if !html[open_end..close_at].trim().is_empty() {
235        return None;
236    }
237    Some((start, close_at + close.len()))
238}
239
240/// Build the hreflang `<link>` block for a single page.
241///
242/// `locale_map` gives each locale's OWN path for this logical page, so
243/// translated slugs (`/about/` ↔ `/fr/a-propos/`) resolve correctly;
244/// `labels` gives each locale's `hreflang` value — ssg builds it with
245/// its own `hreflang_labels`, which reads the site config and so stays
246/// outside this crate.
247///
248/// The `x-default` alternate is emitted only when the default locale
249/// actually serves the page — pointing it at a URL that does not exist
250/// is worse than omitting an optional signal.
251pub fn build_hreflang_links(
252    locale_map: &BTreeMap<String, String>,
253    labels: &BTreeMap<String, String>,
254    default_locale: &str,
255    base: &str,
256    strategy: &UrlPrefixStrategy,
257    root_locale: Option<&str>,
258) -> String {
259    let mut links = String::new();
260
261    for (locale, rel_path) in locale_map {
262        let href = build_url(base, locale, rel_path, strategy, root_locale);
263        let hreflang = labels.get(locale).unwrap_or(locale);
264        links.push_str(&format!(
265            "    <link rel=\"alternate\" hreflang=\"{hreflang}\" href=\"{href}\" />\n"
266        ));
267    }
268
269    if let Some(default_rel) = locale_map.get(default_locale) {
270        let default_href =
271            build_url(base, default_locale, default_rel, strategy, root_locale);
272        links.push_str(&format!(
273            "    <link rel=\"alternate\" hreflang=\"x-default\" href=\"{default_href}\" />\n"
274        ));
275    }
276
277    links
278}
279
280/// Construct a full URL for a given locale + relative path.
281///
282/// `root_locale`, when it names `locale`, suppresses the locale segment
283/// entirely: the root-hosted locale is served from `{base}/{rel_path}`
284/// under either strategy.
285pub fn build_url(
286    base: &str,
287    locale: &str,
288    rel_path: &str,
289    strategy: &UrlPrefixStrategy,
290    root_locale: Option<&str>,
291) -> String {
292    if root_locale == Some(locale) {
293        return format!("{base}/{rel_path}");
294    }
295    match strategy {
296        UrlPrefixStrategy::SubPath => {
297            format!("{base}/{locale}/{rel_path}")
298        }
299        UrlPrefixStrategy::SubDomain => {
300            // Replace scheme://host with scheme://locale.host
301            if let Some(idx) = base.find("://") {
302                let (scheme, rest) = base.split_at(idx + 3);
303                format!("{scheme}{locale}.{rest}/{rel_path}")
304            } else {
305                // Fallback: treat as sub-path.
306                format!("{base}/{locale}/{rel_path}")
307            }
308        }
309    }
310}
311
312/// Parses an Accept-Language header value into a sorted list of locale
313/// preferences (highest quality first).
314///
315/// Example: "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5"
316/// Returns: `["fr-CH", "fr", "en", "de", "*"]`
317///
318/// # Examples
319///
320/// ```rust
321/// use ssg_i18n::parse_accept_language;
322///
323/// let locales = parse_accept_language("fr;q=0.9, en");
324/// assert_eq!(locales[0], "en");
325/// assert_eq!(locales[1], "fr");
326/// ```
327#[must_use]
328pub fn parse_accept_language(header: &str) -> Vec<String> {
329    if header.trim().is_empty() {
330        return Vec::new();
331    }
332
333    let mut entries: Vec<(String, f64)> = header
334        .split(',')
335        .filter_map(|part| {
336            let part = part.trim();
337            if part.is_empty() {
338                return None;
339            }
340            let mut segments = part.splitn(2, ';');
341            let locale = segments.next()?.trim().to_string();
342            if locale.is_empty() {
343                return None;
344            }
345            let quality = segments
346                .next()
347                .and_then(|q| {
348                    let q = q.trim();
349                    q.strip_prefix("q=")
350                        .and_then(|v| v.trim().parse::<f64>().ok())
351                })
352                .unwrap_or(1.0);
353            Some((locale, quality))
354        })
355        .collect();
356
357    // Sort by quality descending; stable sort preserves order for equal quality.
358    entries.sort_by(|a, b| {
359        b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
360    });
361
362    entries.into_iter().map(|(locale, _)| locale).collect()
363}
364
365/// Given a list of preferred locales (from Accept-Language) and a list
366/// of available locales (directories on disk), returns the best match.
367///
368/// Matching rules:
369/// 1. Exact match (e.g., "fr-CH" matches "fr-CH")
370/// 2. Prefix match (e.g., "fr-CH" matches "fr")
371/// 3. Default locale fallback
372///
373/// # Examples
374///
375/// ```rust
376/// use ssg_i18n::negotiate_locale;
377///
378/// let pref = vec!["fr-CH".to_string(), "en".to_string()];
379/// let avail = vec!["en".to_string(), "fr".to_string()];
380/// assert_eq!(negotiate_locale(&pref, &avail, "en"), "fr");
381/// ```
382#[must_use]
383pub fn negotiate_locale(
384    preferred: &[String],
385    available: &[String],
386    default_locale: &str,
387) -> String {
388    let available_lower: Vec<String> =
389        available.iter().map(|l| l.to_lowercase()).collect();
390
391    for pref in preferred {
392        // Skip wildcard
393        if pref == "*" {
394            continue;
395        }
396        let pref_lower = pref.to_lowercase();
397
398        // Exact match
399        if let Some(idx) = available_lower.iter().position(|a| *a == pref_lower)
400        {
401            return available[idx].clone();
402        }
403
404        // Prefix match: preferred "fr-CH" matches available "fr"
405        let prefix = pref_lower.split('-').next().unwrap_or(&pref_lower);
406        if let Some(idx) = available_lower.iter().position(|a| *a == prefix) {
407            return available[idx].clone();
408        }
409    }
410
411    default_locale.to_string()
412}
413
414// ── Language switcher helper ─────────────────────────────────────────
415
416/// Generates an HTML snippet for a language switcher navigation.
417///
418/// This is a pure function that can be called from any plugin or template
419/// helper to produce a `<nav>` block with links to all locale variants
420/// of the current page.
421///
422/// # Arguments
423///
424/// * `locales` — All available locales.
425/// * `current_locale` — The locale of the page being rendered.
426/// * `current_path` — The relative path of the page (e.g. `about/index.html`).
427/// * `base_url` — The site base URL.
428/// * `strategy` — How locale URLs are constructed.
429///
430/// # Example
431///
432/// ```rust
433/// use ssg_i18n::{generate_lang_switcher_html, UrlPrefixStrategy};
434///
435/// let html = generate_lang_switcher_html(
436///     &["en".into(), "fr".into(), "de".into()],
437///     "en",
438///     "about/index.html",
439///     "https://example.com",
440///     &UrlPrefixStrategy::SubPath,
441/// );
442/// assert!(html.contains("lang=\"fr\""));
443/// ```
444#[must_use]
445pub fn generate_lang_switcher_html(
446    locales: &[String],
447    current_locale: &str,
448    current_path: &str,
449    base_url: &str,
450    strategy: &UrlPrefixStrategy,
451) -> String {
452    // Every locale serves the same path — the pre-`translation_key`
453    // assumption, kept for this public helper's callers.
454    let locale_map: BTreeMap<String, String> = locales
455        .iter()
456        .map(|l| (l.clone(), current_path.to_string()))
457        .collect();
458    let labels: BTreeMap<String, String> =
459        locales.iter().map(|l| (l.clone(), l.clone())).collect();
460    generate_lang_switcher_html_with_self_lang(
461        &locale_map,
462        &labels,
463        current_locale,
464        base_url,
465        strategy,
466        None,
467    )
468}
469
470/// Builds a switcher whose entries link to each locale's own path.
471///
472/// Like [`generate_lang_switcher_html`], but takes the translation
473/// matrix row for the page, so each entry links to that locale's OWN
474/// (possibly translated) path rather than the current path under a
475/// different prefix.
476///
477/// `labels` supplies the `lang=`/`hreflang=` value for each locale —
478/// resolved through `seo::lang::resolve_page_lang` (spec A5, plan §2
479/// 1.5) so the switcher agrees with the page's other language sinks.
480pub fn generate_lang_switcher_html_with_self_lang(
481    locale_map: &BTreeMap<String, String>,
482    labels: &BTreeMap<String, String>,
483    current_locale: &str,
484    base_url: &str,
485    strategy: &UrlPrefixStrategy,
486    root_locale: Option<&str>,
487) -> String {
488    let base = base_url.trim_end_matches('/');
489    let mut html = String::from(
490        "<nav class=\"lang-switcher\" aria-label=\"Language\">\n  <ul>\n",
491    );
492
493    for (locale, rel_path) in locale_map {
494        let href = build_url(base, locale, rel_path, strategy, root_locale);
495        let lang_attr = labels.get(locale).unwrap_or(locale);
496        let aria = if locale == current_locale {
497            " aria-current=\"page\""
498        } else {
499            ""
500        };
501        html.push_str(&format!(
502            "    <li><a href=\"{href}\" lang=\"{lang_attr}\" hreflang=\"{lang_attr}\"{aria}>{locale}</a></li>\n"
503        ));
504    }
505
506    html.push_str("  </ul>\n</nav>\n");
507    html
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    /// Quality values order the result, and the wildcard is not a locale.
515    #[test]
516    fn accept_language_is_ordered_by_quality() {
517        let got = parse_accept_language("en;q=0.5,fr-CA,de;q=0.8,*;q=0.1");
518        assert_eq!(got.first().map(String::as_str), Some("fr-CA"));
519        assert!(
520            got.iter().position(|l| l == "de")
521                < got.iter().position(|l| l == "en"),
522            "q=0.8 outranks q=0.5: {got:?}"
523        );
524    }
525
526    #[test]
527    fn negotiation_falls_back_to_the_default() {
528        let have = ["en".to_string(), "fr".to_string()];
529        assert_eq!(negotiate_locale(&["de".to_string()], &have, "en"), "en");
530        assert_eq!(negotiate_locale(&["fr".to_string()], &have, "en"), "fr");
531    }
532
533    /// `fr-CA` should reach a site that only publishes `fr`.
534    #[test]
535    fn negotiation_matches_a_region_against_its_base_language() {
536        let have = ["en".to_string(), "fr".to_string()];
537        assert_eq!(negotiate_locale(&["fr-CA".to_string()], &have, "en"), "fr");
538    }
539
540    #[test]
541    fn sub_path_puts_the_locale_in_the_path() {
542        let url = build_url(
543            "https://example.com",
544            "fr",
545            "about/",
546            &UrlPrefixStrategy::SubPath,
547            None,
548        );
549        assert!(url.contains("/fr/"), "{url}");
550    }
551
552    #[test]
553    fn sub_domain_puts_the_locale_in_the_host() {
554        let url = build_url(
555            "https://example.com",
556            "fr",
557            "about/",
558            &UrlPrefixStrategy::SubDomain,
559            None,
560        );
561        assert!(url.contains("fr."), "{url}");
562        assert!(!url.contains("/fr/"), "not also in the path: {url}");
563    }
564
565    /// The root locale is served unprefixed, so its URLs must not gain one.
566    #[test]
567    fn the_root_locale_keeps_a_bare_path() {
568        let url = build_url(
569            "https://example.com",
570            "en",
571            "about/",
572            &UrlPrefixStrategy::SubPath,
573            Some("en"),
574        );
575        assert!(!url.contains("/en/"), "{url}");
576    }
577
578    #[test]
579    fn hreflang_links_cover_every_locale_and_x_default() {
580        let mut map = BTreeMap::new();
581        let _ = map.insert("en".to_string(), "about/".to_string());
582        let _ = map.insert("fr".to_string(), "a-propos/".to_string());
583        let links = build_hreflang_links(
584            &map,
585            &BTreeMap::new(),
586            "en",
587            "https://example.com",
588            &UrlPrefixStrategy::SubPath,
589            None,
590        );
591        assert!(links.contains("hreflang=\"en\""), "{links}");
592        assert!(links.contains("hreflang=\"fr\""), "{links}");
593        assert!(links.contains("x-default"), "{links}");
594    }
595
596    #[test]
597    fn the_switcher_lists_every_locale() {
598        let locales =
599            vec!["en".to_string(), "fr".to_string(), "de".to_string()];
600        let html = generate_lang_switcher_html(
601            &locales,
602            "en",
603            "about/",
604            "https://example.com",
605            &UrlPrefixStrategy::SubPath,
606        );
607        for locale in &locales {
608            assert!(html.contains(locale.as_str()), "{locale} missing: {html}");
609        }
610    }
611
612    #[test]
613    fn a_document_without_the_marker_has_no_switcher_element() {
614        assert!(find_lang_switcher_element("<p>no switcher here</p>").is_none());
615    }
616
617    #[test]
618    fn the_attribute_locates_the_whole_placeholder_element() {
619        let html = format!("<p>x</p><nav {LANG_SWITCHER_ATTR}></nav><p>y</p>");
620        let (start, end) = find_lang_switcher_element(&html)
621            .expect("an empty placeholder is replaceable");
622        assert_eq!(
623            &html[start..end],
624            format!("<nav {LANG_SWITCHER_ATTR}></nav>"),
625            "the span must cover the element and nothing else"
626        );
627    }
628
629    /// A placeholder the author has filled in is content, not a slot:
630    /// replacing it would destroy their markup.
631    #[test]
632    fn a_non_empty_placeholder_is_left_alone() {
633        let html = format!("<nav {LANG_SWITCHER_ATTR}>hand-written</nav>");
634        assert!(find_lang_switcher_element(&html).is_none(), "{html}");
635    }
636
637    #[test]
638    fn sidecar_candidates_are_derived_from_the_page_path() {
639        let got = sidecar_candidates("about/index.html");
640        assert!(!got.is_empty(), "{got:?}");
641        assert!(
642            got.iter().any(|c| c.contains("about")),
643            "the page's own stem is a candidate: {got:?}"
644        );
645    }
646}