Skip to main content

sova_i18n/
ext.rs

1//! Request helpers and route `i18n_scope`.
2
3use crate::plural::{pluralize, PluralFn};
4use crate::store::{Store, ROOT_SCOPE};
5use arc_swap::ArcSwap;
6use sova_core::extend::RouteValue;
7use sova_core::{App, Request, Router};
8use std::collections::HashSet;
9use std::sync::{Arc, Mutex};
10
11/// Page scope attached via [`I18nRouteExt::i18n_scope`].
12#[derive(Debug, Clone)]
13pub struct I18nScope(pub Box<str>);
14
15impl RouteValue for I18nScope {}
16
17impl I18nScope {
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21}
22
23/// Resolved locale code on the request.
24#[derive(Debug, Clone)]
25pub struct LocaleCode(pub Box<str>);
26
27/// Shared runtime used by middleware and `I18nExt`.
28#[derive(Clone)]
29pub struct I18nState {
30    pub store: Arc<ArcSwap<Store>>,
31    pub fallback: Box<str>,
32    /// Default locale (unprefixed when `path_prefix` is on).
33    pub default: Box<str>,
34    /// When true, locale may appear as the first path segment.
35    pub path_prefix: bool,
36    pub plural_fn: Option<PluralFn>,
37    pub missing_handler: Option<MissingHandler>,
38    pub missing_keys: Arc<Mutex<HashSet<String>>>,
39}
40
41pub type MissingHandler = Arc<dyn Fn(&str, &str, &str) + Send + Sync>;
42
43impl I18nState {
44    pub fn translate(&self, locale: &str, scope: &str, key: &str) -> String {
45        if let Some(v) = self.lookup(locale, scope, key) {
46            return v;
47        }
48        self.record_missing(locale, scope, key);
49        if let Some(h) = &self.missing_handler {
50            h(locale, scope, key);
51        }
52        key.to_string()
53    }
54
55    pub fn has(&self, locale: &str, scope: &str, key: &str) -> bool {
56        self.lookup(locale, scope, key).is_some()
57    }
58
59    fn lookup(&self, locale: &str, scope: &str, key: &str) -> Option<String> {
60        let store = self.store.load();
61        let fallback = self.fallback.as_ref();
62        for (loc, sc) in [
63            (locale, scope),
64            (locale, ROOT_SCOPE),
65            (fallback, scope),
66            (fallback, ROOT_SCOPE),
67        ] {
68            if let Some(v) = store.lookup_flat(loc, sc, key) {
69                return Some(v);
70            }
71        }
72        None
73    }
74
75    fn record_missing(&self, locale: &str, scope: &str, key: &str) {
76        let id = format!("{locale}|{scope}|{key}");
77        let mut set = self.missing_keys.lock().unwrap();
78        if set.insert(id) {
79            tracing::warn!(locale, scope, key, "i18n missing translation");
80        }
81    }
82
83    pub fn missing_snapshot(&self) -> Vec<String> {
84        let mut v: Vec<_> = self.missing_keys.lock().unwrap().iter().cloned().collect();
85        v.sort();
86        v
87    }
88}
89
90/// Attach an i18n page scope to the last registered route.
91pub trait I18nRouteExt {
92    fn i18n_scope(&mut self, scope: impl Into<String>) -> &mut Self;
93}
94
95impl I18nRouteExt for Router {
96    fn i18n_scope(&mut self, scope: impl Into<String>) -> &mut Self {
97        self.with(I18nScope(scope.into().into_boxed_str()))
98    }
99}
100
101impl I18nRouteExt for App {
102    fn i18n_scope(&mut self, scope: impl Into<String>) -> &mut Self {
103        self.with(I18nScope(scope.into().into_boxed_str()));
104        self
105    }
106}
107
108/// Translate helpers on [`Request`].
109pub trait I18nExt {
110    fn locale(&self) -> &str;
111    fn i18n_scope_name(&self) -> String;
112    fn t(&self, key: &str) -> String;
113    fn t_args(&self, key: &str, args: &[(&str, &str)]) -> String;
114    fn tn(&self, key: &str, count: i64) -> String;
115    fn has_t(&self, key: &str) -> bool;
116}
117
118impl I18nExt for Request {
119    fn locale(&self) -> &str {
120        self.get::<LocaleCode>()
121            .map(|c| c.0.as_ref())
122            .unwrap_or("en")
123    }
124
125    fn i18n_scope_name(&self) -> String {
126        scope_of(self)
127    }
128
129    fn t(&self, key: &str) -> String {
130        let Some(state) = self.try_state::<I18nState>() else {
131            return key.to_string();
132        };
133        state.translate(self.locale(), &scope_of(self), key)
134    }
135
136    fn t_args(&self, key: &str, args: &[(&str, &str)]) -> String {
137        interpolate(&self.t(key), args)
138    }
139
140    fn tn(&self, key: &str, count: i64) -> String {
141        let Some(state) = self.try_state::<I18nState>() else {
142            return key.to_string();
143        };
144        let scope = scope_of(self);
145        let raw = state.translate(self.locale(), &scope, key);
146        pluralize(state.plural_fn.as_ref(), key, count, self.locale(), &raw)
147    }
148
149    fn has_t(&self, key: &str) -> bool {
150        let Some(state) = self.try_state::<I18nState>() else {
151            return false;
152        };
153        state.has(self.locale(), &scope_of(self), key)
154    }
155}
156
157pub(crate) fn scope_of(req: &Request) -> String {
158    req.route_meta::<I18nScope>()
159        .map(|s| s.0.to_string())
160        .unwrap_or_else(|| ROOT_SCOPE.to_string())
161}
162
163/// Replace `{token}` placeholders; missing tokens stay as `{token}`.
164pub fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
165    let mut out = template.to_string();
166    for (k, v) in args {
167        out = out.replace(&format!("{{{k}}}"), v);
168    }
169    out
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn interpolate_keeps_missing_token() {
178        assert_eq!(
179            interpolate("Hello {name}, {x}", &[("name", "Ada")]),
180            "Hello Ada, {x}"
181        );
182    }
183}