From bf4bc0cdf1962fa70bf5a7fce896254d086a4529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Mond=C3=A9jar=20Rubio?= Date: Mon, 20 May 2024 01:19:49 +0200 Subject: [PATCH] API refactor, bug fixes and enhancements (#62) --- .github/workflows/ci.yml | 28 +++--- CHANGELOG.md | 22 +++++ CONTRIBUTING.md | 9 +- Cargo.lock | 21 +++-- Cargo.toml | 3 +- README.md | 13 +-- {tests => end2end}/Cargo.toml | 7 +- end2end/tests-helpers/Cargo.toml | 10 +++ end2end/tests-helpers/src/lib.rs | 93 +++++++++++++++++++ end2end/tests/csr_complete.rs | 54 +++++++++++ end2end/tests/csr_minimal.rs | 37 ++++++++ examples/csr-complete/Cargo.toml | 2 +- examples/csr-complete/src/lib.rs | 11 +-- examples/csr-complete/src/main.rs | 4 +- examples/csr-minimal/Cargo.toml | 2 +- examples/csr-minimal/src/lib.rs | 10 +-- examples/csr-minimal/src/main.rs | 4 +- examples/ssr-hydrate-actix/Cargo.toml | 1 - examples/ssr-hydrate-actix/src/app.rs | 11 +-- examples/ssr-hydrate-axum/Cargo.toml | 1 - examples/ssr-hydrate-axum/src/app.rs | 11 +-- leptos-fluent-macros/Cargo.toml | 6 +- leptos-fluent-macros/src/lib.rs | 110 +++++++++++++++++------ leptos-fluent/Cargo.toml | 30 ++----- leptos-fluent/README.md | 13 +-- leptos-fluent/src/lib.rs | 67 +++++++++++--- leptos-fluent/src/localstorage.rs | 4 +- leptos-fluent/src/url.rs | 2 +- tests/src/lib.rs | 1 - tests/src/web.rs | 124 -------------------------- 30 files changed, 445 insertions(+), 266 deletions(-) rename {tests => end2end}/Cargo.toml (75%) create mode 100644 end2end/tests-helpers/Cargo.toml create mode 100644 end2end/tests-helpers/src/lib.rs create mode 100644 end2end/tests/csr_complete.rs create mode 100644 end2end/tests/csr_minimal.rs delete mode 100644 tests/src/lib.rs delete mode 100644 tests/src/web.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13ff5425..0d94cb1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,9 +74,9 @@ jobs: ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ matrix.browser }}-${{ hashFiles('**/Cargo.toml') }}-${{ hashFiles('**/Cargo.lock') }} - - name: Run tests + - name: Run end to end tests run: | - cd tests + cd end2end wasm-pack test --headless --${{ matrix.browser }} build-example: @@ -85,15 +85,11 @@ jobs: strategy: fail-fast: false matrix: - include: - - example: csr-minimal - feature: csr - - example: csr-complete - feature: csr - - example: ssr-hydrate-actix - feature: ssr - - example: ssr-hydrate-axum - feature: ssr + example: + - csr-minimal + - csr-complete + - ssr-hydrate-actix + - ssr-hydrate-axum steps: - uses: actions/checkout@v4 - name: Setup Rust @@ -106,12 +102,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install trunk - if: ${{ contains(matrix.feature, 'csr') }} + if: ${{ startsWith(matrix.example, 'csr') }} run: cargo binstall -y trunk env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install cargo-leptos - if: ${{ matrix.feature == 'ssr' }} + if: ${{ startsWith(matrix.example, 'ssr') }} run: cargo binstall -y cargo-leptos env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -122,14 +118,14 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ matrix.example }}-${{ matrix.feature }}-${{ hashFiles('**/Cargo.toml') }}-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.example }}-${{ hashFiles('**/Cargo.toml') }}-${{ hashFiles('**/Cargo.lock') }} - name: Build with trunk - if: ${{ matrix.feature == 'csr' }} + if: ${{ startsWith(matrix.example, 'csr') }} run: | cd examples/${{ matrix.example }} trunk build --release - name: Build with cargo-leptos - if: ${{ matrix.feature == 'ssr' }} + if: ${{ startsWith(matrix.example, 'ssr') }} run: | cd examples/${{ matrix.example }} cargo leptos build --release diff --git a/CHANGELOG.md b/CHANGELOG.md index b532abe7..02ec4946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # CHANGELOG +## 2024-05-20 - [0.0.24] + +### Enhancements + +- Add `I18n.is_active_language` method. +- Add `I18n.language_key` method to return a hash for the current + language with their active status for usage in `For` components. +- Add `set_to_localstorage` parameter to `leptos_fluent!` macro. +- Add `use_i18n` and `expect_i18n` function. + +### Breaking changes + +- Replace `I18n.set_language_with_localstorage` method with + `I18n.set_language`. Use `set_to_localstorage` macro parameter + and `I18n.set_language` instead. +- Remove `csr` feature. + +### Bug fixes + +- Fix errors getting initial language from URL parameter and local storage. + ## 2024-05-18 - [0.0.23] - Add `axum` feature to integrate with Axum web framework. @@ -44,6 +65,7 @@ - Added all ISO-639-1 and ISO-639-2 languages. +[0.0.24]: https://github.com/mondeja/leptos-fluent/compare/v0.0.23...v0.0.24 [0.0.23]: https://github.com/mondeja/leptos-fluent/compare/v0.0.22...v0.0.23 [0.0.22]: https://github.com/mondeja/leptos-fluent/compare/v0.0.21...v0.0.22 [0.0.21]: https://github.com/mondeja/leptos-fluent/compare/v0.0.20...v0.0.21 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2738b4a8..0238e9bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,10 +21,17 @@ wasm-pack test --{browser} --headless Where `{browser}` is one of `firefox`, `chrome`, or `safari`. For example: ```sh -cd tests +cd end2end wasm-pack test --firefox --headless ``` +If you want to run a test suite: + +```sh +cd end2end +wasm-pack test --firefox --headless --test csr_minimal +``` + ## Documentation ```sh diff --git a/Cargo.lock b/Cargo.lock index e1d44436..f686aca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1428,10 +1428,8 @@ dependencies = [ [[package]] name = "leptos-fluent" -version = "0.0.23" +version = "0.0.24" dependencies = [ - "actix-web", - "axum", "cfg-if", "fluent-templates", "leptos", @@ -1464,14 +1462,13 @@ dependencies = [ [[package]] name = "leptos-fluent-macros" -version = "0.0.23" +version = "0.0.24" dependencies = [ "cfg-if", "proc-macro2", "quote", "serde_json", "syn 2.0.64", - "web-sys", ] [[package]] @@ -1514,13 +1511,11 @@ dependencies = [ name = "leptos-fluent-tests" version = "0.1.0" dependencies = [ - "js-sys", "leptos", "leptos-fluent-csr-complete-example", "leptos-fluent-csr-minimal-example", - "wasm-bindgen-futures", + "tests-helpers", "wasm-bindgen-test", - "web-sys", ] [[package]] @@ -2586,6 +2581,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +[[package]] +name = "tests-helpers" +version = "0.1.0" +dependencies = [ + "js-sys", + "leptos", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "thiserror" version = "1.0.61" diff --git a/Cargo.toml b/Cargo.toml index b16513b4..b869f07e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "leptos-fluent", "leptos-fluent-macros", - "tests", + "end2end", + "end2end/tests-helpers", "examples/csr-complete", "examples/csr-minimal", "examples/ssr-hydrate-actix", diff --git a/README.md b/README.md index 19c7cd19..12027616 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ Add the following to your `Cargo.toml` file: ```toml [dependencies] -leptos-fluent = "0.0.21" +leptos-fluent = "0.0.24" fluent-templates = "0.9" [features] -csr = ["leptos-fluent/csr"] -hydrate = ["leptos-fluent/hydrate"] +hydrate = [ + "leptos-fluent/hydrate" +] ssr = [ "leptos-fluent/ssr", "leptos-fluent/actix", # actix and axum are supported @@ -101,6 +102,9 @@ pub fn App() -> impl IntoView { // Get the initial language from `navigator.languages` if not // found in the local storage. By default, it is `false`. initial_language_from_navigator: true, + // Set the language to local storage when the user changes it. + // By default, it is `false`. + set_to_localstorage: true, // Name of the field in local storage to get and set the // current language of the user. By default, it is `"lang"`. localstorage_key: "language", @@ -134,8 +138,7 @@ fn ChildComponent() -> impl IntoView { ### Features -- **Client side rendering (CSR)**: Use the `leptos-fluent/csr` feature. -- **Server side rendering (SSR)**: Use the `leptos-fluent/ssr` feature. +- **Server Side Rendering**: Use the `leptos-fluent/ssr` feature. - **Hydration**: Use the `leptos-fluent/hydrate` feature. - **Actix Web integration**: Use the `leptos-fluent/actix` feature. - **Axum integration**: Use the `leptos-fluent/axum` feature. diff --git a/tests/Cargo.toml b/end2end/Cargo.toml similarity index 75% rename from tests/Cargo.toml rename to end2end/Cargo.toml index a008df49..d1ae2fe9 100644 --- a/tests/Cargo.toml +++ b/end2end/Cargo.toml @@ -3,14 +3,9 @@ name = "leptos-fluent-tests" edition = "2021" version = "0.1.0" -[lib] -crate-type = ["cdylib"] - [dependencies] +tests-helpers = { path = "./tests-helpers" } leptos-fluent-csr-minimal-example = { path = "../examples/csr-minimal" } leptos-fluent-csr-complete-example = { path = "../examples/csr-complete" } wasm-bindgen-test = "0.3" leptos = "0.6" -js-sys = "0.3" -web-sys = "0.3" -wasm-bindgen-futures = "0.4" diff --git a/end2end/tests-helpers/Cargo.toml b/end2end/tests-helpers/Cargo.toml new file mode 100644 index 00000000..b02ff004 --- /dev/null +++ b/end2end/tests-helpers/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tests-helpers" +edition = "2021" +version = "0.1.0" + +[dependencies] +leptos = "0.6" +web-sys = { version = "0.3", features = ["Navigator", "Storage"] } +js-sys = "0.3" +wasm-bindgen-futures = "0.4" diff --git a/end2end/tests-helpers/src/lib.rs b/end2end/tests-helpers/src/lib.rs new file mode 100644 index 00000000..f2981aec --- /dev/null +++ b/end2end/tests-helpers/src/lib.rs @@ -0,0 +1,93 @@ +#[macro_export] +macro_rules! mount { + ($app:ident) => {{ + ::leptos::mount_to_body( + move || ::leptos::view! {
<$app/>
}, + ); + }}; +} + +#[macro_export] +macro_rules! unmount { + () => {{ + use leptos::wasm_bindgen::JsCast; + ::leptos::document() + .body() + .unwrap() + .remove_child( + ::leptos::document() + .get_element_by_id("wrapper") + .unwrap() + .unchecked_ref(), + ) + .unwrap(); + }}; +} + +pub async fn sleep(delay: i32) { + let mut cb = |resolve: js_sys::Function, _reject: js_sys::Function| { + ::web_sys::window() + .unwrap() + .set_timeout_with_callback_and_timeout_and_arguments_0( + &resolve, delay, + ) + .unwrap(); + }; + + let p = ::js_sys::Promise::new(&mut cb); + ::wasm_bindgen_futures::JsFuture::from(p).await.unwrap(); +} + +pub fn element_text(selector: &str) -> String { + ::leptos::document() + .query_selector(selector) + .unwrap() + .unwrap() + .text_content() + .unwrap() +} + +pub fn input_by_id(id: &str) -> web_sys::HtmlInputElement { + use leptos::wasm_bindgen::JsCast; + ::leptos::document() + .get_element_by_id(id) + .unwrap() + .unchecked_into::() +} + +pub fn html() -> web_sys::HtmlHtmlElement { + use leptos::wasm_bindgen::JsCast; + ::leptos::document() + .document_element() + .unwrap() + .unchecked_into::() +} + +pub mod localstorage { + pub fn delete(key: &str) { + ::leptos::window() + .local_storage() + .unwrap() + .unwrap() + .remove_item(key) + .unwrap(); + } + + pub fn set(key: &str, value: &str) { + ::leptos::window() + .local_storage() + .unwrap() + .unwrap() + .set_item(key, value) + .unwrap(); + } + + pub fn get(key: &str) -> Option { + ::leptos::window() + .local_storage() + .unwrap() + .unwrap() + .get_item(key) + .unwrap() + } +} diff --git a/end2end/tests/csr_complete.rs b/end2end/tests/csr_complete.rs new file mode 100644 index 00000000..efc98c97 --- /dev/null +++ b/end2end/tests/csr_complete.rs @@ -0,0 +1,54 @@ +use tests_helpers::{ + element_text, html, input_by_id, localstorage, mount, sleep, unmount, +}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn csr_complete_example() { + use leptos_fluent_csr_complete_example::App as CompleteExampleApp; + localstorage::delete("language"); + + mount!(CompleteExampleApp); + let es = move || input_by_id("es"); + let en = move || input_by_id("en"); + + // translations working + assert!(en().checked()); + assert!(!es().checked()); + assert_eq!(element_text("p"), "Select a language:"); + es().click(); + assert!(es().checked()); + assert!(!en().checked()); + assert_eq!(element_text("p"), "Selecciona un idioma:"); + en().click(); + assert!(en().checked()); + assert_eq!(element_text("p"), "Select a language:"); + assert!(!es().checked()); + + // sync_html_tag_lang + es().click(); + sleep(30).await; + assert!(es().checked()); + assert_eq!(html().lang(), "es".to_string()); + en().click(); + sleep(30).await; + assert_eq!(html().lang(), "en".to_string()); + + // set_to_localstorage + localstorage::delete("language"); + assert_eq!(localstorage::get("language"), None); + es().click(); + assert_eq!(localstorage::get("language"), Some("es".to_string())); + en().click(); + assert_eq!(localstorage::get("language"), Some("en".to_string())); + + // TODO: + // initial_language_from_url + // initial_language_from_url_to_localstorage + // initial_language_from_localstorage + // initial_language_from_navigator + + unmount!(); +} diff --git a/end2end/tests/csr_minimal.rs b/end2end/tests/csr_minimal.rs new file mode 100644 index 00000000..f3125b70 --- /dev/null +++ b/end2end/tests/csr_minimal.rs @@ -0,0 +1,37 @@ +use tests_helpers::{ + element_text, html, input_by_id, localstorage, mount, sleep, unmount, +}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn csr_minimal_example() { + use leptos_fluent_csr_minimal_example::App as MinimalExampleApp; + mount!(MinimalExampleApp); + let es = move || input_by_id("es"); + let en = move || input_by_id("en"); + + // localstorage not activated + localstorage::set("language", "es"); + + // translations working + assert_eq!(element_text("p"), "Select a language:"); + es().click(); + assert!(es().checked()); + assert!(!en().checked()); + assert_eq!(element_text("p"), "Selecciona un idioma:"); + + // language change not reflected in html tag + sleep(30).await; + html().remove_attribute("lang").unwrap(); + assert_eq!(html().lang(), "".to_string()); + es().click(); + assert!(es().checked()); + assert_eq!(html().lang(), "".to_string()); + en().click(); + assert!(en().checked()); + assert_eq!(html().lang(), "".to_string()); + + unmount!(); +} diff --git a/examples/csr-complete/Cargo.toml b/examples/csr-complete/Cargo.toml index 3da9cba9..62c6581f 100644 --- a/examples/csr-complete/Cargo.toml +++ b/examples/csr-complete/Cargo.toml @@ -9,7 +9,7 @@ path = "src/lib.rs" [dependencies] leptos = {version = "0.6", features = ["csr"]} -leptos-fluent = { path = "../../leptos-fluent", features = ["csr"] } +leptos-fluent = { path = "../../leptos-fluent" } fluent-templates = "0.9" console_error_panic_hook = "0" web-sys = "0" diff --git a/examples/csr-complete/src/lib.rs b/examples/csr-complete/src/lib.rs index 0347fc20..1e1c3468 100644 --- a/examples/csr-complete/src/lib.rs +++ b/examples/csr-complete/src/lib.rs @@ -1,6 +1,6 @@ use fluent_templates::static_loader; use leptos::*; -use leptos_fluent::{i18n, leptos_fluent, move_tr, Language}; +use leptos_fluent::{expect_i18n, leptos_fluent, move_tr, Language}; static_loader! { static TRANSLATIONS = { @@ -20,6 +20,7 @@ pub fn App() -> impl IntoView { initial_language_from_url_to_localstorage: true, initial_language_from_localstorage: true, initial_language_from_navigator: true, + set_to_localstorage: true, localstorage_key: "language", }}; @@ -28,14 +29,14 @@ pub fn App() -> impl IntoView { #[component] fn ChildComponent() -> impl IntoView { - let i18n = i18n(); + let i18n = expect_i18n(); view! {

{move_tr!("select-a-language")}

@@ -44,8 +45,8 @@ fn ChildComponent() -> impl IntoView { id=lang.id.to_string() name="language" value=lang.id.to_string() - checked=*lang == i18n.language.get() - on:click=move |_| i18n.set_language_with_localstorage(lang) + checked=i18n.is_active_language(lang) + on:click=move |_| i18n.set_language(lang) /> diff --git a/examples/csr-complete/src/main.rs b/examples/csr-complete/src/main.rs index 5e894a67..cc810413 100644 --- a/examples/csr-complete/src/main.rs +++ b/examples/csr-complete/src/main.rs @@ -3,7 +3,5 @@ use leptos_fluent_csr_complete_example::App; pub fn main() { console_error_panic_hook::set_once(); - mount_to_body(|| { - view! { } - }) + mount_to_body(App); } diff --git a/examples/csr-minimal/Cargo.toml b/examples/csr-minimal/Cargo.toml index 9c1f26c7..81012e9d 100644 --- a/examples/csr-minimal/Cargo.toml +++ b/examples/csr-minimal/Cargo.toml @@ -9,7 +9,7 @@ path = "src/lib.rs" [dependencies] leptos = {version = "0.6.9", features = ["csr"]} -leptos-fluent = { path = "../../leptos-fluent", features = ["csr"] } +leptos-fluent = { path = "../../leptos-fluent" } fluent-templates = "0.9" console_error_panic_hook = "0" diff --git a/examples/csr-minimal/src/lib.rs b/examples/csr-minimal/src/lib.rs index e0a56dbc..6eb5d76c 100644 --- a/examples/csr-minimal/src/lib.rs +++ b/examples/csr-minimal/src/lib.rs @@ -1,6 +1,6 @@ use fluent_templates::static_loader; use leptos::*; -use leptos_fluent::{i18n, leptos_fluent, move_tr, Language}; +use leptos_fluent::{expect_i18n, leptos_fluent, move_tr, Language}; static_loader! { static TRANSLATIONS = { @@ -21,14 +21,14 @@ pub fn App() -> impl IntoView { #[component] fn ChildComponent() -> impl IntoView { - let i18n = i18n(); + let i18n = expect_i18n(); view! {

{move_tr!("select-a-language")}

@@ -37,8 +37,8 @@ fn ChildComponent() -> impl IntoView { id=lang.id.to_string() name="language" value=lang.id.to_string() - checked=*lang == i18n.language.get() - on:click=move |_| i18n.language.set(lang) + checked=i18n.is_active_language(lang) + on:click=move |_| i18n.set_language(lang) /> diff --git a/examples/csr-minimal/src/main.rs b/examples/csr-minimal/src/main.rs index 167a680f..0863fa99 100644 --- a/examples/csr-minimal/src/main.rs +++ b/examples/csr-minimal/src/main.rs @@ -3,7 +3,5 @@ use leptos_fluent_csr_minimal_example::App; pub fn main() { console_error_panic_hook::set_once(); - mount_to_body(|| { - view! { } - }) + mount_to_body(App); } diff --git a/examples/ssr-hydrate-actix/Cargo.toml b/examples/ssr-hydrate-actix/Cargo.toml index 8df8742a..34e2bbd5 100644 --- a/examples/ssr-hydrate-actix/Cargo.toml +++ b/examples/ssr-hydrate-actix/Cargo.toml @@ -23,7 +23,6 @@ csr = [ "leptos/csr", "leptos_meta/csr", "leptos_router/csr", - "leptos-fluent/csr", ] hydrate = [ "leptos/hydrate", diff --git a/examples/ssr-hydrate-actix/src/app.rs b/examples/ssr-hydrate-actix/src/app.rs index 6dac0382..b2433165 100644 --- a/examples/ssr-hydrate-actix/src/app.rs +++ b/examples/ssr-hydrate-actix/src/app.rs @@ -1,6 +1,6 @@ use fluent_templates::static_loader; use leptos::*; -use leptos_fluent::{i18n, leptos_fluent, move_tr, tr, Language}; +use leptos_fluent::{expect_i18n, leptos_fluent, move_tr, tr, Language}; use leptos_meta::*; use leptos_router::*; @@ -22,6 +22,7 @@ pub fn App() -> impl IntoView { initial_language_from_url_to_localstorage: true, initial_language_from_localstorage: true, initial_language_from_navigator: true, + set_to_localstorage: true, localstorage_key: "language", initial_language_from_accept_language_header: true, }}; @@ -45,14 +46,14 @@ pub fn App() -> impl IntoView { /// Renders the home page of your application. #[component] fn HomePage() -> impl IntoView { - let i18n = i18n(); + let i18n = expect_i18n(); view! {

{move_tr!("welcome-to-leptos")}

@@ -61,8 +62,8 @@ fn HomePage() -> impl IntoView { id=lang.id.to_string() name="language" value=lang.id.to_string() - checked=*lang == i18n.language.get() - on:click=move |_| i18n.set_language_with_localstorage(lang) + checked=i18n.is_active_language(lang) + on:click=move |_| i18n.set_language(lang) /> diff --git a/examples/ssr-hydrate-axum/Cargo.toml b/examples/ssr-hydrate-axum/Cargo.toml index 92746795..4f1cbd8d 100644 --- a/examples/ssr-hydrate-axum/Cargo.toml +++ b/examples/ssr-hydrate-axum/Cargo.toml @@ -27,7 +27,6 @@ csr = [ "leptos/csr", "leptos_meta/csr", "leptos_router/csr", - "leptos-fluent/csr", ] hydrate = [ "leptos/hydrate", diff --git a/examples/ssr-hydrate-axum/src/app.rs b/examples/ssr-hydrate-axum/src/app.rs index 4e7f5039..f254727f 100644 --- a/examples/ssr-hydrate-axum/src/app.rs +++ b/examples/ssr-hydrate-axum/src/app.rs @@ -1,7 +1,7 @@ use crate::error_template::{AppError, ErrorTemplate}; use fluent_templates::static_loader; use leptos::*; -use leptos_fluent::{i18n, leptos_fluent, move_tr, tr, Language}; +use leptos_fluent::{expect_i18n, leptos_fluent, move_tr, tr, Language}; use leptos_meta::*; use leptos_router::*; @@ -23,6 +23,7 @@ pub fn App() -> impl IntoView { initial_language_from_url_to_localstorage: true, initial_language_from_localstorage: true, initial_language_from_navigator: true, + set_to_localstorage: true, localstorage_key: "language", initial_language_from_accept_language_header: true, }}; @@ -52,14 +53,14 @@ pub fn App() -> impl IntoView { /// Renders the home page of your application. #[component] fn HomePage() -> impl IntoView { - let i18n = i18n(); + let i18n = expect_i18n(); view! {

{move_tr!("welcome-to-leptos")}

@@ -68,8 +69,8 @@ fn HomePage() -> impl IntoView { id=lang.id.to_string() name="language" value=lang.id.to_string() - checked=*lang == i18n.language.get() - on:click=move |_| i18n.set_language_with_localstorage(lang) + checked=i18n.is_active_language(lang) + on:click=move |_| i18n.set_language(lang) /> diff --git a/leptos-fluent-macros/Cargo.toml b/leptos-fluent-macros/Cargo.toml index d6de00c1..0d277c27 100644 --- a/leptos-fluent-macros/Cargo.toml +++ b/leptos-fluent-macros/Cargo.toml @@ -2,7 +2,7 @@ name = "leptos-fluent-macros" description = "Macros for leptos-fluent" edition = "2021" -version = "0.0.23" +version = "0.0.24" license = "MIT" documentation = "https://docs.rs/leptos-fluent" repository = "https://github.com/mondeja/leptos-fluent" @@ -18,11 +18,9 @@ quote = "1" syn = "2" serde_json = "1" cfg-if = "1" -web-sys = { version = ">=0.1", optional = true } [features] -csr = ["web-sys/Storage", "web-sys/Navigator"] -hydrate = ["web-sys/Storage", "web-sys/Navigator"] +hydrate = [] ssr = [] actix = [] axum = [] diff --git a/leptos-fluent-macros/src/lib.rs b/leptos-fluent-macros/src/lib.rs index d7ce3136..37e60b4a 100644 --- a/leptos-fluent-macros/src/lib.rs +++ b/leptos-fluent-macros/src/lib.rs @@ -98,6 +98,8 @@ struct I18nLoader { initial_language_from_localstorage_expr: Option, initial_language_from_navigator_bool: Option, initial_language_from_navigator_expr: Option, + set_to_localstorage_bool: Option, + set_to_localstorage_expr: Option, localstorage_key_str: Option, localstorage_key_expr: Option, initial_language_from_accept_language_header_bool: Option, @@ -134,6 +136,8 @@ impl Parse for I18nLoader { let mut initial_language_from_navigator_bool: Option = None; let mut initial_language_from_navigator_expr: Option = None; + let mut set_to_localstorage_bool: Option = None; + let mut set_to_localstorage_expr: Option = None; let mut localstorage_key_str: Option = None; let mut localstorage_key_expr: Option = None; let mut initial_language_from_accept_language_header_bool: Option< @@ -207,6 +211,15 @@ impl Parse for I18nLoader { ) { return Err(err); } + } else if k == "set_to_localstorage" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut set_to_localstorage_bool, + &mut set_to_localstorage_expr, + "set_to_localstorage", + ) { + return Err(err); + } } else if k == "localstorage_key" { if let Some(err) = parse_litstr_or_expr_param( &fields, @@ -254,6 +267,8 @@ impl Parse for I18nLoader { )); } + let mut languages = Vec::new(); + let languages_path_copy = languages_path.clone(); let languages_file = languages_path .map(|languages| workspace_path.join(languages.value())); @@ -271,36 +286,51 @@ impl Parse for I18nLoader { file, ), )); - } - } - - // locales - let locales_path_copy = locales_path.clone(); - let locales_folder = - locales_path.map(|locales| workspace_path.join(locales.value())); + } else { + languages = read_languages_file(&languages_file.unwrap()); - if let Some(ref folder) = locales_folder { - if std::fs::metadata(folder).is_err() { - return Err(syn::Error::new( - locales_path_copy.unwrap().span(), - format!( - concat!( - "Couldn't read locales folder, this path should", - " be relative to your crate's `Cargo.toml`.", - " Looking for: {:?}", + if languages.len() < 2 { + return Err(syn::Error::new( + languages_path_copy.unwrap().span(), + "Languages file must contain at least two languages.", + )); + } + } + } else { + // locales + let locales_path_copy = locales_path.clone(); + let locales_folder = locales_path + .map(|locales| workspace_path.join(locales.value())); + + if let Some(ref folder) = locales_folder { + if std::fs::metadata(folder).is_err() { + return Err(syn::Error::new( + locales_path_copy.unwrap().span(), + format!( + concat!( + "Couldn't read locales folder, this path should", + " be relative to your crate's `Cargo.toml`.", + " Looking for: {:?}", + ), + folder, ), - folder, - ), - )); + )); + } else { + languages = read_locales_folder(&locales_folder.unwrap()); + + if languages.len() < 2 { + return Err(syn::Error::new( + locales_path_copy.unwrap().span(), + "Locales folder must contain at least two languages.", + )); + } + } } } Ok(Self { translations_ident, - languages: match languages_file { - Some(languages_file) => read_languages_file(&languages_file), - None => read_locales_folder(&locales_folder.unwrap()), - }, + languages, sync_html_tag_lang_bool, sync_html_tag_lang_expr, initial_language_from_url_bool, @@ -313,6 +343,8 @@ impl Parse for I18nLoader { initial_language_from_localstorage_expr, initial_language_from_navigator_bool, initial_language_from_navigator_expr, + set_to_localstorage_bool, + set_to_localstorage_expr, localstorage_key_str, localstorage_key_expr, initial_language_from_accept_language_header_bool, @@ -395,6 +427,9 @@ impl Parse for I18nLoader { /// - **`initial_language_from_navigator`** (_`false`_): Load the initial language of the user /// from [`navigator.languages`] if not found in [local storage]. Can be a literal boolean or an /// expression that will be evaluated at runtime. It will only take effect on client-side. +/// **`set_to_localstorage`** (_`false`_): Save the language of the user to [local storage] if +/// when setting the language. Can be a literal boolean or an expression that will be evaluated at +/// runtime. It will only take effect on client-side. /// - **`localstorage_key`** (_`"lang"`_): The [local storage] field to get and save the current language /// of the user. Can be a literal string or an expression that will be evaluated at runtime. /// It will only take effect on client-side. @@ -431,6 +466,8 @@ pub fn leptos_fluent( initial_language_from_navigator_expr, localstorage_key_str, localstorage_key_expr, + set_to_localstorage_bool, + set_to_localstorage_expr, initial_language_from_accept_language_header_bool, initial_language_from_accept_language_header_expr, } = parse_macro_input!(input as I18nLoader); @@ -553,6 +590,14 @@ pub fn leptos_fluent( }, }; + let set_to_localstorage = match set_to_localstorage_bool { + Some(lit) => quote! { #lit }, + None => match set_to_localstorage_expr { + Some(expr) => quote! { #expr }, + None => quote! { false }, + }, + }; + cfg_if! { if #[cfg(not(feature = "ssr"))] { let initial_language_from_url_quote = match initial_language_from_url_bool_value { @@ -778,10 +823,25 @@ pub fn leptos_fluent( languages: &LANGUAGES, translations: &#translations_ident, localstorage_key: #localstorage_key, + use_localstorage: #set_to_localstorage, }; - let ctx = provide_context::<::leptos_fluent::I18n>(i18n); + provide_context::<::leptos_fluent::I18n>(i18n); #sync_html_tag_lang_quote - ctx + + // When hydrating, it could be the case where the language + // has been setted in the server and the language selector + // has been rendered yet, but the client set that to another + // language, using for example the local storage. In that case, + // we need to trigger a rerender. The next code creates a + // rerender. Seems like a hack but I've not found another solution + // to this problem yet. + #[cfg(feature = "hydrate")] + ::leptos::create_effect(move |_| { + i18n.set_language(LANGUAGES[1]); + i18n.set_language(initial_lang); + }); + + i18n } }; diff --git a/leptos-fluent/Cargo.toml b/leptos-fluent/Cargo.toml index 47ddafa5..a75caf9f 100644 --- a/leptos-fluent/Cargo.toml +++ b/leptos-fluent/Cargo.toml @@ -2,7 +2,7 @@ name = "leptos-fluent" description = "Fluent framework for internationalization of Leptos applications" edition = "2021" -version = "0.0.23" +version = "0.0.24" license = "MIT" documentation = "https://docs.rs/leptos-fluent" repository = "https://github.com/mondeja/leptos-fluent" @@ -12,29 +12,13 @@ readme = "README.md" leptos-fluent-macros = { path = "../leptos-fluent-macros" } fluent-templates = "0.9" leptos = ">=0.6" -leptos_router = { version = ">=0.1", optional = true } +leptos_router = ">=0.1" once_cell = "1" cfg-if = "1" -web-sys = { version = ">=0.1", optional = true } -actix-web = { version = ">=3", optional = true } -axum = { version = ">=0.2", optional = true } +web-sys = { version = ">=0.1", features = ["Storage", "Navigator"] } [features] -csr = [ - "web-sys/Storage", - "web-sys/Navigator", - "dep:leptos_router", - "leptos_router/csr", - "leptos/csr", - "leptos-fluent-macros/csr" -] -hydrate = [ - "web-sys/Storage", - "web-sys/Navigator", - "leptos/hydrate", - "leptos-fluent-macros/hydrate", - "leptos_router/hydrate" -] -ssr = ["leptos_router/ssr", "leptos/ssr", "leptos-fluent-macros/ssr"] -actix = ["dep:actix-web", "leptos-fluent-macros/actix"] -axum = ["dep:axum", "leptos-fluent-macros/axum"] +hydrate = ["leptos-fluent-macros/hydrate"] +ssr = ["leptos-fluent-macros/ssr"] +actix = ["leptos-fluent-macros/actix"] +axum = ["leptos-fluent-macros/axum"] diff --git a/leptos-fluent/README.md b/leptos-fluent/README.md index 19c7cd19..12027616 100644 --- a/leptos-fluent/README.md +++ b/leptos-fluent/README.md @@ -18,12 +18,13 @@ Add the following to your `Cargo.toml` file: ```toml [dependencies] -leptos-fluent = "0.0.21" +leptos-fluent = "0.0.24" fluent-templates = "0.9" [features] -csr = ["leptos-fluent/csr"] -hydrate = ["leptos-fluent/hydrate"] +hydrate = [ + "leptos-fluent/hydrate" +] ssr = [ "leptos-fluent/ssr", "leptos-fluent/actix", # actix and axum are supported @@ -101,6 +102,9 @@ pub fn App() -> impl IntoView { // Get the initial language from `navigator.languages` if not // found in the local storage. By default, it is `false`. initial_language_from_navigator: true, + // Set the language to local storage when the user changes it. + // By default, it is `false`. + set_to_localstorage: true, // Name of the field in local storage to get and set the // current language of the user. By default, it is `"lang"`. localstorage_key: "language", @@ -134,8 +138,7 @@ fn ChildComponent() -> impl IntoView { ### Features -- **Client side rendering (CSR)**: Use the `leptos-fluent/csr` feature. -- **Server side rendering (SSR)**: Use the `leptos-fluent/ssr` feature. +- **Server Side Rendering**: Use the `leptos-fluent/ssr` feature. - **Hydration**: Use the `leptos-fluent/hydrate` feature. - **Actix Web integration**: Use the `leptos-fluent/actix` feature. - **Axum integration**: Use the `leptos-fluent/axum` feature. diff --git a/leptos-fluent/src/lib.rs b/leptos-fluent/src/lib.rs index 28cadd1a..e24ba723 100644 --- a/leptos-fluent/src/lib.rs +++ b/leptos-fluent/src/lib.rs @@ -11,12 +11,13 @@ //! //! ```toml //! [dependencies] -//! leptos-fluent = "0.0.21" +//! leptos-fluent = "0.0.24" //! fluent-templates = "0.9" //! //! [features] -//! csr = ["leptos-fluent/csr"] -//! hydrate = ["leptos-fluent/hydrate"] +//! hydrate = [ +//! "leptos-fluent/hydrate" +//! ] //! ssr = [ //! "leptos-fluent/ssr", //! "leptos-fluent/actix", # actix and axum are supported @@ -94,6 +95,9 @@ //! // Get the initial language from `navigator.languages` if not //! // found in the local storage. By default, it is `false`. //! initial_language_from_navigator: true, +//! // Set the language to local storage when the user changes it. +//! // By default, it is `false`. +//! set_to_localstorage: true, //! // Name of the field in local storage to get and set the //! // current language of the user. By default, it is `"lang"`. //! localstorage_key: "language", @@ -127,8 +131,7 @@ //! //! ## Features //! -//! - **Client side rendering (CSR)**: Use the `leptos-fluent/csr` feature. -//! - **Server side rendering (SSR)**: Use the `leptos-fluent/ssr` feature. +//! - **Server Side Rendering**: Use the `leptos-fluent/ssr` feature. //! - **Hydration**: Use the `leptos-fluent/hydrate` feature. //! - **Actix Web integration**: Use the `leptos-fluent/actix` feature. //! - **Axum integration**: Use the `leptos-fluent/axum` feature. @@ -198,12 +201,17 @@ impl PartialEq for Language { /// you can wrap this context in another struct and provide it to Leptos as a context. #[derive(Clone, Copy)] pub struct I18n { - /// Signal that holds the current language + /// Signal that holds the current language. pub language: RwSignal<&'static Language>, - /// Available languages for the application + /// Available languages for the application. pub languages: &'static [&'static Language], + /// leptos-fluent translations loader. pub translations: &'static Lazy, + /// Local storage key to store the current language. pub localstorage_key: &'static str, + /// Whether to use local storage to store the current language + /// when the user changes it. + pub use_localstorage: bool, } impl I18n { @@ -258,21 +266,52 @@ impl I18n { language_from_str_between_languages(code, self.languages) } - /// Set the current language in the signal of the context and in local storage. - pub fn set_language_with_localstorage(&self, lang: &'static Language) { + /// Set the current language in the signal of the context and in local storage + /// (if using local storage). + pub fn set_language(&self, lang: &'static Language) { self.language.set(lang); - localstorage::set(self.localstorage_key, &lang.id.to_string()); + if self.use_localstorage { + localstorage::set(self.localstorage_key, &lang.id.to_string()); + } + } + + /// Get a hash for a language including their active status. + pub fn language_key(&self, lang: &'static Language) -> String { + format!( + "{}{}", + lang.id, + if lang == self.language.get() { + "1" + } else { + "0" + } + ) + } + + /// Get wether a language is the active language. + pub fn is_active_language(&self, lang: &'static Language) -> bool { + lang == self.language.get() } } -/// Get the current context for internationalization. +/// Get the current context for localization. #[inline(always)] -pub fn i18n() -> I18n { +pub fn use_i18n() -> Option { + use_context::() +} + +/// Expect the current context for localization. +#[inline(always)] +pub fn expect_i18n() -> I18n { use_context::().expect( "I18n context is missing, use leptos_fluent!{} macro to provide it.", ) } +#[allow(non_upper_case_globals)] +/// Expect the current context for localization. +pub const i18n: fn() -> I18n = expect_i18n; + /// Translate a text identifier to the current language. /// /// ```rust,ignore @@ -282,10 +321,10 @@ pub fn i18n() -> I18n { #[macro_export] macro_rules! tr { ($text_id:expr$(,)?) => { - $crate::i18n().tr($text_id) + $crate::expect_i18n().tr($text_id) }; ($text_id:expr, {$($key:expr => $value:expr),*$(,)?}$(,)?) => { - $crate::i18n().trs($text_id, &{ + $crate::expect_i18n().trs($text_id, &{ let mut map = ::std::collections::HashMap::new(); $( map.insert($key.to_string(), $value.into()); diff --git a/leptos-fluent/src/localstorage.rs b/leptos-fluent/src/localstorage.rs index c89c404d..e839a804 100644 --- a/leptos-fluent/src/localstorage.rs +++ b/leptos-fluent/src/localstorage.rs @@ -1,7 +1,7 @@ use cfg_if::cfg_if; pub fn get(#[allow(unused_variables)] key: &str) -> Option { - cfg_if! { if #[cfg(all(not(feature = "ssr"), feature = "csr", feature = "hydrate"))] { + cfg_if! { if #[cfg(not(feature = "ssr"))] { ::leptos::window() .local_storage() .unwrap() @@ -17,7 +17,7 @@ pub fn set( #[allow(unused_variables)] key: &str, #[allow(unused_variables)] value: &str, ) { - cfg_if! { if #[cfg(any(feature = "csr", feature = "hydrate"))] { + cfg_if! { if #[cfg(not(feature = "ssr"))] { ::leptos::window() .local_storage() .unwrap() diff --git a/leptos-fluent/src/url.rs b/leptos-fluent/src/url.rs index 97cc5a9b..26b48190 100644 --- a/leptos-fluent/src/url.rs +++ b/leptos-fluent/src/url.rs @@ -1,7 +1,7 @@ use cfg_if::cfg_if; pub fn get(#[allow(unused_variables)] k: &str) -> Option { - cfg_if! { if #[cfg(all(not(feature = "ssr"), feature = "csr", feature = "hydrate"))] { + cfg_if! { if #[cfg(not(feature = "ssr"))] { use leptos_router::Url; let query = ::leptos::window().location().search().unwrap(); diff --git a/tests/src/lib.rs b/tests/src/lib.rs deleted file mode 100644 index 08125fb8..00000000 --- a/tests/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -mod web; diff --git a/tests/src/web.rs b/tests/src/web.rs deleted file mode 100644 index 72170b3a..00000000 --- a/tests/src/web.rs +++ /dev/null @@ -1,124 +0,0 @@ -use leptos::{wasm_bindgen::JsCast, *}; -use wasm_bindgen_test::*; - -wasm_bindgen_test_configure!(run_in_browser); - -macro_rules! mount { - ($app:ident) => {{ - mount_to_body(move || view! {
<$app/>
}); - }}; -} - -macro_rules! unmount { - () => {{ - document() - .body() - .unwrap() - .remove_child( - document() - .get_element_by_id("wrapper") - .unwrap() - .unchecked_ref(), - ) - .unwrap(); - }}; -} - -async fn sleep(delay: i32) { - let mut cb = |resolve: js_sys::Function, _reject: js_sys::Function| { - web_sys::window() - .unwrap() - .set_timeout_with_callback_and_timeout_and_arguments_0( - &resolve, delay, - ) - .unwrap(); - }; - - let p = js_sys::Promise::new(&mut cb); - wasm_bindgen_futures::JsFuture::from(p).await.unwrap(); -} - -fn element_text(selector: &str) -> String { - document() - .query_selector(selector) - .unwrap() - .unwrap() - .text_content() - .unwrap() -} - -fn input_by_id(id: &str) -> web_sys::HtmlInputElement { - document() - .get_element_by_id(id) - .unwrap() - .unchecked_into::() -} - -fn html() -> web_sys::HtmlHtmlElement { - document() - .document_element() - .unwrap() - .unchecked_into::() -} - -fn html_lang() -> String { - html().lang() -} - -#[wasm_bindgen_test] -async fn minimal_example() { - use leptos_fluent_csr_minimal_example::App as MinimalExampleApp; - mount!(MinimalExampleApp); - let es = input_by_id("es"); - let en = input_by_id("en"); - - // translations working - assert_eq!(element_text("p"), "Select a language:"); - es.click(); - assert!(es.checked()); - assert!(!en.checked()); - assert_eq!(element_text("p"), "Selecciona un idioma:"); - - // language change not reflected in html tag - sleep(30).await; - html().remove_attribute("lang").unwrap(); - assert_eq!(html_lang(), "".to_string()); - es.click(); - assert!(es.checked()); - assert_eq!(html_lang(), "".to_string()); - en.click(); - assert!(en.checked()); - assert_eq!(html_lang(), "".to_string()); - - unmount!(); -} - -#[wasm_bindgen_test] -async fn complete_example() { - use leptos_fluent_csr_complete_example::App as CompleteExampleApp; - mount!(CompleteExampleApp); - let es = input_by_id("es"); - let en = input_by_id("en"); - - // translations working - assert_eq!(element_text("p"), "Select a language:"); - es.click(); - assert!(es.checked()); - assert!(!en.checked()); - assert_eq!(element_text("p"), "Selecciona un idioma:"); - en.click(); - assert!(en.checked()); - assert!(!es.checked()); - assert_eq!(element_text("p"), "Select a language:"); - - // language change reflected in html tag - sleep(30).await; - assert_eq!(html_lang(), "en".to_string()); - es.click(); - assert!(es.checked()); - assert_eq!(html_lang(), "es".to_string()); - en.click(); - assert_eq!(html_lang(), "en".to_string()); - - unmount!(); -}