1mod 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#[derive(Clone, Copy)]
32pub struct AllJsonEnabled(pub bool);
33
34pub 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 #[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 #[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 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 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}