├── .github ├── release-drafter.yml └── workflows │ ├── publish.yml │ ├── release-drafter.yml │ └── test_runs.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE.md ├── README.md ├── examples ├── config-auto-locales.rs ├── config-dynamic-pathbuf.rs ├── config-static-includestr.rs ├── data │ ├── fluent │ │ └── en.ftl │ └── i18n │ │ ├── en-US.ftl │ │ └── es-ES.ftl ├── dioxus-desktop.rs ├── fluent-grammar.rs └── freya.rs ├── src ├── error.rs ├── i18n_macro.rs ├── lib.rs └── use_i18n.rs └── tests ├── README.md ├── common ├── mod.rs └── test_hook.rs ├── data ├── fallback │ ├── fb-FB.ftl │ ├── la-Scpt-LA-variants.ftl │ ├── la-Scpt-LA.ftl │ ├── la-Scpt.ftl │ └── la.ftl └── i18n │ └── en.ftl ├── defects_spec.rs ├── graceful_fallback_spec.rs └── translations_spec.rs /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "Release v$RESOLVED_VERSION 🦀" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "🚀 Features" 5 | label: "feature" 6 | - title: "🐛 Bug Fixes" 7 | label: "bug" 8 | - title: "♻️ Refactor" 9 | label: "refactor" 10 | - title: "📝 Documentation" 11 | label: "documentation" 12 | - title: "🧰 Maintenance" 13 | labels: 14 | - "chore" 15 | - "dependencies" 16 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 17 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 18 | version-resolver: 19 | major: 20 | labels: 21 | - "major" 22 | minor: 23 | labels: 24 | - "minor" 25 | patch: 26 | labels: 27 | - "patch" 28 | default: patch 29 | template: | 30 | ## Changes 31 | 32 | $CHANGES 33 | autolabeler: 34 | - label: feature 35 | branch: 36 | - "/^feat(ure)?[/-].+/" 37 | - label: bug 38 | branch: 39 | - "/^fix[/-].+/" 40 | - label: refactor 41 | branch: 42 | - "/(refactor|refactoring)[/-].+/" 43 | - label: documentation 44 | branch: 45 | - "/doc(s|umentation)[/-].+/" 46 | - label: chore 47 | branch: 48 | - "/^chore[/-].+/" -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | publish: 13 | name: Publish to crate.io 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - run: cargo publish 18 | env: 19 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | types: [opened, reopened, synchronize] 10 | 11 | jobs: 12 | update_release_draft: 13 | permissions: 14 | contents: write 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: release-drafter/release-drafter@v5 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test_runs.yml: -------------------------------------------------------------------------------- 1 | name: Test Runs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | types: [opened, reopened, synchronize] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Updates 17 | run: | 18 | sudo apt update 19 | sudo apt install libwebkit2gtk-4.1-dev \ 20 | build-essential \ 21 | libxdo-dev \ 22 | libssl-dev \ 23 | libayatana-appindicator3-dev \ 24 | librsvg2-dev \ 25 | libglib2.0-dev 26 | 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Lint 31 | run: cargo clippy -- -D warnings 32 | 33 | - name: Test 34 | run: cargo test 35 | 36 | - name: Compile 37 | run: | 38 | rustup target add wasm32-unknown-unknown 39 | cargo build --target wasm32-unknown-unknown 40 | cargo build --release 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | /target 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.4.3] 4 | 5 | - [Issue #19](https://github.com/dioxus-community/dioxus-i18n/issues/19) Enable use of "message-id.attribute-id" 6 | syntax in the `t!`, `te!`, `tid!` macros in order to extract attribute definition, e.g. `t!("mycomponent.placeholder")` 7 | in: 8 | ``` 9 | mycomponent = Component Name 10 | .placeholder = Some placeholder 11 | .aria-text = Some aria text 12 | ``` 13 | 14 | - Added examples for all fluent grammar constructs and configuration variants. 15 | 16 | ## [0.4.2] 2025-02-08 17 | 18 | ### Fixed 19 | 20 | - [Issue #15](https://github.com/dioxus-community/dioxus-i18n/issues/15) Recent change to t! macro unnecessarily breaks v0.3 code. 21 | 22 | ### Amended 23 | 24 | - t! macro amended to use unwrap_or_else rather than panic!. 25 | 26 | - Error messages made consistant across all macros. 27 | 28 | ## [0.4.1] 2025-02-02 29 | 30 | ### Added 31 | 32 | - New methods (`I18nConfig::with_auto_locales`) to determine supported locales from deep search for translation files. 33 | 34 | - New methods returning `Result<_, Error>` rather than `panic!`, such that: 35 | | __`panic!` version__ | __`Result<_, Error>` vesion__ | 36 | |-----------------------------------------|------------------------------------------| 37 | | `LocaleResource::to_resource_string` | `LocaleResource::try_to_resource_string` | 38 | | `I18n::translate` | `I18n::try_translate` | 39 | | `I18n::translate_with_args` | `I18n::try_translate_with_args` | 40 | | `I18n::set_fallback_language` | `I18n::try_set_fallback_language` | 41 | | `I18n::set_language` | `I18n::try_set_language` | 42 | | `use_init_i18n` | `try_use_init_i18n` | 43 | | `I18nConfig::with_auto_locales` | `I18nConfig::try_with_auto_locales` | 44 | 45 | - New `te!` macro which acts like `t!` but returns `Error`. 46 | 47 | - New `tid!` macro which acts like `t!` but returns the message-id. 48 | 49 | ### Change 50 | 51 | - t! macro amended to use `try_translate` and `try_translate_with_args`, but will perform `.expect("..")` 52 | and therefore panic! on error. This retains backwards compatibility for this macro. 53 | 54 | - Use of `set_fallback_language` / `try_set_fallback_language` without a corresponding locale 55 | translation is treated as an error. 56 | 57 | ## [0.4.0] 2025-01-25 58 | 59 | ### Added 60 | 61 | - Code: 62 | - Doc comments 63 | - Module tests for `cargo test` 64 | 65 | - Amended `I18nConfig::with_locale` so that the `Locale` dynamic or static 66 | constructors no longer have to be _explicitly_ given. 67 | They can be determined implicitly from `(LanguageIdentifier, &str)` or 68 | `(LanguageIdentifer, PathBuf)`. 69 | 70 | - Enabled shared 'LocaleResource's, where two dialect can use the same translation file. 71 | For example ["en", "en-GB"] share "en-GB.ftl". 72 | 73 | ### Changed 74 | 75 | - The translations used are determined when `I18n::set_language` or 76 | `I18n::set_fallback_language` is called, and not each time a message is translated. 77 | 78 | - __Fallback handling has changed__. It no longer just uses _fallback_language_ when the message 79 | id is missing from the current _locale_. It performs a graceful fallback from 80 | _-_ to __ before using the actual _fallback_ (in fact it 81 | falls back along the _---_ 82 | hiearchy). 83 | 84 | __Note:__ this is a breaking change which may impact the selected translation. 85 | 86 | - `LocaleResource::to_string` renamed to `LocaleResource::to_resource_string` 87 | 88 | ## [0.3.0] 2024-12-10 89 | 90 | - [Dioxus 0.6](https://dioxuslabs.com/) support 91 | 92 | ## [0.2.4] 2024-09-11 93 | 94 | - Hide new_dynamic in WASM 95 | - New t!() macro 96 | 97 | ## [0.2.3] 2024-09-04 98 | 99 | - Support dynamic loading of locales 100 | 101 | ## [0.2.2] 2024-09-02 102 | 103 | - Enable macros instead of serde in unic-langid 104 | 105 | ## [0.2.1] 2024-09-02 106 | 107 | - Export unic_langid and fluent 108 | - Use absolute path to import fluent in the translate macro 109 | - Updated freya example 110 | 111 | ## [0.2.0] 2024-09-01 112 | 113 | - Now based in the [Fluent Project](https://github.com/projectfluent/fluent-rs) 114 | 115 | ## [0.1.0] 2024-08-31 116 | 117 | - Initial release 118 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dioxus-i18n" 3 | version = "0.4.3" 4 | edition = "2021" 5 | authors = ["Marc Espín "] 6 | description = "i18n integration for Dioxus apps based on Fluent Project." 7 | license = "MIT" 8 | repository = "https://github.com/dioxus-community/dioxus-i18n" 9 | readme = "./README.md" 10 | categories = ["accessibility", "gui", "localization", "internationalization"] 11 | 12 | [dependencies] 13 | dioxus-lib = { version = "0.6", default-features = false, features = [ 14 | "hooks", 15 | "macro", 16 | "signals", 17 | ] } 18 | fluent = "0.16.1" 19 | thiserror = "2.0.9" 20 | unic-langid = { version = "0.9.5", features = ["macros"] } 21 | 22 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 23 | walkdir = "2.5.0" 24 | 25 | [dev-dependencies] 26 | dioxus = { version = "0.6", features = ["desktop"] } 27 | freya = "0.3" 28 | futures = "0.3.31" 29 | pretty_assertions = "1.4.1" 30 | unic-langid = { version = "0.9.5", features = ["macros"] } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Marc Espín Sanz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dioxus-i18n 🌍 2 | 3 | i18n integration for Dioxus apps based on the [Project Fluent](https://github.com/projectfluent/fluent-rs). 4 | 5 | > This crate used to be in the [Dioxus SDK](https://github.com/DioxusLabs/sdk). 6 | 7 | ## Support 8 | 9 | - **Dioxus v0.6** 🧬 10 | - Renderers: 11 | - [web](https://dioxuslabs.com/learn/0.6/guides/web/), 12 | - [desktop](https://dioxuslabs.com/learn/0.6/guides/desktop/), 13 | - [freya](https://github.com/marc2332/freya) 14 | - Both WASM and native targets 15 | 16 | ## Example: 17 | 18 | ```ftl 19 | # en-US.ftl 20 | 21 | hello = Hello, {$name}! 22 | ``` 23 | 24 | ```rs 25 | // main.rs 26 | 27 | fn app() -> Element { 28 | let i18 = use_init_i18n(|| { 29 | I18nConfig::new(langid!("en-US")) 30 | // implicit [`Locale`] 31 | .with_locale(( // Embed 32 | langid!("en-US"), 33 | include_str!("./en-US.ftl") 34 | )) 35 | .with_locale(( // Load at launch 36 | langid!("es-ES"), 37 | PathBuf::from("./es-ES.ftl"), 38 | )) 39 | .with_locale(( // Locales will share duplicated locale_resources 40 | langid!("en"), // which is useful to assign a specific region for 41 | include_str!("./en-US.ftl") // the primary language 42 | )) 43 | // explicit [`Locale`] 44 | .with_locale(Locale::new_static( // Embed 45 | langid!("en-US"), 46 | include_str!("./en-US.ftl"), 47 | )) 48 | .with_locale(Locale::new_dynamic( // Load at launch 49 | langid!("es-ES"), 50 | PathBuf::from("./es-ES.ftl"), 51 | )) 52 | }); 53 | 54 | rsx!( 55 | label { { t!("hello", name: "World") } } 56 | ) 57 | } 58 | ``` 59 | 60 | ## Further examples 61 | 62 | The examples folder contains a number of working examples: 63 | 64 | * Desktop examples: 65 | * [Dioxus](./examples/dioxus-desktop.rs) 66 | * [Freya](./examples/freya.rs) 67 | * Configuration variants: 68 | * [Auto locales](./examples/config-auto-locales.rs) 69 | * [Dynamic (PathBuf)](./examples/config-dynamic-pathbuf.rs) 70 | * [Static (include_str!)](./examples/config-static-includestr.rs) 71 | * Fluent grammer: 72 | * [Application](./examples/fluent-grammar.rs) 73 | * [FTL file](./examples/data/fluent/en.ftl) 74 | 75 | ## Development 76 | 77 | ```bash 78 | # Checks clean compile against `#[cfg(not(target_arch = "wasm32"))]` 79 | cargo build --target wasm32-unknown-unknown 80 | 81 | # Runs all tests 82 | cargo test 83 | ``` 84 | 85 | [MIT License](./LICENSE.md) 86 | -------------------------------------------------------------------------------- /examples/config-auto-locales.rs: -------------------------------------------------------------------------------- 1 | //! This example demonstrates how to use an auto_locales derived I18nConfig. 2 | //! This is useful when you have a lot of locales and you don't want to manually add them. 3 | 4 | use dioxus::prelude::*; 5 | use dioxus_i18n::{prelude::*, t}; 6 | use unic_langid::langid; 7 | 8 | use std::path::PathBuf; 9 | 10 | fn main() { 11 | launch(app); 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | fn Body() -> Element { 16 | let mut i18n = i18n(); 17 | 18 | let change_to_english = move |_| i18n.set_language(langid!("en-US")); 19 | let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); 20 | 21 | rsx!( 22 | button { 23 | onclick: change_to_english, 24 | label { 25 | "English" 26 | } 27 | } 28 | button { 29 | onclick: change_to_spanish, 30 | label { 31 | "Spanish" 32 | } 33 | } 34 | p { { t!("hello_world") } } 35 | p { { t!("hello", name: "Dioxus") } } 36 | ) 37 | } 38 | 39 | fn app() -> Element { 40 | use_init_i18n(|| { 41 | // This initialisation performs a deep search for all locales in the given path. 42 | // It IS NOT supported in WASM targets. 43 | I18nConfig::new(langid!("en-US")).with_auto_locales(PathBuf::from("./examples/data/i18n/")) 44 | }); 45 | 46 | rsx!(Body {}) 47 | } 48 | -------------------------------------------------------------------------------- /examples/config-dynamic-pathbuf.rs: -------------------------------------------------------------------------------- 1 | //! This example demonstrates how to use pathbuf derived I18nConfig. 2 | //! This is useful when the path to the translation files is not known at compile time. 3 | 4 | use dioxus::prelude::*; 5 | use dioxus_i18n::{prelude::*, t}; 6 | use unic_langid::langid; 7 | 8 | use std::path::PathBuf; 9 | 10 | fn main() { 11 | launch(app); 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | fn Body() -> Element { 16 | let mut i18n = i18n(); 17 | 18 | let change_to_english = move |_| i18n.set_language(langid!("en-US")); 19 | let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); 20 | 21 | rsx!( 22 | button { 23 | onclick: change_to_english, 24 | label { 25 | "English" 26 | } 27 | } 28 | button { 29 | onclick: change_to_spanish, 30 | label { 31 | "Spanish" 32 | } 33 | } 34 | p { { t!("hello_world") } } 35 | p { { t!("hello", name: "Dioxus") } } 36 | ) 37 | } 38 | 39 | fn app() -> Element { 40 | use_init_i18n(|| { 41 | // This initialisation allows individual translation files to be selected. 42 | // The locales can be added with an implicitly derived locale (see config-static-includestr.rs for a comparison) 43 | // or using an explicit Locale::new_dynamic call. 44 | // 45 | // The two examples are functionally equivalent. 46 | // 47 | // It IS NOT supported in WASM targets. 48 | I18nConfig::new(langid!("en-US")) 49 | // Implicit... 50 | .with_locale(( 51 | langid!("es-ES"), 52 | PathBuf::from("./examples/data/i18n/es-ES.ftl"), 53 | )) 54 | // Explicit... 55 | .with_locale(Locale::new_dynamic( 56 | langid!("en-US"), 57 | PathBuf::from("./examples/data/i18n/en-US.ftl"), 58 | )) 59 | }); 60 | 61 | rsx!(Body {}) 62 | } 63 | -------------------------------------------------------------------------------- /examples/config-static-includestr.rs: -------------------------------------------------------------------------------- 1 | //! This example demonstrates how to use pathbuf derived I18nConfig. 2 | //! This is useful for WASM targets; the paths to the translation files must be known at compile time. 3 | 4 | use dioxus::prelude::*; 5 | use dioxus_i18n::{prelude::*, t}; 6 | use unic_langid::langid; 7 | 8 | fn main() { 9 | launch(app); 10 | } 11 | 12 | #[allow(non_snake_case)] 13 | fn Body() -> Element { 14 | let mut i18n = i18n(); 15 | 16 | let change_to_english = move |_| i18n.set_language(langid!("en-US")); 17 | let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); 18 | 19 | rsx!( 20 | button { 21 | onclick: change_to_english, 22 | label { 23 | "English" 24 | } 25 | } 26 | button { 27 | onclick: change_to_spanish, 28 | label { 29 | "Spanish" 30 | } 31 | } 32 | p { { t!("hello_world") } } 33 | p { { t!("hello", name: "Dioxus") } } 34 | ) 35 | } 36 | 37 | fn app() -> Element { 38 | use_init_i18n(|| { 39 | // This initialisation allows individual translation files to be selected. 40 | // The locales can be added with an implicitly derived locale (see config-dynamic-pathbuf.rs for a comparison) 41 | // or using an explicit Locale::new_static call. 42 | // 43 | // The two examples are functionally equivalent. 44 | // 45 | // It IS supported in WASM targets. 46 | I18nConfig::new(langid!("en-US")) 47 | // Implicit... 48 | .with_locale((langid!("es-ES"), include_str!("./data/i18n/es-ES.ftl"))) 49 | // Explicit... 50 | .with_locale(Locale::new_static( 51 | langid!("en-US"), 52 | include_str!("./data/i18n/en-US.ftl"), 53 | )) 54 | }); 55 | 56 | rsx!(Body {}) 57 | } 58 | -------------------------------------------------------------------------------- /examples/data/fluent/en.ftl: -------------------------------------------------------------------------------- 1 | ### Fluent grammar examples for dioxus-i18n. 2 | 3 | ## These examples demonstrate Fluent file grammar and how dioxus-i18n can be 4 | ## used to access these translations. 5 | 6 | ## Examples derived from: https://projectfluent.org/fluent/guide/index.html 7 | 8 | # Simple message 9 | simple-message = This is a simple message. 10 | 11 | # $name (String) - The name you want to display. 12 | message-with-variable = This is a message with a variable: { $name }. 13 | 14 | # Reference to a term. 15 | -a-term = This is a common term used by many messages. 16 | message-referencing-a-term = This is a message with a reference: { -a-term }. 17 | 18 | # Use of special characters. 19 | message-with-special-character = This message contain opening curly brace {"{"} and a closing curly brace {"}"}. 20 | 21 | # Message with blanks. 22 | blank-is-removed = This message starts with no blanks. 23 | blank-is-preserved = {" "}This message starts with 4 spaces (note HTML contracts them). 24 | 25 | # Message with attributes. 26 | message-with-attributes = Predefined value 27 | .placeholder = email@example.com 28 | .aria-label = Login input value 29 | .title = Type your login email 30 | 31 | # Message with quotes. 32 | literal-quote-cryptic = Text in {"\""}double quotes{"\""}. 33 | literal-quote-preferred = Text in "double quotes". 34 | 35 | # Message with Unicode characters. 36 | unicode-cryptic = {"\u2605"} {"\u2606"} {"\u2728"} {"\u262F"} {"\u263A"} 37 | unicode-preferred = ★ ☆ ✨ ☯ ☺ 38 | 39 | # Message with a placeable. 40 | single-line = Text can be written in a single line. 41 | 42 | multi-line = Text can also span multiple lines 43 | as long as each new line is indented 44 | by at least one space. 45 | 46 | block-line = 47 | Sometimes it's more readable to format 48 | multiline text as a "block", which means 49 | starting it on a new line. All lines must 50 | be indented by at least one space. 51 | 52 | # Message using functions. 53 | # 54 | # Note: Builtin functions are currently unsupported: See Fluent issue https://github.com/projectfluent/fluent-rs/issues/181 55 | # The Bundle::add_builtins() function is not published at the time of writing this example. 56 | # 57 | # Using a builtin currently results in an error. 58 | # 59 | # $duration (Number) - The duration in seconds. 60 | time-elapsed-no-function = Time elapsed: { $duration }s. 61 | time-elapsed-function = Currently unsupported: error raised: { NUMBER($duration) }. 62 | 63 | # Message reference. 64 | referenced-message = Referenced message 65 | message-referencing-another-message = Message referencing another message: { referenced-message }. 66 | 67 | # Message selection plurals. 68 | message-selection-plurals = 69 | { $value -> 70 | *[one] Value is one: { $value }. 71 | [other] Value is more than one: { $value }. 72 | } 73 | 74 | # Message selection numeric. 75 | # Argument must be numeric. 76 | message-selection-numeric = 77 | { NUMERIC($value) -> 78 | [0.0] Zero: { $value }. 79 | *[0.5] A half: { $value }. 80 | [other] Other ($value) 81 | } 82 | 83 | # Message selection number. 84 | # 85 | # Note: Builtin functions are currently unsupported: See Fluent issue https://github.com/projectfluent/fluent-rs/issues/181 86 | # The Bundle::add_builtins() function is not published at the time of writing this example. 87 | # 88 | # Using the NUMBER builtin always results in a default behaviour. 89 | # 90 | message-selection-number = { NUMBER($pos, type: "ordinal") -> 91 | [1] First! 92 | [one] {$pos}st 93 | [two] {$pos}nd 94 | [few] {$pos}rd 95 | *[other] {$pos}th 96 | } 97 | 98 | 99 | # Variables in references. 100 | -term-using-variable = https://{ $host } 101 | message-using-term-with-variable = For example: { -term-using-variable(host: "example.com") }. 102 | 103 | -term-using-variable-2 = 104 | { $case -> 105 | *[nominative] Firefox 106 | [locative] Firefoksie 107 | } 108 | message-using-term-with-variable-2-1 = Informacje o { -term-using-variable-2(case: "locative") }. 109 | message-using-term-with-variable-2-2 = About { -term-using-variable-2(case: "nominative") }. 110 | message-using-term-with-variable-2-default = About { -term-using-variable-2(case: "") }. 111 | message-using-term-with-variable-2-not-provided = About { -term-using-variable-2 }. 112 | 113 | -brand-name = 114 | { $case -> 115 | *[nominative] Firefox 116 | [locative] Firefoksie 117 | } 118 | 119 | string-literal = { "string literal" } 120 | number-literal-1 = { 1 } 121 | number-literal-2 = { -123 } 122 | number-literal-3 = { 3.14 } 123 | inline-expression-placeable-1 = { { "string literal" } } 124 | inline-expression-placeable-2 = { { 123 } } 125 | -------------------------------------------------------------------------------- /examples/data/i18n/en-US.ftl: -------------------------------------------------------------------------------- 1 | hello_world = Hello, World! 2 | 3 | hello = Hello, {$name}! -------------------------------------------------------------------------------- /examples/data/i18n/es-ES.ftl: -------------------------------------------------------------------------------- 1 | hello_world = Hola, Mundo! 2 | 3 | hello = Hola, {$name}! -------------------------------------------------------------------------------- /examples/dioxus-desktop.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_i18n::{prelude::*, t}; 3 | use unic_langid::langid; 4 | 5 | use std::path::PathBuf; 6 | 7 | fn main() { 8 | launch(app); 9 | } 10 | 11 | #[allow(non_snake_case)] 12 | fn Body() -> Element { 13 | let mut i18n = i18n(); 14 | 15 | let change_to_english = move |_| i18n.set_language(langid!("en-US")); 16 | let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); 17 | 18 | rsx!( 19 | button { 20 | onclick: change_to_english, 21 | label { 22 | "English" 23 | } 24 | } 25 | button { 26 | onclick: change_to_spanish, 27 | label { 28 | "Spanish" 29 | } 30 | } 31 | p { { t!("hello_world") } } 32 | p { { t!("hello", name: "Dioxus") } } 33 | ) 34 | } 35 | 36 | fn app() -> Element { 37 | use_init_i18n(|| { 38 | I18nConfig::new(langid!("en-US")) 39 | .with_locale((langid!("en-US"), include_str!("./data/i18n/en-US.ftl"))) 40 | .with_locale(( 41 | langid!("es-ES"), 42 | PathBuf::from("./examples/data/i18n/es-ES.ftl"), 43 | )) 44 | }); 45 | 46 | rsx!(Body {}) 47 | } 48 | -------------------------------------------------------------------------------- /examples/fluent-grammar.rs: -------------------------------------------------------------------------------- 1 | //! This example demonstrates many of the Fluent grammar constructs, and how they are 2 | //! used in dioxus-i18n. 3 | //! This performs a lookup only, no additional translation files are provided 4 | 5 | use dioxus::prelude::*; 6 | use dioxus_i18n::{prelude::*, tid}; 7 | use unic_langid::langid; 8 | 9 | use std::path::PathBuf; 10 | 11 | fn main() { 12 | launch(app); 13 | } 14 | 15 | #[allow(non_snake_case)] 16 | #[component] 17 | fn Body() -> Element { 18 | rsx! { 19 | table { 20 | tbody { 21 | tr { 22 | td { "Simple message" } 23 | td { {tid!("simple-message")} } 24 | } 25 | tr { 26 | td { "Non-existing message: id provided by default when using tid! macro" } 27 | td { {tid!("non-existing-message")} } 28 | } 29 | tr { 30 | td { "Message with a variable" } 31 | td { {tid!("message-with-variable", name: "Value 1")} } 32 | } 33 | tr { 34 | td { } 35 | td { {tid!("message-with-variable", name: "Value 2")} } 36 | } 37 | tr { 38 | td { "Reference to a term" } 39 | td { {tid!("message-referencing-a-term")} } 40 | } 41 | tr { 42 | td { "Use of special characters." } 43 | td { {tid!("message-with-special-character")} } 44 | } 45 | tr { 46 | td { "Message with blanks." } 47 | td { "'" {tid!("blank-is-removed")} "'" } 48 | } 49 | tr { 50 | td { } 51 | td { "'" {tid!("blank-is-preserved")} "'" } 52 | } 53 | tr { 54 | td { "Message with attributes: root" } 55 | td { {tid!("message-with-attributes")} } 56 | } 57 | tr { 58 | td { "Message with attributes: attribute" } 59 | td { {tid!("message-with-attributes.placeholder")} } 60 | } 61 | tr { 62 | td { } 63 | td { {tid!("message-with-attributes.aria-label")} } 64 | } 65 | tr { 66 | td { } 67 | td { {tid!("message-with-attributes.title")} } 68 | } 69 | tr { 70 | td { "Message with attributes: not existing" } 71 | td { {tid!("message-with-attributes.not-existing")} } 72 | } 73 | tr { 74 | td { "Message with attributes: invalid" } 75 | td { {tid!("message-with-attributes.placeholder.invalid")} } 76 | } 77 | tr { 78 | td { "Message with quotes: cryptic" } 79 | td { {tid!("literal-quote-cryptic")} } 80 | } 81 | tr { 82 | td { "Message with quotes: preferred" } 83 | td { {tid!("literal-quote-preferred")} } 84 | } 85 | tr { 86 | td { "Message with Unicode characters: cryptic" } 87 | td { {tid!("unicode-cryptic")} } 88 | } 89 | tr { 90 | td { "Message with Unicode characters: preferred" } 91 | td { {tid!("unicode-preferred")} } 92 | } 93 | tr { 94 | td { "Message with a placeable: single-line" } 95 | td { {tid!("line-single")} } 96 | } 97 | tr { 98 | td { "Message with a placeable: single-line" } 99 | td { {tid!("single-line")} } 100 | } 101 | tr { 102 | td { "Message with a placeable: multi-line (1)" } 103 | td { {tid!("multi-line")} } 104 | } 105 | tr { 106 | td { "Message with a placeable: multi-line (2)" } 107 | td { pre { {tid!("multi-line")} } } 108 | } 109 | tr { 110 | td { "Message with a placeable: block-line (1)" } 111 | td { {tid!("block-line")} } 112 | } 113 | tr { 114 | td { "Message with a placeable: block-line (2)" } 115 | td { pre { {tid!("block-line")} } } 116 | } 117 | tr { 118 | td { "Message using functions: no function" } 119 | td { pre { {tid!("time-elapsed-no-function", duration: 23.7114812589)} } } 120 | } 121 | tr { 122 | td { "Message using functions: function" } 123 | td { pre { {tid!("time-elapsed-function", duration: 23.7114812589)} } } 124 | } 125 | tr { 126 | td { "Reference to a message" } 127 | td { {tid!("message-referencing-another-message")} } 128 | } 129 | tr { 130 | td { "Message selection: plurals" } 131 | td { {tid!("message-selection-plurals", value: 1)} } 132 | } 133 | tr { 134 | td { } 135 | td { {tid!("message-selection-plurals", value: 2)} } 136 | } 137 | tr { 138 | td { "Message selection: plurals (default: an 'empty' value must be provided...)" } 139 | td { {tid!("message-selection-plurals", value: "")} } 140 | } 141 | tr { 142 | td { "Message selection: plurals (default: ... otherwise an error is raised)" } 143 | td { {tid!("message-selection-plurals")} } 144 | } 145 | tr { 146 | td { "Message selection: numeric" } 147 | td { {tid!("message-selection-numeric", value: 0.0)} } 148 | } 149 | tr { 150 | td { } 151 | td { {tid!("message-selection-numeric", value: 0.5)} } 152 | } 153 | tr { 154 | td { } 155 | td { {tid!("message-selection-numeric", value: 42.0)} } 156 | } 157 | tr { 158 | td { "Message selection: numeric (default)" } 159 | td { {tid!("message-selection-numeric", value: "")} } 160 | } 161 | tr { 162 | td { "Message selection: number" } 163 | td { {tid!("message-selection-number", pos: 1)} } 164 | } 165 | tr { 166 | td { "" } 167 | td { {tid!("message-selection-number", pos: 2)} } 168 | } 169 | tr { 170 | td { "" } 171 | td { {tid!("message-selection-number", pos: 3)} } 172 | } 173 | tr { 174 | td { "" } 175 | td { {tid!("message-selection-number", pos: 4)} } 176 | } 177 | tr { 178 | td { "Variables in references (1)" } 179 | td { {tid!("message-using-term-with-variable")} } 180 | } 181 | tr { 182 | td { "Variables in references (2)" } 183 | td { {tid!("message-using-term-with-variable-2-1")} } 184 | } 185 | tr { 186 | td { } 187 | td { {tid!("message-using-term-with-variable-2-2")} } 188 | } 189 | tr { 190 | td { } 191 | td { {tid!("message-using-term-with-variable-2-default")} } 192 | } 193 | tr { 194 | td { } 195 | td { {tid!("message-using-term-with-variable-2-not-provided")} } 196 | } 197 | tr { 198 | td { "Literals: string" } 199 | td { {tid!("string-literal")} } 200 | } 201 | tr { 202 | td { } 203 | td { {tid!("number-literal-1")} } 204 | } 205 | tr { 206 | td { } 207 | td { {tid!("number-literal-2")} } 208 | } 209 | tr { 210 | td { } 211 | td { {tid!("number-literal-3")} } 212 | } 213 | tr { 214 | td { } 215 | td { {tid!("inline-expression-placeable-1")} } 216 | } 217 | tr { 218 | td { } 219 | td { {tid!("inline-expression-placeable-2")} } 220 | } 221 | } 222 | } 223 | } 224 | } 225 | 226 | fn app() -> Element { 227 | use_init_i18n(|| { 228 | // Only one example in this path, which contains the complete Fluent grammar. 229 | I18nConfig::new(langid!("en")).with_auto_locales(PathBuf::from("./examples/data/fluent/")) 230 | }); 231 | 232 | rsx!(Body {}) 233 | } 234 | -------------------------------------------------------------------------------- /examples/freya.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | use dioxus_i18n::{prelude::*, t}; 7 | use freya::prelude::*; 8 | use unic_langid::langid; 9 | 10 | use std::path::PathBuf; 11 | 12 | fn main() { 13 | launch_with_props(app, "freya + i18n", (300.0, 200.0)); 14 | } 15 | 16 | #[allow(non_snake_case)] 17 | fn Body() -> Element { 18 | let mut i18n = i18n(); 19 | 20 | let change_to_english = move |_| i18n.set_language(langid!("en-US")); 21 | let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); 22 | 23 | rsx!( 24 | rect { 25 | rect { 26 | direction: "horizontal", 27 | Button { 28 | onclick: change_to_english, 29 | label { 30 | "English" 31 | } 32 | } 33 | Button { 34 | onclick: change_to_spanish, 35 | label { 36 | "Spanish" 37 | } 38 | } 39 | } 40 | label { { t!("hello_world") } } 41 | label { { t!("hello", name: "Dioxus") } } 42 | } 43 | ) 44 | } 45 | 46 | fn app() -> Element { 47 | use_init_i18n(|| { 48 | I18nConfig::new(langid!("en-US")) 49 | .with_locale((langid!("en-US"), include_str!("./data/i18n/en-US.ftl"))) 50 | .with_locale(( 51 | langid!("es-ES"), 52 | PathBuf::from("./examples/data/i18n/es-ES.ftl"), 53 | )) 54 | }); 55 | 56 | rsx!(Body {}) 57 | } 58 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Clone, Debug, Error)] 4 | pub enum Error { 5 | #[error("invalid message id: '{0}'")] 6 | InvalidMessageId(String), 7 | 8 | #[error("message id not found for key: '{0}'")] 9 | MessageIdNotFound(String), 10 | 11 | #[error("attribute id not found for key: '{0}'")] 12 | AttributeIdNotFound(String), 13 | 14 | #[error("message pattern not found for key: '{0}'")] 15 | MessagePatternNotFound(String), 16 | 17 | #[error("fluent errors during lookup:\n{0}")] 18 | FluentErrorsDetected(String), 19 | 20 | #[error("failed to read locale resource from path: {0}")] 21 | LocaleResourcePathReadFailed(String), 22 | 23 | #[error("fallback for \"{0}\" must have locale")] 24 | FallbackMustHaveLocale(String), 25 | 26 | #[error("language id cannot be determined - reason: {0}")] 27 | InvalidLanguageId(String), 28 | 29 | #[error("invalid path: {0}")] 30 | InvalidPath(String), 31 | } 32 | -------------------------------------------------------------------------------- /src/i18n_macro.rs: -------------------------------------------------------------------------------- 1 | //! Key translation macros. 2 | //! 3 | //! Using file: 4 | //! 5 | //! ```ftl 6 | //! # en-US.ftl 7 | //! # 8 | //! hello = Hello, {$name}! 9 | //! ``` 10 | 11 | /// Translate message from key, returning [`crate::prelude::DioxusI18nError`] if id not found... 12 | /// 13 | /// ```rust 14 | /// # use dioxus::prelude::*; 15 | /// # use dioxus_i18n::{te, prelude::*}; 16 | /// # use unic_langid::langid; 17 | /// # #[component] 18 | /// # fn Example() -> Element { 19 | /// # let lang = langid!("en-US"); 20 | /// # let config = I18nConfig::new(lang.clone()).with_locale((lang.clone(), "hello = Hello, {$name}")).with_fallback(lang.clone()); 21 | /// # let mut i18n = use_init_i18n(|| config); 22 | /// let name = "Avery Gigglesworth"; 23 | /// let hi = te!("hello", name: {name}).expect("message id 'name' should be present"); 24 | /// assert_eq!(hi, "Hello, Avery Gigglesworth"); 25 | /// # rsx! { "" } 26 | /// # } 27 | /// ``` 28 | /// 29 | #[macro_export] 30 | macro_rules! te { 31 | ($id:expr, $( $name:ident : $value:expr ),* ) => { 32 | { 33 | let mut params_map = dioxus_i18n::fluent::FluentArgs::new(); 34 | $( 35 | params_map.set(stringify!($name), $value); 36 | )* 37 | dioxus_i18n::prelude::i18n().try_translate_with_args($id, Some(¶ms_map)) 38 | } 39 | }; 40 | 41 | ($id:expr ) => {{ 42 | dioxus_i18n::prelude::i18n().try_translate($id) 43 | }}; 44 | } 45 | 46 | /// Translate message from key, panic! if id not found... 47 | /// 48 | /// ```rust 49 | /// # use dioxus::prelude::*; 50 | /// # use dioxus_i18n::{t, prelude::*}; 51 | /// # use unic_langid::langid; 52 | /// # #[component] 53 | /// # fn Example() -> Element { 54 | /// # let lang = langid!("en-US"); 55 | /// # let config = I18nConfig::new(lang.clone()).with_locale((lang.clone(), "hello = Hello, {$name}")).with_fallback(lang.clone()); 56 | /// # let mut i18n = use_init_i18n(|| config); 57 | /// let name = "Avery Gigglesworth"; 58 | /// let hi = t!("hello", name: {name}); 59 | /// assert_eq!(hi, "Hello, Avery Gigglesworth"); 60 | /// # rsx! { "" } 61 | /// # } 62 | /// ``` 63 | /// 64 | #[macro_export] 65 | macro_rules! t { 66 | ($id:expr, $( $name:ident : $value:expr ),* ) => { 67 | dioxus_i18n::te!($id, $( $name : $value ),*).unwrap_or_else(|e| panic!("{}", e.to_string())) 68 | }; 69 | 70 | ($id:expr ) => {{ 71 | dioxus_i18n::te!($id).unwrap_or_else(|e| panic!("{}", e.to_string())) 72 | }}; 73 | } 74 | 75 | /// Translate message from key, return id if no translation found... 76 | /// 77 | /// ```rust 78 | /// # use dioxus::prelude::*; 79 | /// # use dioxus_i18n::{tid, prelude::*}; 80 | /// # use unic_langid::langid; 81 | /// # #[component] 82 | /// # fn Example() -> Element { 83 | /// # let lang = langid!("en-US"); 84 | /// # let config = I18nConfig::new(lang.clone()).with_locale((lang.clone(), "hello = Hello, {$name}")).with_fallback(lang.clone()); 85 | /// # let mut i18n = use_init_i18n(|| config); 86 | /// let message = tid!("no-key"); 87 | /// assert_eq!(message, "message-id: no-key should be translated"); 88 | /// # rsx! { "" } 89 | /// # } 90 | /// ``` 91 | /// 92 | #[macro_export] 93 | macro_rules! tid { 94 | ($id:expr, $( $name:ident : $value:expr ),* ) => { 95 | dioxus_i18n::te!($id, $( $name : $value ),*).unwrap_or_else(|e| e.to_string()) 96 | }; 97 | 98 | ($id:expr ) => {{ 99 | dioxus_i18n::te!($id).unwrap_or_else(|e| e.to_string()) 100 | }}; 101 | } 102 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | mod error; 3 | pub mod i18n_macro; 4 | pub mod use_i18n; 5 | 6 | pub use fluent; 7 | pub use unic_langid; 8 | 9 | pub mod prelude { 10 | pub use crate::error::Error as DioxusI18nError; 11 | pub use crate::use_i18n::*; 12 | } 13 | -------------------------------------------------------------------------------- /src/use_i18n.rs: -------------------------------------------------------------------------------- 1 | use super::error::Error; 2 | 3 | use dioxus_lib::prelude::*; 4 | use fluent::{FluentArgs, FluentBundle, FluentResource}; 5 | use unic_langid::LanguageIdentifier; 6 | 7 | #[cfg(not(target_arch = "wasm32"))] 8 | use walkdir::WalkDir; 9 | 10 | use std::collections::HashMap; 11 | 12 | #[cfg(not(target_arch = "wasm32"))] 13 | use std::path::{Path, PathBuf}; 14 | 15 | /// `Locale` is a "place-holder" around what will eventually be a `fluent::FluentBundle` 16 | #[cfg_attr(test, derive(Debug, PartialEq))] 17 | pub struct Locale { 18 | id: LanguageIdentifier, 19 | resource: LocaleResource, 20 | } 21 | 22 | impl Locale { 23 | pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self { 24 | Self { 25 | id, 26 | resource: LocaleResource::Static(str), 27 | } 28 | } 29 | 30 | #[cfg(not(target_arch = "wasm32"))] 31 | pub fn new_dynamic(id: LanguageIdentifier, path: impl Into) -> Self { 32 | Self { 33 | id, 34 | resource: LocaleResource::Path(path.into()), 35 | } 36 | } 37 | } 38 | 39 | impl From<(LanguageIdentifier, T)> for Locale 40 | where 41 | T: Into, 42 | { 43 | fn from((id, resource): (LanguageIdentifier, T)) -> Self { 44 | let resource = resource.into(); 45 | Self { id, resource } 46 | } 47 | } 48 | 49 | /// A `LocaleResource` can be static text, or a filesystem file (not supported in WASM). 50 | #[derive(Debug, PartialEq)] 51 | pub enum LocaleResource { 52 | Static(&'static str), 53 | #[cfg(not(target_arch = "wasm32"))] 54 | Path(PathBuf), 55 | } 56 | 57 | impl LocaleResource { 58 | pub fn try_to_resource_string(&self) -> Result { 59 | match self { 60 | Self::Static(str) => Ok(str.to_string()), 61 | #[cfg(not(target_arch = "wasm32"))] 62 | Self::Path(path) => std::fs::read_to_string(path) 63 | .map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())), 64 | } 65 | } 66 | 67 | pub fn to_resource_string(&self) -> String { 68 | let result = self.try_to_resource_string(); 69 | match result { 70 | Ok(string) => string, 71 | Err(err) => panic!("failed to create resource string {:?}: {}", self, err), 72 | } 73 | } 74 | } 75 | 76 | impl From<&'static str> for LocaleResource { 77 | fn from(value: &'static str) -> Self { 78 | Self::Static(value) 79 | } 80 | } 81 | 82 | #[cfg(not(target_arch = "wasm32"))] 83 | impl From for LocaleResource { 84 | fn from(value: PathBuf) -> Self { 85 | Self::Path(value) 86 | } 87 | } 88 | 89 | /// The configuration for `I18n`. 90 | #[cfg_attr(test, derive(Debug, PartialEq))] 91 | pub struct I18nConfig { 92 | /// The initial language, can be later changed with [`I18n::set_language`] 93 | id: LanguageIdentifier, 94 | 95 | /// The final fallback language if no other locales are found for `id`. 96 | /// A `Locale` must exist in `locales' if `fallback` is defined. 97 | fallback: Option, 98 | 99 | /// The locale_resources added to the configuration. 100 | locale_resources: Vec, 101 | 102 | /// The locales added to the configuration. 103 | locales: HashMap, 104 | } 105 | 106 | impl I18nConfig { 107 | /// Create an i18n config with the selected [LanguageIdentifier]. 108 | pub fn new(id: LanguageIdentifier) -> Self { 109 | Self { 110 | id, 111 | fallback: None, 112 | locale_resources: Vec::new(), 113 | locales: HashMap::new(), 114 | } 115 | } 116 | 117 | /// Set a fallback [LanguageIdentifier]. 118 | pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self { 119 | self.fallback = Some(fallback); 120 | self 121 | } 122 | 123 | /// Add [Locale]. 124 | /// It is possible to share locales resources. If this locale's resource 125 | /// matches a previously added one, then this locale will use the existing one. 126 | /// This is primarily for the static locale_resources to avoid string duplication. 127 | pub fn with_locale(mut self, locale: T) -> Self 128 | where 129 | T: Into, 130 | { 131 | let locale = locale.into(); 132 | let locale_resources_len = self.locale_resources.len(); 133 | 134 | let index = self 135 | .locale_resources 136 | .iter() 137 | .position(|r| *r == locale.resource) 138 | .unwrap_or(locale_resources_len); 139 | 140 | if index == locale_resources_len { 141 | self.locale_resources.push(locale.resource) 142 | }; 143 | 144 | self.locales.insert(locale.id, index); 145 | self 146 | } 147 | 148 | /// Add multiple locales from given folder, based on their filename. 149 | /// 150 | /// If the path represents a folder, then the folder will be deep traversed for 151 | /// all '*.ftl' files. If the filename represents a [LanguageIdentifier] then it 152 | /// will be added to the config. 153 | /// 154 | /// If the path represents a file, then the filename must represent a 155 | /// unic_langid::LanguageIdentifier for it to be added to the config. 156 | /// 157 | /// The method is not available for `wasm32` builds. 158 | #[cfg(not(target_arch = "wasm32"))] 159 | pub fn try_with_auto_locales(self, path: PathBuf) -> Result { 160 | if path.is_dir() { 161 | let files = find_ftl_files(&path)?; 162 | files 163 | .into_iter() 164 | .try_fold(self, |acc, file| acc.with_auto_pathbuf(file)) 165 | } else if is_ftl_file(&path) { 166 | self.with_auto_pathbuf(path) 167 | } else { 168 | Err(Error::InvalidPath(path.to_string_lossy().to_string())) 169 | } 170 | } 171 | 172 | #[cfg(not(target_arch = "wasm32"))] 173 | fn with_auto_pathbuf(self, file: PathBuf) -> Result { 174 | assert!(is_ftl_file(&file)); 175 | 176 | let stem = file.file_stem().ok_or_else(|| { 177 | Error::InvalidLanguageId(format!("No file stem: '{}'", file.display())) 178 | })?; 179 | 180 | let id_str = stem.to_str().ok_or_else(|| { 181 | Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy())) 182 | })?; 183 | 184 | let id = LanguageIdentifier::from_bytes(id_str.as_bytes()) 185 | .map_err(|e| Error::InvalidLanguageId(e.to_string()))?; 186 | 187 | Ok(self.with_locale((id, file))) 188 | } 189 | 190 | /// Add multiple locales from given folder, based on their filename. 191 | /// 192 | /// Will panic! on error. 193 | /// 194 | /// The method is not available for `wasm32` builds. 195 | #[cfg(not(target_arch = "wasm32"))] 196 | pub fn with_auto_locales(self, path: PathBuf) -> Self { 197 | let path_name = path.display().to_string(); 198 | let result = self.try_with_auto_locales(path); 199 | match result { 200 | Ok(result) => result, 201 | Err(err) => panic!( 202 | "with_auto_locales must have valid pathbuf {}: {}", 203 | path_name, err 204 | ), 205 | } 206 | } 207 | } 208 | 209 | #[cfg(not(target_arch = "wasm32"))] 210 | fn find_ftl_files(folder: &PathBuf) -> Result, Error> { 211 | let ftl_files: Vec = WalkDir::new(folder) 212 | .into_iter() 213 | .filter_map(|entry| entry.ok()) 214 | .filter(|entry| is_ftl_file(entry.path())) 215 | .map(|entry| entry.path().to_path_buf()) 216 | .collect(); 217 | 218 | Ok(ftl_files) 219 | } 220 | 221 | #[cfg(not(target_arch = "wasm32"))] 222 | fn is_ftl_file(entry: &Path) -> bool { 223 | entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false) 224 | } 225 | 226 | /// Initialize an i18n provider. 227 | pub fn try_use_init_i18n(init: impl FnOnce() -> I18nConfig) -> Result { 228 | use_context_provider(move || { 229 | // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675 230 | let I18nConfig { 231 | id, 232 | fallback, 233 | locale_resources, 234 | locales, 235 | } = init(); 236 | 237 | I18n::try_new(id, fallback, locale_resources, locales) 238 | }) 239 | } 240 | 241 | /// Initialize an i18n provider. 242 | pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n { 243 | use_context_provider(move || { 244 | // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675 245 | let I18nConfig { 246 | id, 247 | fallback, 248 | locale_resources, 249 | locales, 250 | } = init(); 251 | 252 | match I18n::try_new(id, fallback, locale_resources, locales) { 253 | Ok(i18n) => i18n, 254 | Err(e) => panic!("Failed to create I18n context: {}", e), 255 | } 256 | }) 257 | } 258 | 259 | #[derive(Clone, Copy)] 260 | pub struct I18n { 261 | selected_language: Signal, 262 | fallback_language: Signal>, 263 | locale_resources: Signal>, 264 | locales: Signal>, 265 | active_bundle: Signal>, 266 | } 267 | 268 | impl I18n { 269 | pub fn try_new( 270 | selected_language: LanguageIdentifier, 271 | fallback_language: Option, 272 | locale_resources: Vec, 273 | locales: HashMap, 274 | ) -> Result { 275 | let bundle = try_create_bundle( 276 | &selected_language, 277 | &fallback_language, 278 | &locale_resources, 279 | &locales, 280 | )?; 281 | Ok(Self { 282 | selected_language: Signal::new(selected_language), 283 | fallback_language: Signal::new(fallback_language), 284 | locale_resources: Signal::new(locale_resources), 285 | locales: Signal::new(locales), 286 | active_bundle: Signal::new(bundle), 287 | }) 288 | } 289 | 290 | pub fn new( 291 | selected_language: LanguageIdentifier, 292 | fallback_language: Option, 293 | locale_resources: Vec, 294 | locales: HashMap, 295 | ) -> Self { 296 | let result = Self::try_new( 297 | selected_language, 298 | fallback_language, 299 | locale_resources, 300 | locales, 301 | ); 302 | match result { 303 | Ok(i18n) => i18n, 304 | Err(err) => panic!("I18n cannot be created: {}", err), 305 | } 306 | } 307 | 308 | pub fn try_translate_with_args( 309 | &self, 310 | msg: &str, 311 | args: Option<&FluentArgs>, 312 | ) -> Result { 313 | let (message_id, attribute_name) = Self::decompose_identifier(msg)?; 314 | 315 | let bundle = self.active_bundle.read(); 316 | 317 | let message = bundle 318 | .get_message(message_id) 319 | .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?; 320 | 321 | let pattern = if let Some(attribute_name) = attribute_name { 322 | let attribute = message 323 | .get_attribute(attribute_name) 324 | .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?; 325 | attribute.value() 326 | } else { 327 | message 328 | .value() 329 | .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))? 330 | }; 331 | 332 | let mut errors = vec![]; 333 | let translation = bundle 334 | .format_pattern(pattern, args, &mut errors) 335 | .to_string(); 336 | 337 | (errors.is_empty()) 338 | .then_some(translation) 339 | .ok_or_else(|| Error::FluentErrorsDetected(format!("{:#?}", errors))) 340 | } 341 | 342 | pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> { 343 | let parts: Vec<&str> = msg.split('.').collect(); 344 | match parts.as_slice() { 345 | [message_id] => Ok((message_id, None)), 346 | [message_id, attribute_name] => Ok((message_id, Some(attribute_name))), 347 | _ => Err(Error::InvalidMessageId(msg.to_string())), 348 | } 349 | } 350 | 351 | pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String { 352 | let result = self.try_translate_with_args(msg, args); 353 | match result { 354 | Ok(translation) => translation, 355 | Err(err) => panic!("Failed to translate {}: {}", msg, err), 356 | } 357 | } 358 | 359 | #[inline] 360 | pub fn try_translate(&self, msg: &str) -> Result { 361 | self.try_translate_with_args(msg, None) 362 | } 363 | 364 | pub fn translate(&self, msg: &str) -> String { 365 | let result = self.try_translate(msg); 366 | match result { 367 | Ok(translation) => translation, 368 | Err(err) => panic!("Failed to translate {}: {}", msg, err), 369 | } 370 | } 371 | 372 | /// Get the selected language. 373 | #[inline] 374 | pub fn language(&self) -> LanguageIdentifier { 375 | self.selected_language.read().clone() 376 | } 377 | 378 | /// Get the fallback language. 379 | pub fn fallback_language(&self) -> Option { 380 | self.fallback_language.read().clone() 381 | } 382 | 383 | /// Update the selected language. 384 | pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> { 385 | *self.selected_language.write() = id; 386 | self.try_update_active_bundle() 387 | } 388 | 389 | /// Update the selected language. 390 | pub fn set_language(&mut self, id: LanguageIdentifier) { 391 | let id_name = id.to_string(); 392 | let result = self.try_set_language(id); 393 | match result { 394 | Ok(()) => (), 395 | Err(err) => panic!("cannot set language {}: {}", id_name, err), 396 | } 397 | } 398 | 399 | /// Update the fallback language. 400 | pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> { 401 | self.locales 402 | .read() 403 | .get(&id) 404 | .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?; 405 | 406 | *self.fallback_language.write() = Some(id); 407 | self.try_update_active_bundle() 408 | } 409 | 410 | /// Update the fallback language. 411 | pub fn set_fallback_language(&mut self, id: LanguageIdentifier) { 412 | let id_name = id.to_string(); 413 | let result = self.try_set_fallback_language(id); 414 | match result { 415 | Ok(()) => (), 416 | Err(err) => panic!("cannot set fallback language {}: {}", id_name, err), 417 | } 418 | } 419 | 420 | fn try_update_active_bundle(&mut self) -> Result<(), Error> { 421 | let bundle = try_create_bundle( 422 | &self.selected_language.peek(), 423 | &self.fallback_language.peek(), 424 | &self.locale_resources.peek(), 425 | &self.locales.peek(), 426 | )?; 427 | 428 | self.active_bundle.set(bundle); 429 | Ok(()) 430 | } 431 | } 432 | 433 | fn try_create_bundle( 434 | selected_language: &LanguageIdentifier, 435 | fallback_language: &Option, 436 | locale_resources: &[LocaleResource], 437 | locales: &HashMap, 438 | ) -> Result, Error> { 439 | let add_resource = move |bundle: &mut FluentBundle, 440 | langid: &LanguageIdentifier, 441 | locale_resources: &[LocaleResource]| { 442 | if let Some(&i) = locales.get(langid) { 443 | let resource = &locale_resources[i]; 444 | let resource = 445 | FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| { 446 | Error::FluentErrorsDetected(format!("resource langid: {}\n{:#?}", langid, e)) 447 | })?; 448 | bundle.add_resource_overriding(resource); 449 | }; 450 | Ok(()) 451 | }; 452 | 453 | let mut bundle = FluentBundle::new(vec![selected_language.clone()]); 454 | if let Some(fallback_language) = fallback_language { 455 | add_resource(&mut bundle, fallback_language, locale_resources)?; 456 | } 457 | 458 | let (language, script, region, variants) = selected_language.clone().into_parts(); 459 | let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants); 460 | let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]); 461 | let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]); 462 | let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]); 463 | 464 | add_resource(&mut bundle, &language_lang, locale_resources)?; 465 | add_resource(&mut bundle, &script_lang, locale_resources)?; 466 | add_resource(&mut bundle, ®ion_lang, locale_resources)?; 467 | add_resource(&mut bundle, &variants_lang, locale_resources)?; 468 | 469 | /* Add this code when the fluent crate includes FluentBundle::add_builtins. 470 | * This will allow the use of built-in functions like `NUMBER` and `DATETIME`. 471 | * See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information. 472 | bundle 473 | .add_builtins() 474 | .map_err(|e| Error::FluentErrorsDetected(e.to_string()))?; 475 | */ 476 | 477 | Ok(bundle) 478 | } 479 | 480 | pub fn i18n() -> I18n { 481 | consume_context() 482 | } 483 | 484 | #[cfg(test)] 485 | mod test { 486 | use super::*; 487 | use pretty_assertions::assert_eq; 488 | use unic_langid::langid; 489 | 490 | #[test] 491 | fn can_add_locale_to_config_explicit_locale() { 492 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 493 | const LANG_B: LanguageIdentifier = langid!("la-LB"); 494 | const LANG_C: LanguageIdentifier = langid!("la-LC"); 495 | 496 | let config = I18nConfig::new(LANG_A) 497 | .with_locale(Locale::new_static(LANG_B, "lang = lang_b")) 498 | .with_locale(Locale::new_dynamic(LANG_C, PathBuf::new())); 499 | 500 | assert_eq!( 501 | config, 502 | I18nConfig { 503 | id: LANG_A, 504 | fallback: None, 505 | locale_resources: vec![ 506 | LocaleResource::Static("lang = lang_b"), 507 | LocaleResource::Path(PathBuf::new()), 508 | ], 509 | locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]), 510 | } 511 | ); 512 | } 513 | 514 | #[test] 515 | fn can_add_locale_to_config_implicit_locale() { 516 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 517 | const LANG_B: LanguageIdentifier = langid!("la-LB"); 518 | const LANG_C: LanguageIdentifier = langid!("la-LC"); 519 | 520 | let config = I18nConfig::new(LANG_A) 521 | .with_locale((LANG_B, "lang = lang_b")) 522 | .with_locale((LANG_C, PathBuf::new())); 523 | 524 | assert_eq!( 525 | config, 526 | I18nConfig { 527 | id: LANG_A, 528 | fallback: None, 529 | locale_resources: vec![ 530 | LocaleResource::Static("lang = lang_b"), 531 | LocaleResource::Path(PathBuf::new()) 532 | ], 533 | locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]), 534 | } 535 | ); 536 | } 537 | 538 | #[test] 539 | fn can_add_locale_string_to_config() { 540 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 541 | const LANG_B: LanguageIdentifier = langid!("la-LB"); 542 | 543 | let config = I18nConfig::new(LANG_A).with_locale((LANG_B, "lang = lang_b")); 544 | 545 | assert_eq!( 546 | config, 547 | I18nConfig { 548 | id: LANG_A, 549 | fallback: None, 550 | locale_resources: vec![LocaleResource::Static("lang = lang_b")], 551 | locales: HashMap::from([(LANG_B, 0)]), 552 | } 553 | ); 554 | } 555 | 556 | #[test] 557 | fn can_add_shared_locale_string_to_config() { 558 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 559 | const LANG_B: LanguageIdentifier = langid!("la-LB"); 560 | const LANG_C: LanguageIdentifier = langid!("la-LC"); 561 | 562 | let shared_string = "lang = a language"; 563 | let config = I18nConfig::new(LANG_A) 564 | .with_locale((LANG_B, shared_string)) 565 | .with_locale((LANG_C, shared_string)); 566 | 567 | assert_eq!( 568 | config, 569 | I18nConfig { 570 | id: LANG_A, 571 | fallback: None, 572 | locale_resources: vec![LocaleResource::Static(shared_string)], 573 | locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]), 574 | } 575 | ); 576 | } 577 | 578 | #[test] 579 | fn can_add_locale_pathbuf_to_config() { 580 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 581 | const LANG_C: LanguageIdentifier = langid!("la-LC"); 582 | 583 | let config = I18nConfig::new(LANG_A) 584 | .with_locale((LANG_C, PathBuf::from("./test/data/fallback/la.ftl"))); 585 | 586 | assert_eq!( 587 | config, 588 | I18nConfig { 589 | id: LANG_A, 590 | fallback: None, 591 | locale_resources: vec![LocaleResource::Path(PathBuf::from( 592 | "./test/data/fallback/la.ftl" 593 | ))], 594 | locales: HashMap::from([(LANG_C, 0)]), 595 | } 596 | ); 597 | } 598 | 599 | #[test] 600 | fn can_add_shared_locale_pathbuf_to_config() { 601 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 602 | const LANG_B: LanguageIdentifier = langid!("la-LB"); 603 | const LANG_C: LanguageIdentifier = langid!("la-LC"); 604 | 605 | let shared_pathbuf = PathBuf::from("./test/data/fallback/la.ftl"); 606 | 607 | let config = I18nConfig::new(LANG_A) 608 | .with_locale((LANG_B, shared_pathbuf.clone())) 609 | .with_locale((LANG_C, shared_pathbuf.clone())); 610 | 611 | assert_eq!( 612 | config, 613 | I18nConfig { 614 | id: LANG_A, 615 | fallback: None, 616 | locale_resources: vec![LocaleResource::Path(shared_pathbuf)], 617 | locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]), 618 | } 619 | ); 620 | } 621 | 622 | #[test] 623 | fn can_auto_add_locales_folder_to_config() { 624 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 625 | 626 | let root_path_str = &format!("{}/tests/data/fallback/", env!("CARGO_MANIFEST_DIR")); 627 | let pathbuf = PathBuf::from(root_path_str); 628 | 629 | let config = I18nConfig::new(LANG_A) 630 | .try_with_auto_locales(pathbuf) 631 | .ok() 632 | .unwrap(); 633 | 634 | let expected_locales = [ 635 | "fb-FB", 636 | "la", 637 | "la-Scpt", 638 | "la-Scpt-LA", 639 | "la-Scpt-LA-variants", 640 | ]; 641 | 642 | assert_eq!(config.locales.len(), expected_locales.len()); 643 | assert_eq!(config.locale_resources.len(), expected_locales.len()); 644 | 645 | expected_locales.into_iter().for_each(|l| { 646 | let expected_filename = format!("{root_path_str}/{l}.ftl"); 647 | let id = LanguageIdentifier::from_bytes(l.as_bytes()).unwrap(); 648 | assert!(config.locales.get(&id).is_some()); 649 | assert!(config 650 | .locale_resources 651 | .contains(&LocaleResource::Path(PathBuf::from(expected_filename)))); 652 | }); 653 | } 654 | 655 | #[test] 656 | fn can_auto_add_locales_file_to_config() { 657 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 658 | 659 | let path_str = &format!( 660 | "{}/tests/data/fallback/fb-FB.ftl", 661 | env!("CARGO_MANIFEST_DIR") 662 | ); 663 | let pathbuf = PathBuf::from(path_str); 664 | 665 | let config = I18nConfig::new(LANG_A) 666 | .try_with_auto_locales(pathbuf.clone()) 667 | .ok() 668 | .unwrap(); 669 | 670 | assert_eq!(config.locales.len(), 1); 671 | assert!(config.locales.get(&langid!("fb-FB")).is_some()); 672 | 673 | assert_eq!(config.locale_resources.len(), 1); 674 | assert!(config 675 | .locale_resources 676 | .contains(&LocaleResource::Path(pathbuf))); 677 | } 678 | 679 | #[test] 680 | fn will_fail_auto_locales_with_invalid_folder() { 681 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 682 | 683 | let root_path_str = &format!("{}/non_existing_path/", env!("CARGO_MANIFEST_DIR")); 684 | let pathbuf = PathBuf::from(root_path_str); 685 | 686 | let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf); 687 | assert_eq!(config.is_err(), true); 688 | } 689 | 690 | #[test] 691 | fn will_fail_auto_locales_with_invalid_file() { 692 | const LANG_A: LanguageIdentifier = langid!("la-LA"); 693 | 694 | let path_str = &format!( 695 | "{}/tests/data/fallback/invalid_language_id.ftl", 696 | env!("CARGO_MANIFEST_DIR") 697 | ); 698 | let pathbuf = PathBuf::from(path_str); 699 | 700 | let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf); 701 | assert_eq!(config.is_err(), true); 702 | } 703 | } 704 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Note 2 | 3 | //***************************************************************************** 4 | // 5 | // This set of tests takes a heavy handed approach to errors, whereby the 6 | // process is exited. This is done because panic! and assert_eq! failures 7 | // are trapped within `dioxus::runtime::RuntimeGuard`. 8 | // Unfortunately panic! is still made silent. 9 | // 10 | // Errors will be shown with: 11 | // cargo test -- --nocapture 12 | // 13 | //***************************************************************************** 14 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | mod test_hook; 2 | 3 | pub(crate) use test_hook::test_hook; 4 | -------------------------------------------------------------------------------- /tests/common/test_hook.rs: -------------------------------------------------------------------------------- 1 | // Lifted from: https://dioxuslabs.com/learn/0.6/cookbook/testing 2 | // 3 | // Much curtialed functionality and massaged to use in the local testing 4 | // here. This hook isn't intended for reuse. 5 | // 6 | 7 | use dioxus::{dioxus_core::NoOpMutations, prelude::*}; 8 | use futures::FutureExt; 9 | 10 | use std::{cell::RefCell, fmt::Debug, rc::Rc}; 11 | 12 | pub(crate) fn test_hook( 13 | initialize: impl FnMut() -> V + 'static, 14 | check: impl FnMut(V, &mut Assertions) + 'static, 15 | ) { 16 | #[derive(Props)] 17 | struct MockAppComponent { 18 | hook: Rc>, 19 | check: Rc>, 20 | } 21 | 22 | impl PartialEq for MockAppComponent { 23 | fn eq(&self, _: &Self) -> bool { 24 | true 25 | } 26 | } 27 | 28 | impl Clone for MockAppComponent { 29 | fn clone(&self) -> Self { 30 | Self { 31 | hook: self.hook.clone(), 32 | check: self.check.clone(), 33 | } 34 | } 35 | } 36 | 37 | fn mock_app V, C: FnMut(V, &mut Assertions), V>( 38 | props: MockAppComponent, 39 | ) -> Element { 40 | let value = props.hook.borrow_mut()(); 41 | 42 | let mut assertions = Assertions::new(); 43 | 44 | props.check.borrow_mut()(value, &mut assertions); 45 | 46 | rsx! { div {} } 47 | } 48 | 49 | let mut vdom = VirtualDom::new_with_props( 50 | mock_app, 51 | MockAppComponent { 52 | hook: Rc::new(RefCell::new(initialize)), 53 | check: Rc::new(RefCell::new(check)), 54 | }, 55 | ); 56 | 57 | vdom.rebuild_in_place(); 58 | 59 | while vdom.wait_for_work().now_or_never().is_some() { 60 | vdom.render_immediate(&mut NoOpMutations); 61 | } 62 | 63 | vdom.in_runtime(|| ScopeId::ROOT.in_runtime(|| {})) 64 | } 65 | 66 | #[derive(Debug)] 67 | pub(crate) struct Assertions {} 68 | 69 | impl Assertions { 70 | pub fn new() -> Self { 71 | Self {} 72 | } 73 | 74 | pub fn assert(&mut self, actual: T, expected: T, id: &str) 75 | where 76 | T: PartialEq + Debug, 77 | { 78 | if actual != expected { 79 | eprintln!( 80 | "***** ERROR in {}: actual: '{:?}' != expected: '{:?}' *****\n", 81 | id, actual, expected 82 | ); 83 | std::process::exit(-1); 84 | }; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/data/fallback/fb-FB.ftl: -------------------------------------------------------------------------------- 1 | fallback = fallback only 2 | language = fallback language 3 | script = fallback script 4 | region = fallback region 5 | variants = fallback variants 6 | -------------------------------------------------------------------------------- /tests/data/fallback/la-Scpt-LA-variants.ftl: -------------------------------------------------------------------------------- 1 | variants = variants only 2 | -------------------------------------------------------------------------------- /tests/data/fallback/la-Scpt-LA.ftl: -------------------------------------------------------------------------------- 1 | region = region only 2 | variants = region variants 3 | -------------------------------------------------------------------------------- /tests/data/fallback/la-Scpt.ftl: -------------------------------------------------------------------------------- 1 | script = script only 2 | region = script region 3 | variants = script variants 4 | -------------------------------------------------------------------------------- /tests/data/fallback/la.ftl: -------------------------------------------------------------------------------- 1 | language = language only 2 | script = language script 3 | region = language region 4 | variants = language variants 5 | -------------------------------------------------------------------------------- /tests/data/i18n/en.ftl: -------------------------------------------------------------------------------- 1 | hello = Hello, {$name}! 2 | simple = Hello, Zaphod! 3 | my_component = My Component 4 | .placeholder = Component's placeholder 5 | .hint = Component's hint with parameter {$name} 6 | -------------------------------------------------------------------------------- /tests/defects_spec.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | use common::*; 3 | 4 | use dioxus_i18n::{ 5 | prelude::{use_init_i18n, I18n, I18nConfig}, 6 | t, 7 | }; 8 | use unic_langid::{langid, LanguageIdentifier}; 9 | 10 | #[test] 11 | fn issue_15_recent_change_to_t_macro_unnecessarily_breaks_v0_3_code_test_attr() { 12 | test_hook(i18n_from_static, |_, proxy| { 13 | let panic = std::panic::catch_unwind(|| { 14 | let name = "World"; 15 | t!(&format!("hello"), name: name) 16 | }); 17 | proxy.assert(panic.is_ok(), true, "translate_from_static_source"); 18 | proxy.assert( 19 | panic.ok().unwrap(), 20 | "Hello, \u{2068}World\u{2069}!".to_string(), 21 | "translate_from_static_source", 22 | ); 23 | }); 24 | } 25 | 26 | #[test] 27 | fn issue_15_recent_change_to_t_macro_unnecessarily_breaks_v0_3_code_test_no_attr() { 28 | test_hook(i18n_from_static, |_, proxy| { 29 | let panic = std::panic::catch_unwind(|| t!(&format!("simple"))); 30 | proxy.assert(panic.is_ok(), true, "translate_from_static_source"); 31 | proxy.assert( 32 | panic.ok().unwrap(), 33 | "Hello, Zaphod!".to_string(), 34 | "translate_from_static_source", 35 | ); 36 | }); 37 | } 38 | 39 | const EN: LanguageIdentifier = langid!("en"); 40 | 41 | fn i18n_from_static() -> I18n { 42 | let config = I18nConfig::new(EN).with_locale((EN, include_str!("./data/i18n/en.ftl"))); 43 | use_init_i18n(|| config) 44 | } 45 | -------------------------------------------------------------------------------- /tests/graceful_fallback_spec.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | use common::*; 3 | 4 | use dioxus_i18n::prelude::{use_init_i18n, I18n, I18nConfig}; 5 | use unic_langid::{langid, LanguageIdentifier}; 6 | 7 | #[test] 8 | fn exact_locale_match_will_use_translation() { 9 | test_hook(i18n, |value, proxy| { 10 | proxy.assert( 11 | value 12 | .try_translate("variants") 13 | .expect("test message id must exist"), 14 | "variants only".to_string(), 15 | "exact_locale_match_will_use_translation", 16 | ); 17 | }); 18 | } 19 | 20 | #[test] 21 | fn non_exact_locale_match_will_use_region() { 22 | test_hook(i18n, |value, proxy| { 23 | proxy.assert( 24 | value 25 | .try_translate("region") 26 | .expect("test message id must exist"), 27 | "region only".to_string(), 28 | "non_exact_locale_match_will_use_region", 29 | ); 30 | }); 31 | } 32 | 33 | #[test] 34 | fn non_exact_locale_match_will_use_script() { 35 | test_hook(i18n, |value, proxy| { 36 | proxy.assert( 37 | value 38 | .try_translate("script") 39 | .expect("test message id must exist"), 40 | "script only".to_string(), 41 | "non_exact_locale_match_will_use_script", 42 | ); 43 | }); 44 | } 45 | 46 | #[test] 47 | fn non_exact_locale_match_will_use_language() { 48 | test_hook(i18n, |value, proxy| { 49 | proxy.assert( 50 | value 51 | .try_translate("language") 52 | .expect("test message id must exist"), 53 | "language only".to_string(), 54 | "non_exact_locale_match_will_use_language", 55 | ); 56 | }); 57 | } 58 | 59 | #[test] 60 | fn no_locale_match_will_use_fallback() { 61 | test_hook(i18n, |value, proxy| { 62 | proxy.assert( 63 | value 64 | .try_translate("fallback") 65 | .expect("test message id must exist"), 66 | "fallback only".to_string(), 67 | "no_locale_match_will_use_fallback", 68 | ); 69 | }); 70 | } 71 | 72 | fn i18n() -> I18n { 73 | const FALLBACK_LANG: LanguageIdentifier = langid!("fb-FB"); 74 | const LANGUAGE_LANG: LanguageIdentifier = langid!("la"); 75 | const SCRIPT_LANG: LanguageIdentifier = langid!("la-Scpt"); 76 | const REGION_LANG: LanguageIdentifier = langid!("la-Scpt-LA"); 77 | let variants_lang: LanguageIdentifier = langid!("la-Scpt-LA-variants"); 78 | 79 | let config = I18nConfig::new(variants_lang.clone()) 80 | .with_locale((LANGUAGE_LANG, include_str!("../tests/data/fallback/la.ftl"))) 81 | .with_locale(( 82 | SCRIPT_LANG, 83 | include_str!("../tests/data/fallback/la-Scpt.ftl"), 84 | )) 85 | .with_locale(( 86 | REGION_LANG, 87 | include_str!("../tests/data/fallback/la-Scpt-LA.ftl"), 88 | )) 89 | .with_locale(( 90 | variants_lang.clone(), 91 | include_str!("../tests/data/fallback/la-Scpt-LA-variants.ftl"), 92 | )) 93 | .with_locale(( 94 | FALLBACK_LANG, 95 | include_str!("../tests/data/fallback/fb-FB.ftl"), 96 | )) 97 | .with_fallback(FALLBACK_LANG); 98 | use_init_i18n(|| config) 99 | } 100 | -------------------------------------------------------------------------------- /tests/translations_spec.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | use common::*; 3 | 4 | use dioxus_i18n::{ 5 | prelude::{use_init_i18n, I18n, I18nConfig}, 6 | t, te, tid, 7 | }; 8 | use unic_langid::{langid, LanguageIdentifier}; 9 | 10 | use std::path::PathBuf; 11 | 12 | #[test] 13 | fn translate_from_static_source() { 14 | test_hook(i18n_from_static, |_, proxy| { 15 | let panic = std::panic::catch_unwind(|| { 16 | let name = "World"; 17 | t!("hello", name: name) 18 | }); 19 | proxy.assert(panic.is_ok(), true, "translate_from_static_source"); 20 | proxy.assert( 21 | panic.ok().unwrap(), 22 | "Hello, \u{2068}World\u{2069}!".to_string(), 23 | "translate_from_static_source", 24 | ); 25 | }); 26 | } 27 | 28 | #[test] 29 | fn failed_to_translate_with_invalid_key() { 30 | test_hook(i18n_from_static, |_, proxy| { 31 | let panic = std::panic::catch_unwind(|| { 32 | let _ = &t!("invalid"); 33 | }); 34 | proxy.assert(panic.is_err(), true, "failed_to_translate_with_invalid_key"); 35 | }); 36 | } 37 | 38 | #[test] 39 | fn failed_to_translate_with_invalid_key_as_error() { 40 | test_hook(i18n_from_static, |_, proxy| { 41 | let panic = std::panic::catch_unwind(|| te!("invalid")); 42 | proxy.assert( 43 | panic.is_ok(), 44 | true, 45 | "failed_to_translate_with_invalid_key_as_error", 46 | ); 47 | proxy.assert( 48 | panic.ok().unwrap().err().unwrap().to_string(), 49 | "message id not found for key: 'invalid'".to_string(), 50 | "failed_to_translate_with_invalid_key_as_error", 51 | ); 52 | }); 53 | } 54 | 55 | #[test] 56 | fn failed_to_translate_with_invalid_key_with_args_as_error() { 57 | test_hook(i18n_from_static, |_, proxy| { 58 | let panic = std::panic::catch_unwind(|| te!("invalid", name: "")); 59 | proxy.assert( 60 | panic.is_ok(), 61 | true, 62 | "failed_to_translate_with_invalid_key_with_args_as_error", 63 | ); 64 | proxy.assert( 65 | panic.ok().unwrap().err().unwrap().to_string(), 66 | "message id not found for key: 'invalid'".to_string(), 67 | "failed_to_translate_with_invalid_key_with_args_as_error", 68 | ); 69 | }); 70 | } 71 | 72 | #[test] 73 | fn failed_to_translate_with_invalid_key_as_id() { 74 | test_hook(i18n_from_static, |_, proxy| { 75 | let panic = std::panic::catch_unwind(|| tid!("invalid")); 76 | proxy.assert( 77 | panic.is_ok(), 78 | true, 79 | "failed_to_translate_with_invalid_key_as_id", 80 | ); 81 | proxy.assert( 82 | panic.ok().unwrap(), 83 | "message id not found for key: 'invalid'".to_string(), 84 | "failed_to_translate_with_invalid_key_as_id", 85 | ); 86 | }); 87 | } 88 | 89 | #[test] 90 | fn failed_to_translate_with_invalid_key_with_args_as_id() { 91 | test_hook(i18n_from_static, |_, proxy| { 92 | let panic = std::panic::catch_unwind(|| tid!("invalid", name: "")); 93 | proxy.assert( 94 | panic.is_ok(), 95 | true, 96 | "failed_to_translate_with_invalid_key_with_args_as_id", 97 | ); 98 | proxy.assert( 99 | panic.ok().unwrap(), 100 | "message id not found for key: 'invalid'".to_string(), 101 | "failed_to_translate_with_invalid_key_with_args_as_id", 102 | ); 103 | }); 104 | } 105 | 106 | #[test] 107 | fn translate_root_message_in_attributed_definition() { 108 | test_hook(i18n_from_static, |_, proxy| { 109 | let panic = std::panic::catch_unwind(|| tid!("my_component")); 110 | proxy.assert( 111 | panic.is_ok(), 112 | true, 113 | "translate_root_message_in_attributed_definition", 114 | ); 115 | proxy.assert( 116 | panic.ok().unwrap(), 117 | "My Component".to_string(), 118 | "translate_root_message_in_attributed_definition", 119 | ); 120 | }); 121 | } 122 | 123 | #[test] 124 | fn translate_attribute_with_no_args_in_attributed_definition() { 125 | test_hook(i18n_from_static, |_, proxy| { 126 | let panic = std::panic::catch_unwind(|| tid!("my_component.placeholder")); 127 | proxy.assert( 128 | panic.is_ok(), 129 | true, 130 | "translate_attribute_with_no_args_in_attributed_definition", 131 | ); 132 | proxy.assert( 133 | panic.ok().unwrap(), 134 | "Component's placeholder".to_string(), 135 | "translate_attribute_with_no_args_in_attributed_definition", 136 | ); 137 | }); 138 | } 139 | 140 | #[test] 141 | fn translate_attribute_with_args_in_attributed_definition() { 142 | test_hook(i18n_from_static, |_, proxy| { 143 | let panic = std::panic::catch_unwind(|| tid!("my_component.hint", name: "Zaphod")); 144 | proxy.assert( 145 | panic.is_ok(), 146 | true, 147 | "translate_attribute_with_args_in_attributed_definition", 148 | ); 149 | proxy.assert( 150 | panic.ok().unwrap(), 151 | "Component's hint with parameter \u{2068}Zaphod\u{2069}".to_string(), 152 | "translate_attribute_with_args_in_attributed_definition", 153 | ); 154 | }); 155 | } 156 | 157 | #[test] 158 | fn fail_translate_invalid_attribute_with_no_args_in_attributed_definition() { 159 | test_hook(i18n_from_static, |_, proxy| { 160 | let panic = std::panic::catch_unwind(|| tid!("my_component.not_a_placeholder")); 161 | proxy.assert( 162 | panic.is_ok(), 163 | true, 164 | "fail_translate_invalid_attribute_with_no_args_in_attributed_definition", 165 | ); 166 | proxy.assert( 167 | panic.ok().unwrap(), 168 | "attribute id not found for key: 'my_component.not_a_placeholder'".to_string(), 169 | "fail_translate_invalid_attribute_with_no_args_in_attributed_definition", 170 | ); 171 | }); 172 | } 173 | 174 | #[test] 175 | fn fail_translate_invalid_attribute_with_args_in_attributed_definition() { 176 | test_hook(i18n_from_static, |_, proxy| { 177 | let panic = std::panic::catch_unwind(|| tid!("my_component.not_a_hint", name: "Zaphod")); 178 | proxy.assert( 179 | panic.is_ok(), 180 | true, 181 | "fail_translate_invalid_attribute_with_args_in_attributed_definition", 182 | ); 183 | proxy.assert( 184 | panic.ok().unwrap(), 185 | "attribute id not found for key: 'my_component.not_a_hint'".to_string(), 186 | "fail_translate_invalid_attribute_with_args_in_attributed_definition", 187 | ); 188 | }); 189 | } 190 | 191 | #[test] 192 | fn fail_translate_with_invalid_attribute_key() { 193 | test_hook(i18n_from_static, |_, proxy| { 194 | let panic = std::panic::catch_unwind(|| tid!("my_component.placeholder.invalid")); 195 | proxy.assert( 196 | panic.is_ok(), 197 | true, 198 | "fail_translate_with_invalid_attribute_key", 199 | ); 200 | proxy.assert( 201 | panic.ok().unwrap(), 202 | "invalid message id: 'my_component.placeholder.invalid'".to_string(), 203 | "fail_translate_with_invalid_attribute_key", 204 | ); 205 | }); 206 | } 207 | 208 | #[test] 209 | fn translate_from_dynamic_source() { 210 | test_hook(i18n_from_dynamic, |_, proxy| { 211 | let panic = std::panic::catch_unwind(|| { 212 | let name = "World"; 213 | t!("hello", name: name) 214 | }); 215 | proxy.assert(panic.is_ok(), true, "translate_from_dynamic_source"); 216 | proxy.assert( 217 | panic.ok().unwrap(), 218 | "Hello, \u{2068}World\u{2069}!".to_string(), 219 | "translate_from_dynamic_source", 220 | ); 221 | }); 222 | } 223 | 224 | #[test] 225 | #[should_panic] 226 | #[ignore] // Panic hidden within test_hook. 227 | fn fail_translate_from_dynamic_source_when_file_does_not_exist() { 228 | test_hook(i18n_from_dynamic_none_existing, |_, _| unreachable!()); 229 | } 230 | 231 | #[test] 232 | fn initial_language_is_set() { 233 | test_hook(i18n_from_static, |value, proxy| { 234 | proxy.assert(value.language(), EN, "initial_language_is_set"); 235 | }); 236 | } 237 | 238 | #[test] 239 | fn language_can_be_set() { 240 | test_hook(i18n_from_static, |mut value, proxy| { 241 | value 242 | .try_set_language(JP) 243 | .expect("set_language must succeed"); 244 | proxy.assert(value.language(), JP, "language_can_be_set"); 245 | }); 246 | } 247 | 248 | #[test] 249 | fn no_default_fallback_language() { 250 | test_hook(i18n_from_static, |value, proxy| { 251 | proxy.assert( 252 | format!("{:?}", value.fallback_language()), 253 | "None".to_string(), 254 | "no_default_fallback_language", 255 | ); 256 | }); 257 | } 258 | 259 | #[test] 260 | fn some_default_fallback_language() { 261 | test_hook(i18n_from_static_with_fallback, |value, proxy| { 262 | proxy.assert( 263 | format!("{:?}", value.fallback_language().map(|l| l.to_string())), 264 | "Some(\"jp\")".to_string(), 265 | "some_default_fallback_language", 266 | ); 267 | }); 268 | } 269 | 270 | #[test] 271 | fn fallback_language_can_be_set() { 272 | test_hook(i18n_from_static_with_fallback, |mut value, proxy| { 273 | value 274 | .try_set_fallback_language(EN) 275 | .expect("try_set_fallback_language must succeed"); 276 | proxy.assert( 277 | format!("{:?}", value.fallback_language().map(|l| l.to_string())), 278 | "Some(\"en\")".to_string(), 279 | "fallback_language_can_be_set", 280 | ); 281 | }); 282 | } 283 | 284 | #[test] 285 | fn fallback_language_must_have_locale_translation() { 286 | test_hook(i18n_from_static_with_fallback, |mut value, proxy| { 287 | let result = value.try_set_fallback_language(IT); 288 | 289 | proxy.assert( 290 | result.is_err(), 291 | true, 292 | "fallback_language_must_have_locale_translation", 293 | ); 294 | proxy.assert( 295 | result.err().unwrap().to_string(), 296 | "fallback for \"it\" must have locale".to_string(), 297 | "fallback_language_must_have_locale_translation", 298 | ); 299 | proxy.assert( 300 | format!("{:?}", value.fallback_language().map(|l| l.to_string())), 301 | "Some(\"jp\")".to_string(), 302 | "fallback_language_must_have_locale_translation", 303 | ); 304 | }); 305 | } 306 | 307 | const EN: LanguageIdentifier = langid!("en"); 308 | const IT: LanguageIdentifier = langid!("it"); 309 | const JP: LanguageIdentifier = langid!("jp"); 310 | 311 | fn i18n_from_static() -> I18n { 312 | let config = I18nConfig::new(EN).with_locale((EN, include_str!("./data/i18n/en.ftl"))); 313 | use_init_i18n(|| config) 314 | } 315 | 316 | fn i18n_from_static_with_fallback() -> I18n { 317 | let config = I18nConfig::new(EN) 318 | .with_locale((EN, include_str!("./data/i18n/en.ftl"))) 319 | .with_fallback(JP); 320 | use_init_i18n(|| config) 321 | } 322 | 323 | fn i18n_from_dynamic() -> I18n { 324 | let config = I18nConfig::new(EN).with_locale(( 325 | EN, 326 | PathBuf::from(format!( 327 | "{}/tests/data/i18n/en.ftl", 328 | env!("CARGO_MANIFEST_DIR") 329 | )), 330 | )); 331 | use_init_i18n(|| config) 332 | } 333 | 334 | fn i18n_from_dynamic_none_existing() -> I18n { 335 | let config = I18nConfig::new(EN).with_locale(( 336 | EN, 337 | PathBuf::from(format!( 338 | "{}/tests/data/i18n/non_existing.ftl", 339 | env!("CARGO_MANIFEST_DIR") 340 | )), 341 | )); 342 | use_init_i18n(|| config) 343 | } 344 | --------------------------------------------------------------------------------