Skip to main content

sova_i18n/
lib.rs

1//! Two-level i18n for Sova (root + page scopes).
2
3mod ext;
4mod mount;
5mod plural;
6mod resolve;
7mod routes;
8mod store;
9mod template;
10
11pub use ext::{interpolate, I18nExt, I18nRouteExt, I18nScope, I18nState, LocaleCode};
12pub use template::template_fn;
13pub use mount::{
14    localize_path, localized_url, mount_localized, strip_locale_prefix, PrefixMode,
15};
16pub use plural::{default_plural, PluralFn};
17pub use resolve::{
18    negotiate_accept_language, resolve_server_locale, LocaleSource, ResolveOptions,
19};
20pub use store::{get_by_path, load_store, Locale, Scope, Store, ROOT_SCOPE};
21
22use arc_swap::ArcSwap;
23use ext::MissingHandler;
24use sova_core::extend::named;
25use sova_core::{with_state, App, Error, Plugin};
26use std::collections::HashSet;
27use std::path::PathBuf;
28use std::sync::{Arc, Mutex};
29
30/// Marker: `GET /_i18n/all.json` is allowed.
31#[derive(Clone, Copy)]
32pub struct AllJsonEnabled(pub bool);
33
34/// I18n plugin.
35pub struct I18n {
36    dir: PathBuf,
37    locales: Vec<Locale>,
38    default: String,
39    fallback: String,
40    path_prefix: bool,
41    cookie_name: Option<String>,
42    set_locale_cookie: bool,
43    enable_all_json: bool,
44    plural_fn: Option<PluralFn>,
45    missing_handler: Option<MissingHandler>,
46    #[cfg(feature = "watch")]
47    watch: bool,
48}
49
50impl I18n {
51    pub fn new(dir: impl Into<PathBuf>, locales: Vec<Locale>) -> Self {
52        let default = locales
53            .first()
54            .map(|l| l.code.clone())
55            .unwrap_or_else(|| "en".into());
56        Self {
57            dir: dir.into(),
58            locales,
59            fallback: default.clone(),
60            default,
61            path_prefix: true,
62            cookie_name: None,
63            set_locale_cookie: false,
64            enable_all_json: false,
65            plural_fn: None,
66            missing_handler: None,
67            #[cfg(feature = "watch")]
68            watch: false,
69        }
70    }
71
72    pub fn default_locale(mut self, code: impl Into<String>) -> Self {
73        self.default = code.into();
74        self
75    }
76
77    pub fn fallback(mut self, code: impl Into<String>) -> Self {
78        self.fallback = code.into();
79        self
80    }
81
82    pub fn path_prefix(mut self, enabled: bool) -> Self {
83        self.path_prefix = enabled;
84        self
85    }
86
87    /// Enable cookie locale resolution (requires feature `cookie` + `CookieLayer`).
88    #[cfg(feature = "cookie")]
89    pub fn cookie(mut self, name: impl Into<String>) -> Self {
90        self.cookie_name = Some(name.into());
91        self
92    }
93
94    #[cfg(feature = "cookie")]
95    pub fn set_locale_cookie(mut self, yes: bool) -> Self {
96        self.set_locale_cookie = yes;
97        self
98    }
99
100    pub fn enable_all_json(mut self, yes: bool) -> Self {
101        self.enable_all_json = yes;
102        self
103    }
104
105    pub fn plural_fn<F>(mut self, f: F) -> Self
106    where
107        F: Fn(&str, i64, &str, &[&str]) -> String + Send + Sync + 'static,
108    {
109        self.plural_fn = Some(Arc::new(f));
110        self
111    }
112
113    pub fn missing_handler<F>(mut self, f: F) -> Self
114    where
115        F: Fn(&str, &str, &str) + Send + Sync + 'static,
116    {
117        self.missing_handler = Some(Arc::new(f));
118        self
119    }
120
121    #[cfg(feature = "watch")]
122    pub fn watch(mut self, yes: bool) -> Self {
123        self.watch = yes;
124        self
125    }
126}
127
128impl Plugin for I18n {
129    fn id(&self) -> &'static str {
130        "i18n"
131    }
132
133    fn meta(&self) -> sova_core::PluginMeta {
134        sova_core::PluginMeta::new("I18n")
135            .description("Locales, catalogs, optional path prefix and cookie")
136            .version(env!("CARGO_PKG_VERSION"))
137    }
138
139    fn requires(&self) -> &'static [&'static str] {
140        // Feature `cookie` always needs Cookies (toml may set cookie_name at install).
141        #[cfg(feature = "cookie")]
142        {
143            let _ = &self.cookie_name;
144            return &["cookies"];
145        }
146        #[cfg(not(feature = "cookie"))]
147        {
148            &[]
149        }
150    }
151
152    fn install(mut self, app: &mut App) {
153        if let Some(doc) = app.config_doc() {
154            if let Some(section) = doc.section("i18n") {
155                if let Some(c) = section.get("default").and_then(|v| v.as_str()) {
156                    self.default = c.to_string();
157                }
158                #[cfg(feature = "cookie")]
159                if self.cookie_name.is_none() {
160                    if let Some(n) = section.get("cookie").and_then(|v| v.as_str()) {
161                        self.cookie_name = Some(n.to_string());
162                    }
163                }
164                #[cfg(feature = "watch")]
165                if let Some(v) = section.get("watch").and_then(|v| v.as_bool()) {
166                    self.watch = v;
167                }
168            }
169        }
170        let store = match load_store(&self.dir, &self.locales) {
171            Ok(s) => s,
172            Err(err) => {
173                let msg = err.to_string();
174                app.on_startup(move |_| {
175                    let msg = msg.clone();
176                    async move { Err(Error::Internal(format!("i18n: {msg}"))) }
177                });
178                return;
179            }
180        };
181
182        let swap = Arc::new(ArcSwap::from_pointee(store));
183        let state = I18nState {
184            store: Arc::clone(&swap),
185            fallback: self.fallback.clone().into_boxed_str(),
186            default: self.default.clone().into_boxed_str(),
187            path_prefix: self.path_prefix,
188            plural_fn: self.plural_fn.clone(),
189            missing_handler: self.missing_handler.clone(),
190            missing_keys: Arc::new(Mutex::new(HashSet::new())),
191        };
192        app.state(state);
193        app.state(AllJsonEnabled(self.enable_all_json));
194
195        #[cfg(feature = "templates")]
196        {
197            use crate::template::template_fn;
198            sova_templates::register_per_request(app, "t", template_fn);
199        }
200
201        let locales_dir = self.dir.clone();
202        let locales_meta = self.locales.clone();
203        app.register_audit("i18n", move |_state| {
204            let dir = locales_dir.clone();
205            let locales = locales_meta.clone();
206            async move {
207                load_store(&dir, &locales)?;
208                Ok(())
209            }
210        });
211
212        let codes: Vec<String> = self.locales.iter().map(|l| l.code.clone()).collect();
213        let mut resolve_opts = ResolveOptions::new(codes, self.default.clone());
214        resolve_opts.path_prefix = self.path_prefix;
215        resolve_opts.cookie_name = self.cookie_name.clone();
216
217        let set_cookie = self.set_locale_cookie;
218        let cookie_name = self.cookie_name.clone();
219        let opts = resolve_opts;
220
221        app.use_middleware(named(
222            "i18n",
223            with_state(
224                (opts, set_cookie, cookie_name),
225                |cfg, mut req, next| async move {
226                    let (opts, set_cookie, cookie_name) = &*cfg;
227                    let resolved = resolve_server_locale(&req, opts);
228                    req.set(LocaleCode(resolved.code.clone().into_boxed_str()));
229                    let mut res = next(req).await;
230                    res = res
231                        .header("content-language", &resolved.code)
232                        .header("vary", "accept-language");
233                    #[cfg(feature = "cookie")]
234                    if *set_cookie {
235                        if let Some(name) = cookie_name {
236                            use sova_cookies::{CookieBuilder, ResponseCookieExt};
237                            res = res.cookie(
238                                CookieBuilder::build((name.clone(), resolved.code.clone()))
239                                    .path("/")
240                                    .build(),
241                            );
242                        }
243                    }
244                    #[cfg(not(feature = "cookie"))]
245                    let _ = (set_cookie, cookie_name);
246                    res
247                },
248            ),
249        ));
250
251        // Exact paths before param routes.
252        app.get("/_i18n/locales.json", routes::locales_json);
253        app.get("/_i18n/all.json", routes::all_json);
254        app.get("/_i18n/_missing.json", routes::missing_json);
255        // `/_i18n/en.json` and `/_i18n/en/blog.json` (`.json` stripped in handler).
256        app.get("/_i18n/:locale", routes::locale_or_scope_json);
257        app.get("/_i18n/:locale/:scope", routes::locale_or_scope_json);
258
259        #[cfg(feature = "watch")]
260        if self.watch {
261            spawn_watch(self.dir.clone(), self.locales.clone(), Arc::clone(&swap));
262        }
263    }
264}
265
266#[cfg(feature = "watch")]
267fn spawn_watch(dir: PathBuf, locales: Vec<Locale>, swap: Arc<ArcSwap<Store>>) {
268    use notify::{RecommendedWatcher, RecursiveMode, Watcher};
269    std::thread::spawn(move || {
270        let (tx, rx) = std::sync::mpsc::channel();
271        let mut watcher = match RecommendedWatcher::new(tx, notify::Config::default()) {
272            Ok(w) => w,
273            Err(err) => {
274                tracing::error!("i18n watch: {err}");
275                return;
276            }
277        };
278        if let Err(err) = watcher.watch(&dir, RecursiveMode::Recursive) {
279            tracing::error!("i18n watch: {err}");
280            return;
281        }
282        for _ in rx {
283            match load_store(&dir, &locales) {
284                Ok(store) => {
285                    tracing::info!("i18n: reloaded translations");
286                    swap.store(Arc::new(store));
287                }
288                Err(err) => tracing::error!("i18n reload failed: {err}"),
289            }
290        }
291    });
292}