zino_http/i18n/
mod.rs

1//! Internationalization and localization.
2
3use fluent::{FluentArgs, FluentResource, bundle::FluentBundle};
4use intl_memoizer::concurrent::IntlLangMemoizer;
5use std::{fs, io::ErrorKind};
6use unic_langid::LanguageIdentifier;
7use zino_core::{
8    LazyLock, SharedString,
9    application::{Agent, Application},
10    bail,
11    error::Error,
12    extension::TomlTableExt,
13    state::State,
14    warn,
15};
16
17/// Translates the localization message.
18pub fn translate(
19    locale: &LanguageIdentifier,
20    message: &str,
21    args: Option<FluentArgs<'_>>,
22) -> Result<SharedString, Error> {
23    let bundle = LOCALIZATION
24        .iter()
25        .find_map(|(lang_id, bundle)| (lang_id == locale).then_some(bundle))
26        .or_else(|| {
27            let lang = locale.language;
28            LOCALIZATION
29                .iter()
30                .find_map(|(lang_id, bundle)| (lang_id.language == lang).then_some(bundle))
31        })
32        .or(*DEFAULT_BUNDLE)
33        .ok_or_else(|| warn!("localization bundle does not exits"))?;
34    let pattern = bundle
35        .get_message(message)
36        .ok_or_else(|| warn!("fail to get the localization message for `{}`", message))?
37        .value()
38        .ok_or_else(|| {
39            warn!(
40                "fail to retrieve an option of the pattern for `{}`",
41                message
42            )
43        })?;
44
45    let mut errors = vec![];
46    if let Some(args) = args {
47        let mut value = String::new();
48        bundle.write_pattern(&mut value, pattern, Some(&args), &mut errors)?;
49        if errors.is_empty() {
50            Ok(value.into())
51        } else {
52            bail!("{:?}", errors);
53        }
54    } else {
55        let value = bundle.format_pattern(pattern, None, &mut errors);
56        if errors.is_empty() {
57            Ok(value)
58        } else {
59            bail!("{:?}", errors);
60        }
61    }
62}
63
64/// Translation type.
65type Translation = FluentBundle<FluentResource, IntlLangMemoizer>;
66
67/// Localization.
68static LOCALIZATION: LazyLock<Vec<(LanguageIdentifier, Translation)>> = LazyLock::new(|| {
69    let mut locales = Vec::new();
70    let locale_dir = Agent::config_dir().join("locale");
71    match fs::read_dir(locale_dir) {
72        Ok(entries) => {
73            let files = entries.filter_map(|entry| entry.ok());
74            for file in files {
75                let locale_file = file.path();
76                let ftl_string = fs::read_to_string(&locale_file).unwrap_or_else(|err| {
77                    let locale_file = locale_file.display();
78                    panic!("fail to read `{locale_file}`: {err}");
79                });
80                let resource =
81                    FluentResource::try_new(ftl_string).expect("fail to parse an FTL string");
82                if let Some(locale) = file
83                    .file_name()
84                    .to_str()
85                    .map(|s| s.trim_end_matches(".ftl"))
86                {
87                    let lang = locale
88                        .parse::<LanguageIdentifier>()
89                        .unwrap_or_else(|_| panic!("fail to language identifier `{locale}`"));
90
91                    let mut bundle = FluentBundle::new_concurrent(vec![lang.clone()]);
92                    bundle.set_use_isolating(false);
93                    bundle
94                        .add_resource(resource)
95                        .expect("fail to add FTL resources to the bundle");
96                    locales.push((lang, bundle));
97                }
98            }
99        }
100        Err(err) => {
101            if err.kind() != ErrorKind::NotFound {
102                tracing::error!("{err}");
103            }
104        }
105    }
106    locales
107});
108
109/// Default bundle.
110static DEFAULT_BUNDLE: LazyLock<Option<&'static Translation>> = LazyLock::new(|| {
111    let default_locale = LazyLock::force(&DEFAULT_LOCALE);
112    LOCALIZATION
113        .iter()
114        .find_map(|(lang_id, bundle)| (lang_id == default_locale).then_some(bundle))
115});
116
117/// Default locale.
118pub(crate) static DEFAULT_LOCALE: LazyLock<&'static str> = LazyLock::new(|| {
119    if let Some(i18n) = State::shared().get_config("i18n") {
120        i18n.get_str("default-locale").unwrap_or("en-US")
121    } else {
122        "en-US"
123    }
124});
125
126/// Supported locales.
127pub(crate) static SUPPORTED_LOCALES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
128    LOCALIZATION
129        .iter()
130        .map(|(key, _)| {
131            let language: &'static str = key.to_string().leak();
132            language
133        })
134        .collect::<Vec<_>>()
135});