Files
todo-baggins/dioxus-i18n/src/use_i18n.rs
Matouš Volf 2c2ad7ad21
Some checks failed
hadolint check / hadolint check (pull_request) Successful in 13s
actionlint check / actionlint check (pull_request) Successful in 7s
conventional pull request title check / conventional pull request title check (pull_request) Successful in 3s
conventional commit messages check / conventional commit messages check (pull_request) Successful in 6s
dotenv-linter check / dotenv-linter check (pull_request) Successful in 7s
GitLeaks check / GitLeaks check (pull_request) Successful in 13s
markdownlint check / markdownlint check (pull_request) Failing after 54s
Prettier check / Prettier check (pull_request) Failing after 51s
htmlhint check / htmlhint check (pull_request) Successful in 1m3s
checkov check / checkov check (pull_request) Failing after 2m26s
ShellCheck check / ShellCheck check (pull_request) Successful in 1m14s
Stylelint check / Stylelint check (pull_request) Successful in 1m27s
Rust check / Rust check (pull_request) Failing after 11m40s
yamllint check / yamllint check (pull_request) Successful in 13m36s
fix: automatically reconnect after losing a WebSocket connection
2026-01-23 11:04:10 +01:00

704 lines
23 KiB
Rust

use super::error::Error;
use dioxus::prelude::*;
use fluent::{FluentArgs, FluentBundle, FluentResource};
use unic_langid::LanguageIdentifier;
#[cfg(not(target_arch = "wasm32"))]
use walkdir::WalkDir;
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
/// `Locale` is a "place-holder" around what will eventually be a `fluent::FluentBundle`
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct Locale {
id: LanguageIdentifier,
resource: LocaleResource,
}
impl Locale {
pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self {
Self {
id,
resource: LocaleResource::Static(str),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn new_dynamic(id: LanguageIdentifier, path: impl Into<PathBuf>) -> Self {
Self {
id,
resource: LocaleResource::Path(path.into()),
}
}
}
impl<T> From<(LanguageIdentifier, T)> for Locale
where
T: Into<LocaleResource>,
{
fn from((id, resource): (LanguageIdentifier, T)) -> Self {
let resource = resource.into();
Self { id, resource }
}
}
/// A `LocaleResource` can be static text, or a filesystem file (not supported in WASM).
#[derive(Debug, PartialEq)]
pub enum LocaleResource {
Static(&'static str),
#[cfg(not(target_arch = "wasm32"))]
Path(PathBuf),
}
impl LocaleResource {
pub fn try_to_resource_string(&self) -> Result<String, Error> {
match self {
Self::Static(str) => Ok(str.to_string()),
#[cfg(not(target_arch = "wasm32"))]
Self::Path(path) => std::fs::read_to_string(path)
.map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())),
}
}
pub fn to_resource_string(&self) -> String {
let result = self.try_to_resource_string();
match result {
Ok(string) => string,
Err(err) => panic!("failed to create resource string {:?}: {}", self, err),
}
}
}
impl From<&'static str> for LocaleResource {
fn from(value: &'static str) -> Self {
Self::Static(value)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl From<PathBuf> for LocaleResource {
fn from(value: PathBuf) -> Self {
Self::Path(value)
}
}
/// The configuration for `I18n`.
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct I18nConfig {
/// The initial language, can be later changed with [`I18n::set_language`]
id: LanguageIdentifier,
/// The final fallback language if no other locales are found for `id`.
/// A `Locale` must exist in `locales' if `fallback` is defined.
fallback: Option<LanguageIdentifier>,
/// The locale_resources added to the configuration.
locale_resources: Vec<LocaleResource>,
/// The locales added to the configuration.
locales: HashMap<LanguageIdentifier, usize>,
}
impl I18nConfig {
/// Create an i18n config with the selected [LanguageIdentifier].
pub fn new(id: LanguageIdentifier) -> Self {
Self {
id,
fallback: None,
locale_resources: Vec::new(),
locales: HashMap::new(),
}
}
/// Set a fallback [LanguageIdentifier].
pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self {
self.fallback = Some(fallback);
self
}
/// Add [Locale].
/// It is possible to share locales resources. If this locale's resource
/// matches a previously added one, then this locale will use the existing one.
/// This is primarily for the static locale_resources to avoid string duplication.
pub fn with_locale<T>(mut self, locale: T) -> Self
where
T: Into<Locale>,
{
let locale = locale.into();
let locale_resources_len = self.locale_resources.len();
let index = self
.locale_resources
.iter()
.position(|r| *r == locale.resource)
.unwrap_or(locale_resources_len);
if index == locale_resources_len {
self.locale_resources.push(locale.resource)
};
self.locales.insert(locale.id, index);
self
}
/// Add multiple locales from given folder, based on their filename.
///
/// If the path represents a folder, then the folder will be deep traversed for
/// all '*.ftl' files. If the filename represents a [LanguageIdentifier] then it
/// will be added to the config.
///
/// If the path represents a file, then the filename must represent a
/// unic_langid::LanguageIdentifier for it to be added to the config.
///
/// The method is not available for `wasm32` builds.
#[cfg(not(target_arch = "wasm32"))]
pub fn try_with_auto_locales(self, path: PathBuf) -> Result<Self, Error> {
if path.is_dir() {
let files = find_ftl_files(&path)?;
files
.into_iter()
.try_fold(self, |acc, file| acc.with_auto_pathbuf(file))
} else if is_ftl_file(&path) {
self.with_auto_pathbuf(path)
} else {
Err(Error::InvalidPath(path.to_string_lossy().to_string()))
}
}
#[cfg(not(target_arch = "wasm32"))]
fn with_auto_pathbuf(self, file: PathBuf) -> Result<Self, Error> {
assert!(is_ftl_file(&file));
let stem = file.file_stem().ok_or_else(|| {
Error::InvalidLanguageId(format!("No file stem: '{}'", file.display()))
})?;
let id_str = stem.to_str().ok_or_else(|| {
Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy()))
})?;
let id = LanguageIdentifier::from_bytes(id_str.as_bytes())
.map_err(|e| Error::InvalidLanguageId(e.to_string()))?;
Ok(self.with_locale((id, file)))
}
/// Add multiple locales from given folder, based on their filename.
///
/// Will panic! on error.
///
/// The method is not available for `wasm32` builds.
#[cfg(not(target_arch = "wasm32"))]
pub fn with_auto_locales(self, path: PathBuf) -> Self {
let path_name = path.display().to_string();
let result = self.try_with_auto_locales(path);
match result {
Ok(result) => result,
Err(err) => panic!(
"with_auto_locales must have valid pathbuf {}: {}",
path_name, err
),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn find_ftl_files(folder: &PathBuf) -> Result<Vec<PathBuf>, Error> {
let ftl_files: Vec<PathBuf> = WalkDir::new(folder)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| is_ftl_file(entry.path()))
.map(|entry| entry.path().to_path_buf())
.collect();
Ok(ftl_files)
}
#[cfg(not(target_arch = "wasm32"))]
fn is_ftl_file(entry: &Path) -> bool {
entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false)
}
/// Initialize an i18n provider.
pub fn try_use_init_i18n(init: impl FnOnce() -> I18nConfig) -> Result<I18n, Error> {
use_context_provider(move || {
// Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675
let I18nConfig {
id,
fallback,
locale_resources,
locales,
} = init();
I18n::try_new(id, fallback, locale_resources, locales)
})
}
/// Initialize an i18n provider.
pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n {
use_context_provider(move || {
// Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675
let I18nConfig {
id,
fallback,
locale_resources,
locales,
} = init();
match I18n::try_new(id, fallback, locale_resources, locales) {
Ok(i18n) => i18n,
Err(e) => panic!("Failed to create I18n context: {}", e),
}
})
}
#[derive(Clone, Copy)]
pub struct I18n {
selected_language: Signal<LanguageIdentifier>,
fallback_language: Signal<Option<LanguageIdentifier>>,
locale_resources: Signal<Vec<LocaleResource>>,
locales: Signal<HashMap<LanguageIdentifier, usize>>,
active_bundle: Signal<FluentBundle<FluentResource>>,
}
impl I18n {
pub fn try_new(
selected_language: LanguageIdentifier,
fallback_language: Option<LanguageIdentifier>,
locale_resources: Vec<LocaleResource>,
locales: HashMap<LanguageIdentifier, usize>,
) -> Result<Self, Error> {
let bundle = try_create_bundle(
&selected_language,
&fallback_language,
&locale_resources,
&locales,
)?;
Ok(Self {
selected_language: Signal::new(selected_language),
fallback_language: Signal::new(fallback_language),
locale_resources: Signal::new(locale_resources),
locales: Signal::new(locales),
active_bundle: Signal::new(bundle),
})
}
pub fn new(
selected_language: LanguageIdentifier,
fallback_language: Option<LanguageIdentifier>,
locale_resources: Vec<LocaleResource>,
locales: HashMap<LanguageIdentifier, usize>,
) -> Self {
let result = Self::try_new(
selected_language,
fallback_language,
locale_resources,
locales,
);
match result {
Ok(i18n) => i18n,
Err(err) => panic!("I18n cannot be created: {}", err),
}
}
pub fn try_translate_with_args(
&self,
msg: &str,
args: Option<&FluentArgs>,
) -> Result<String, Error> {
let (message_id, attribute_name) = Self::decompose_identifier(msg)?;
let bundle = self.active_bundle.read();
let message = bundle
.get_message(message_id)
.ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?;
let pattern = if let Some(attribute_name) = attribute_name {
let attribute = message
.get_attribute(attribute_name)
.ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?;
attribute.value()
} else {
message
.value()
.ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))?
};
let mut errors = vec![];
let translation = bundle
.format_pattern(pattern, args, &mut errors)
.to_string();
(errors.is_empty())
.then_some(translation)
.ok_or_else(|| Error::FluentErrorsDetected(format!("{:#?}", errors)))
}
pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> {
let parts: Vec<&str> = msg.split('.').collect();
match parts.as_slice() {
[message_id] => Ok((message_id, None)),
[message_id, attribute_name] => Ok((message_id, Some(attribute_name))),
_ => Err(Error::InvalidMessageId(msg.to_string())),
}
}
pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String {
let result = self.try_translate_with_args(msg, args);
match result {
Ok(translation) => translation,
Err(err) => panic!("Failed to translate {}: {}", msg, err),
}
}
#[inline]
pub fn try_translate(&self, msg: &str) -> Result<String, Error> {
self.try_translate_with_args(msg, None)
}
pub fn translate(&self, msg: &str) -> String {
let result = self.try_translate(msg);
match result {
Ok(translation) => translation,
Err(err) => panic!("Failed to translate {}: {}", msg, err),
}
}
/// Get the selected language.
#[inline]
pub fn language(&self) -> LanguageIdentifier {
self.selected_language.read().clone()
}
/// Get the fallback language.
pub fn fallback_language(&self) -> Option<LanguageIdentifier> {
self.fallback_language.read().clone()
}
/// Update the selected language.
pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
*self.selected_language.write() = id;
self.try_update_active_bundle()
}
/// Update the selected language.
pub fn set_language(&mut self, id: LanguageIdentifier) {
let id_name = id.to_string();
let result = self.try_set_language(id);
match result {
Ok(()) => (),
Err(err) => panic!("cannot set language {}: {}", id_name, err),
}
}
/// Update the fallback language.
pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> {
self.locales
.read()
.get(&id)
.ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?;
*self.fallback_language.write() = Some(id);
self.try_update_active_bundle()
}
/// Update the fallback language.
pub fn set_fallback_language(&mut self, id: LanguageIdentifier) {
let id_name = id.to_string();
let result = self.try_set_fallback_language(id);
match result {
Ok(()) => (),
Err(err) => panic!("cannot set fallback language {}: {}", id_name, err),
}
}
fn try_update_active_bundle(&mut self) -> Result<(), Error> {
let bundle = try_create_bundle(
&self.selected_language.peek(),
&self.fallback_language.peek(),
&self.locale_resources.peek(),
&self.locales.peek(),
)?;
self.active_bundle.set(bundle);
Ok(())
}
}
fn try_create_bundle(
selected_language: &LanguageIdentifier,
fallback_language: &Option<LanguageIdentifier>,
locale_resources: &[LocaleResource],
locales: &HashMap<LanguageIdentifier, usize>,
) -> Result<FluentBundle<FluentResource>, Error> {
let add_resource = move |bundle: &mut FluentBundle<FluentResource>,
langid: &LanguageIdentifier,
locale_resources: &[LocaleResource]| {
if let Some(&i) = locales.get(langid) {
let resource = &locale_resources[i];
let resource =
FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| {
Error::FluentErrorsDetected(format!("resource langid: {}\n{:#?}", langid, e))
})?;
bundle.add_resource_overriding(resource);
};
Ok(())
};
let mut bundle = FluentBundle::new(vec![selected_language.clone()]);
if let Some(fallback_language) = fallback_language {
add_resource(&mut bundle, fallback_language, locale_resources)?;
}
let (language, script, region, variants) = selected_language.clone().into_parts();
let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants);
let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]);
let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]);
let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]);
add_resource(&mut bundle, &language_lang, locale_resources)?;
add_resource(&mut bundle, &script_lang, locale_resources)?;
add_resource(&mut bundle, &region_lang, locale_resources)?;
add_resource(&mut bundle, &variants_lang, locale_resources)?;
/* Add this code when the fluent crate includes FluentBundle::add_builtins.
* This will allow the use of built-in functions like `NUMBER` and `DATETIME`.
* See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information.
bundle
.add_builtins()
.map_err(|e| Error::FluentErrorsDetected(e.to_string()))?;
*/
Ok(bundle)
}
pub fn i18n() -> I18n {
consume_context()
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use unic_langid::langid;
#[test]
fn can_add_locale_to_config_explicit_locale() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
const LANG_B: LanguageIdentifier = langid!("la-LB");
const LANG_C: LanguageIdentifier = langid!("la-LC");
let config = I18nConfig::new(LANG_A)
.with_locale(Locale::new_static(LANG_B, "lang = lang_b"))
.with_locale(Locale::new_dynamic(LANG_C, PathBuf::new()));
assert_eq!(
config,
I18nConfig {
id: LANG_A,
fallback: None,
locale_resources: vec![
LocaleResource::Static("lang = lang_b"),
LocaleResource::Path(PathBuf::new()),
],
locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]),
}
);
}
#[test]
fn can_add_locale_to_config_implicit_locale() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
const LANG_B: LanguageIdentifier = langid!("la-LB");
const LANG_C: LanguageIdentifier = langid!("la-LC");
let config = I18nConfig::new(LANG_A)
.with_locale((LANG_B, "lang = lang_b"))
.with_locale((LANG_C, PathBuf::new()));
assert_eq!(
config,
I18nConfig {
id: LANG_A,
fallback: None,
locale_resources: vec![
LocaleResource::Static("lang = lang_b"),
LocaleResource::Path(PathBuf::new())
],
locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]),
}
);
}
#[test]
fn can_add_locale_string_to_config() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
const LANG_B: LanguageIdentifier = langid!("la-LB");
let config = I18nConfig::new(LANG_A).with_locale((LANG_B, "lang = lang_b"));
assert_eq!(
config,
I18nConfig {
id: LANG_A,
fallback: None,
locale_resources: vec![LocaleResource::Static("lang = lang_b")],
locales: HashMap::from([(LANG_B, 0)]),
}
);
}
#[test]
fn can_add_shared_locale_string_to_config() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
const LANG_B: LanguageIdentifier = langid!("la-LB");
const LANG_C: LanguageIdentifier = langid!("la-LC");
let shared_string = "lang = a language";
let config = I18nConfig::new(LANG_A)
.with_locale((LANG_B, shared_string))
.with_locale((LANG_C, shared_string));
assert_eq!(
config,
I18nConfig {
id: LANG_A,
fallback: None,
locale_resources: vec![LocaleResource::Static(shared_string)],
locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]),
}
);
}
#[test]
fn can_add_locale_pathbuf_to_config() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
const LANG_C: LanguageIdentifier = langid!("la-LC");
let config = I18nConfig::new(LANG_A)
.with_locale((LANG_C, PathBuf::from("./test/data/fallback/la.ftl")));
assert_eq!(
config,
I18nConfig {
id: LANG_A,
fallback: None,
locale_resources: vec![LocaleResource::Path(PathBuf::from(
"./test/data/fallback/la.ftl"
))],
locales: HashMap::from([(LANG_C, 0)]),
}
);
}
#[test]
fn can_add_shared_locale_pathbuf_to_config() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
const LANG_B: LanguageIdentifier = langid!("la-LB");
const LANG_C: LanguageIdentifier = langid!("la-LC");
let shared_pathbuf = PathBuf::from("./test/data/fallback/la.ftl");
let config = I18nConfig::new(LANG_A)
.with_locale((LANG_B, shared_pathbuf.clone()))
.with_locale((LANG_C, shared_pathbuf.clone()));
assert_eq!(
config,
I18nConfig {
id: LANG_A,
fallback: None,
locale_resources: vec![LocaleResource::Path(shared_pathbuf)],
locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]),
}
);
}
#[test]
fn can_auto_add_locales_folder_to_config() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
let root_path_str = &format!("{}/tests/data/fallback/", env!("CARGO_MANIFEST_DIR"));
let pathbuf = PathBuf::from(root_path_str);
let config = I18nConfig::new(LANG_A)
.try_with_auto_locales(pathbuf)
.ok()
.unwrap();
let expected_locales = [
"fb-FB",
"la",
"la-Scpt",
"la-Scpt-LA",
"la-Scpt-LA-variants",
];
assert_eq!(config.locales.len(), expected_locales.len());
assert_eq!(config.locale_resources.len(), expected_locales.len());
expected_locales.into_iter().for_each(|l| {
let expected_filename = format!("{root_path_str}/{l}.ftl");
let id = LanguageIdentifier::from_bytes(l.as_bytes()).unwrap();
assert!(config.locales.get(&id).is_some());
assert!(config
.locale_resources
.contains(&LocaleResource::Path(PathBuf::from(expected_filename))));
});
}
#[test]
fn can_auto_add_locales_file_to_config() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
let path_str = &format!(
"{}/tests/data/fallback/fb-FB.ftl",
env!("CARGO_MANIFEST_DIR")
);
let pathbuf = PathBuf::from(path_str);
let config = I18nConfig::new(LANG_A)
.try_with_auto_locales(pathbuf.clone())
.ok()
.unwrap();
assert_eq!(config.locales.len(), 1);
assert!(config.locales.get(&langid!("fb-FB")).is_some());
assert_eq!(config.locale_resources.len(), 1);
assert!(config
.locale_resources
.contains(&LocaleResource::Path(pathbuf)));
}
#[test]
fn will_fail_auto_locales_with_invalid_folder() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
let root_path_str = &format!("{}/non_existing_path/", env!("CARGO_MANIFEST_DIR"));
let pathbuf = PathBuf::from(root_path_str);
let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf);
assert_eq!(config.is_err(), true);
}
#[test]
fn will_fail_auto_locales_with_invalid_file() {
const LANG_A: LanguageIdentifier = langid!("la-LA");
let path_str = &format!(
"{}/tests/data/fallback/invalid_language_id.ftl",
env!("CARGO_MANIFEST_DIR")
);
let pathbuf = PathBuf::from(path_str);
let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf);
assert_eq!(config.is_err(), true);
}
}