From 2c2ad7ad21087fd06e91877d5d101015884a4f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sat, 10 Jan 2026 12:03:48 +0100 Subject: [PATCH] fix: automatically reconnect after losing a WebSocket connection --- Cargo.lock | 541 +++----------- Cargo.toml | 6 +- dioxus-i18n/.github/release-drafter.yml | 48 ++ dioxus-i18n/.github/workflows/publish.yml | 19 + .../.github/workflows/release-drafter.yml | 19 + dioxus-i18n/.github/workflows/test_runs.yml | 40 + dioxus-i18n/.gitignore | 2 + dioxus-i18n/CHANGELOG.md | 117 +++ dioxus-i18n/Cargo.toml | 30 + dioxus-i18n/LICENSE.md | 20 + dioxus-i18n/README.md | 85 +++ dioxus-i18n/examples/config-auto-locales.rs | 47 ++ .../examples/config-dynamic-pathbuf.rs | 62 ++ .../examples/config-static-includestr.rs | 57 ++ dioxus-i18n/examples/data/fluent/en.ftl | 124 +++ dioxus-i18n/examples/data/i18n/en-US.ftl | 3 + dioxus-i18n/examples/data/i18n/es-ES.ftl | 3 + dioxus-i18n/examples/dioxus-desktop.rs | 47 ++ dioxus-i18n/examples/fluent-grammar.rs | 233 ++++++ dioxus-i18n/examples/freya.rs | 57 ++ dioxus-i18n/src/error.rs | 31 + dioxus-i18n/src/i18n_macro.rs | 101 +++ dioxus-i18n/src/lib.rs | 12 + dioxus-i18n/src/use_i18n.rs | 703 ++++++++++++++++++ dioxus-i18n/tests/README.md | 13 + dioxus-i18n/tests/common/mod.rs | 3 + dioxus-i18n/tests/common/test_hook.rs | 85 +++ dioxus-i18n/tests/data/fallback/fb-FB.ftl | 5 + .../data/fallback/la-Scpt-LA-variants.ftl | 1 + .../tests/data/fallback/la-Scpt-LA.ftl | 2 + dioxus-i18n/tests/data/fallback/la-Scpt.ftl | 3 + dioxus-i18n/tests/data/fallback/la.ftl | 4 + dioxus-i18n/tests/data/i18n/en.ftl | 5 + dioxus-i18n/tests/defects_spec.rs | 44 ++ dioxus-i18n/tests/graceful_fallback_spec.rs | 99 +++ dioxus-i18n/tests/macro_reexport_spec.rs | 85 +++ dioxus-i18n/tests/translations_spec.rs | 343 +++++++++ docker/dev/app/Dockerfile | 2 +- docker/prod/app/Dockerfile | 2 +- src/hooks/mod.rs | 81 +- 40 files changed, 2740 insertions(+), 444 deletions(-) create mode 100644 dioxus-i18n/.github/release-drafter.yml create mode 100644 dioxus-i18n/.github/workflows/publish.yml create mode 100644 dioxus-i18n/.github/workflows/release-drafter.yml create mode 100644 dioxus-i18n/.github/workflows/test_runs.yml create mode 100644 dioxus-i18n/.gitignore create mode 100644 dioxus-i18n/CHANGELOG.md create mode 100644 dioxus-i18n/Cargo.toml create mode 100644 dioxus-i18n/LICENSE.md create mode 100644 dioxus-i18n/README.md create mode 100644 dioxus-i18n/examples/config-auto-locales.rs create mode 100644 dioxus-i18n/examples/config-dynamic-pathbuf.rs create mode 100644 dioxus-i18n/examples/config-static-includestr.rs create mode 100644 dioxus-i18n/examples/data/fluent/en.ftl create mode 100644 dioxus-i18n/examples/data/i18n/en-US.ftl create mode 100644 dioxus-i18n/examples/data/i18n/es-ES.ftl create mode 100644 dioxus-i18n/examples/dioxus-desktop.rs create mode 100644 dioxus-i18n/examples/fluent-grammar.rs create mode 100644 dioxus-i18n/examples/freya.rs create mode 100644 dioxus-i18n/src/error.rs create mode 100644 dioxus-i18n/src/i18n_macro.rs create mode 100644 dioxus-i18n/src/lib.rs create mode 100644 dioxus-i18n/src/use_i18n.rs create mode 100644 dioxus-i18n/tests/README.md create mode 100644 dioxus-i18n/tests/common/mod.rs create mode 100644 dioxus-i18n/tests/common/test_hook.rs create mode 100644 dioxus-i18n/tests/data/fallback/fb-FB.ftl create mode 100644 dioxus-i18n/tests/data/fallback/la-Scpt-LA-variants.ftl create mode 100644 dioxus-i18n/tests/data/fallback/la-Scpt-LA.ftl create mode 100644 dioxus-i18n/tests/data/fallback/la-Scpt.ftl create mode 100644 dioxus-i18n/tests/data/fallback/la.ftl create mode 100644 dioxus-i18n/tests/data/i18n/en.ftl create mode 100644 dioxus-i18n/tests/defects_spec.rs create mode 100644 dioxus-i18n/tests/graceful_fallback_spec.rs create mode 100644 dioxus-i18n/tests/macro_reexport_spec.rs create mode 100644 dioxus-i18n/tests/translations_spec.rs diff --git a/Cargo.lock b/Cargo.lock index e0867f6..eca40d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,45 +38,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle 0.6.2", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "askama_escape" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener 5.4.1", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-channel" version = "1.9.0" @@ -158,17 +125,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "async-std" version = "1.13.2" @@ -672,7 +628,16 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" dependencies = [ - "const-serialize-macro", + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", "serde", ] @@ -687,6 +652,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "const-str" version = "0.7.1" @@ -1047,7 +1022,7 @@ dependencies = [ "byteorder", "chrono", "diesel_derives", - "downcast-rs 2.0.2", + "downcast-rs", "itoa", "pq-sys", "serde_json", @@ -1098,9 +1073,8 @@ dependencies = [ [[package]] name = "dioxus" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a115f9dbe5900c6044ee6a791e1b160c29989c6a8721eec099e01a964e5dae4" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-asset-resolver", "dioxus-cli-config", @@ -1132,9 +1106,8 @@ dependencies = [ [[package]] name = "dioxus-asset-resolver" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6851ae49ba3988f1b77f6ef826eb142e811602129841c24bf5a4e103708d9844" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "http", @@ -1153,18 +1126,16 @@ dependencies = [ [[package]] name = "dioxus-cli-config" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59e9d9da2e7334fdae5d77e3989207aa549062f74ff1ca2171393bbdd7fda90" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "wasm-bindgen", ] [[package]] name = "dioxus-config-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd56be5ea6c9f416b25e9e3adc910c02127be75b6d1ecd567661f31920b27ba" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "proc-macro2", "quote", @@ -1172,15 +1143,13 @@ dependencies = [ [[package]] name = "dioxus-config-macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c49327465c2d434d00fb4c86bd35ae72155b479622e09352b950d9ab4807bf23" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" [[package]] name = "dioxus-core" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7400cbd21a98e585a13f8c29574da9b8afb2fd343f712618042b6c71761f0933" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "const_format", @@ -1200,9 +1169,8 @@ dependencies = [ [[package]] name = "dioxus-core-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51c0eb7eb76dd5a0b9a116d94d29ca78924a1ed1fcb7ea072eda5045d3ac056" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "convert_case 0.8.0", "dioxus-rsx", @@ -1213,15 +1181,13 @@ dependencies = [ [[package]] name = "dioxus-core-types" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0652ab5f9c2c32261d44a3155debbfd909ed03d03434d7f70f5a796bf255c519" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" [[package]] name = "dioxus-desktop" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b24aa7e4aa87fce202c5e67d560cddd9ed67ad533f16b7d922916c04993766ff" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "async-trait", "base64", @@ -1274,9 +1240,8 @@ dependencies = [ [[package]] name = "dioxus-devtools" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9748128bcd102b10e58c765939807053ccab542206a939b8bab228077455c259" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1294,9 +1259,8 @@ dependencies = [ [[package]] name = "dioxus-devtools-types" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48540ca8a0ab1ec81cd4db35f0c9713d43b158647fc1dcb0d79965fc3b41d96c" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "serde", @@ -1305,9 +1269,8 @@ dependencies = [ [[package]] name = "dioxus-document" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501a189b391d091c9aa02c05f5b25f5d0d17fa0e1016e000b0fdbb073d77cd6a" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-core-macro", @@ -1324,9 +1287,8 @@ dependencies = [ [[package]] name = "dioxus-fullstack" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54150804265defdb21a6f2d8914a45316a1e7fb70ab22c30cf836e8fe2f8081b" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "async-stream", @@ -1389,9 +1351,8 @@ dependencies = [ [[package]] name = "dioxus-fullstack-core" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a9be2ef4d701520eefef284d218fb35b159dccd6bccc02b5bad42945e2599d" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "axum-core", @@ -1417,9 +1378,8 @@ dependencies = [ [[package]] name = "dioxus-fullstack-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31ea4451fe8c9d2af24fb718a94966d5fd7e11325777e5b5a59085c5c85e5fb" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "const_format", "convert_case 0.8.0", @@ -1431,9 +1391,8 @@ dependencies = [ [[package]] name = "dioxus-history" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d704b3ba9504cb3c9cde49499b75546d1faaff2736f4c368aca6c061c48ac3" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "tracing", @@ -1441,9 +1400,8 @@ dependencies = [ [[package]] name = "dioxus-hooks" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c6d68be372eca8186a1c57ec49be67a6ea46022150b5e85ab6a6acde52d272" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1457,9 +1415,8 @@ dependencies = [ [[package]] name = "dioxus-html" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa87ecfa0f38ec286be25789a7f2d6c30778111f1fbff563da4bae41d171496" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "async-trait", "bytes", @@ -1484,9 +1441,8 @@ dependencies = [ [[package]] name = "dioxus-html-internal-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49301d0e389378e8070b8b704110339a0d3358efad9f5ad483ffab3a8d406dae" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1496,9 +1452,7 @@ dependencies = [ [[package]] name = "dioxus-i18n" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "033ee9f42add6e16bc2a585840b17fd47ad7ce143c115d605a60a7ed72dbba02" +version = "0.5.1" dependencies = [ "dioxus", "fluent", @@ -1509,9 +1463,8 @@ dependencies = [ [[package]] name = "dioxus-interpreter-js" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5437a89d3ef7edfebc0f10acb065f1709cb7ffb678e3a4bb1416706d71f7c67" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-core-types", @@ -1529,9 +1482,8 @@ dependencies = [ [[package]] name = "dioxus-liveview" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f690466a88cc93d7f87e1735aab9cb4a83c70f452ed344a32559577e80505da4" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "axum", "dioxus-cli-config", @@ -1557,9 +1509,8 @@ dependencies = [ [[package]] name = "dioxus-logger" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b25ebfbc193cebcf5af5e19b8ee7c6adee486fbd1c12f11aea058b464da16f9" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "tracing", @@ -1569,9 +1520,8 @@ dependencies = [ [[package]] name = "dioxus-router" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18282604175f38d8c9291946ad6b34899657e47aef994fbbe6defb501a000f33" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1590,9 +1540,8 @@ dependencies = [ [[package]] name = "dioxus-router-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47340b339c2c3f042b190f541b7241e2547b2e703f813d34ea24b963330c6757" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "base16", "digest", @@ -1605,9 +1554,8 @@ dependencies = [ [[package]] name = "dioxus-rsx" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d97c02689beff55767ba5f6e185ffd204c6a193e372f0fead8a3722c6f7eea" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "proc-macro2", "proc-macro2-diagnostics", @@ -1618,9 +1566,8 @@ dependencies = [ [[package]] name = "dioxus-server" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d608c33c39f032469c6eb59f361dc2724799724d8b3e15c824d1047e664c087" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "async-trait", @@ -1676,9 +1623,8 @@ dependencies = [ [[package]] name = "dioxus-signals" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27fc4df7a31a7f02e5a0b40884bb66ee165226a05d75fce03baa44029e438762" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "futures-channel", @@ -1692,9 +1638,8 @@ dependencies = [ [[package]] name = "dioxus-ssr" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "088efddedd39fc29d007bc91c8a61b25130355149ea5313469f96fb695c5e3ab" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "askama_escape", "dioxus-core", @@ -1704,9 +1649,8 @@ dependencies = [ [[package]] name = "dioxus-stores" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2dec3cd677078824a733de25ddbe8e987cfc8d98aec29b7d199e1fdb8452b96" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1716,9 +1660,8 @@ dependencies = [ [[package]] name = "dioxus-stores-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b7f085e374aaaa78403227b9bd83675c4078388d41a41b67dfbe4aa0bb64d5" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1728,9 +1671,8 @@ dependencies = [ [[package]] name = "dioxus-web" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "315009f3a77c3c813415b3b8a8ea62a4d7a32dde9a666664b30862d4386e8456" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1810,15 +1752,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.8.9", -] - [[package]] name = "dlopen2" version = "0.8.2" @@ -1857,12 +1790,6 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "downcast-rs" version = "2.0.2" @@ -1931,33 +1858,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "enumset" version = "1.1.10" @@ -2401,9 +2301,8 @@ dependencies = [ [[package]] name = "generational-box" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e658d10252a15200ca4a1c67c7180fc0baffa3f92869bbd903025daf6f70fd65" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "parking_lot", "tracing", @@ -3206,9 +3105,8 @@ dependencies = [ [[package]] name = "lazy-js-bundle" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21972afec4627b7ba0de60b5269585b5ac2f56d559b0696f57eee6daf8a51b68" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" [[package]] name = "lazy_static" @@ -3389,32 +3287,32 @@ dependencies = [ [[package]] name = "manganis" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97c63ae68d25457a579b7714806088c5cb44c536cf624a53a17184878f9f0bcd" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ - "const-serialize", + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", "manganis-core", "manganis-macro", ] [[package]] name = "manganis-core" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d071660b149f985cbab8b23f2004ea6dd5cf947b63a0843f0e2f46e6af7229" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ - "const-serialize", + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", "dioxus-cli-config", "dioxus-core-types", "serde", + "winnow 0.7.14", ] [[package]] name = "manganis-macro" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9793d1d33778245b4240c330a8f575d208ce077c7e7bab1c79064252ddd4a162" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dunce", "macro-string", @@ -3652,19 +3550,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nodrop" version = "0.1.14" @@ -3879,16 +3764,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "pango" version = "0.18.3" @@ -4316,15 +4191,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d" -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -4632,26 +4498,26 @@ dependencies = [ [[package]] name = "rfd" -version = "0.15.4" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd", "block2", "dispatch2", "js-sys", + "libc", "log", "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "percent-encoding", "pollster", "raw-window-handle 0.6.2", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4791,12 +4657,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -5196,12 +5056,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stfu8" version = "0.2.7" @@ -5241,9 +5095,8 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subsecond" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c09bc2c9ef0381b403ab8b58122961cb83266d16b1f55f9486d5857ba4a9ae26" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "js-sys", "libc", @@ -5260,9 +5113,8 @@ dependencies = [ [[package]] name = "subsecond-types" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07aa455c66ddfdbb51507537402b961e027846468954ef8d974bce65dff9eb0" +version = "0.7.3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "serde", ] @@ -5572,10 +5424,8 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -5941,17 +5791,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "uds_windows" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" -dependencies = [ - "memoffset", - "tempfile", - "winapi", -] - [[package]] name = "unic-langid" version = "0.9.6" @@ -6050,12 +5889,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -6075,7 +5908,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "js-sys", - "serde_core", "wasm-bindgen", ] @@ -6283,66 +6115,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wayland-backend" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" -dependencies = [ - "cc", - "downcast-rs 1.2.1", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" -dependencies = [ - "bitflags 2.10.0", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.83" @@ -7044,62 +6816,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" -dependencies = [ - "async-broadcast", - "async-recursion", - "async-trait", - "enumflags2", - "event-listener 5.4.1", - "futures-core", - "futures-lite", - "hex", - "nix", - "ordered-stream", - "serde", - "serde_repr", - "tokio", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.14", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.111", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" -dependencies = [ - "serde", - "static_assertions", - "winnow 0.7.14", - "zvariant", -] - [[package]] name = "zerocopy" version = "0.8.31" @@ -7180,44 +6896,3 @@ dependencies = [ "quote", "syn 2.0.111", ] - -[[package]] -name = "zvariant" -version = "5.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow 0.7.14", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" -dependencies = [ - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.111", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.111", - "winnow 0.7.14", -] diff --git a/Cargo.toml b/Cargo.toml index 3fe5307..1b05a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,9 @@ edition = "2024" [dependencies] chrono = { version = "0.4.42", features = ["serde", "unstable-locales"] } -dioxus = { version = "0.7.2", features = ["fullstack", "router"] } +dioxus = { git = "https://github.com/matous-volf/dioxus", rev = "627d5ca5b80aeed57c23e253024665f103117f5e", features = ["fullstack", "router"] } # TODO: Remove this once https://github.com/DioxusLabs/dioxus/issues/4765 is resolved. -dioxus-html = { version = "0.7.2", features = ["serialize"] } +dioxus-html = { git = "https://github.com/matous-volf/dioxus", rev = "627d5ca5b80aeed57c23e253024665f103117f5e", features = ["serialize"] } feruca = { version = "0.11.5" } serde = { version = "1.0.228" } serde_json = { version = "1.0.145" } @@ -33,7 +33,7 @@ time = { version = "0.3.44", optional = true } tokio = { version = "1.48.0", optional = true } async-std = { version = "1.13.2", optional = true } -dioxus-i18n = "0.5.0" +dioxus-i18n = { path = "dioxus-i18n" } voca_rs = "1.15.2" load-dotenv = "0.1.2" diff --git a/dioxus-i18n/.github/release-drafter.yml b/dioxus-i18n/.github/release-drafter.yml new file mode 100644 index 0000000..2fdeca8 --- /dev/null +++ b/dioxus-i18n/.github/release-drafter.yml @@ -0,0 +1,48 @@ +name-template: "Release v$RESOLVED_VERSION 🦀" +tag-template: "v$RESOLVED_VERSION" +categories: + - title: "🚀 Features" + label: "feature" + - title: "🐛 Bug Fixes" + label: "bug" + - title: "♻️ Refactor" + label: "refactor" + - title: "📝 Documentation" + label: "documentation" + - title: "🧰 Maintenance" + labels: + - "chore" + - "dependencies" +change-template: "- $TITLE @$AUTHOR (#$NUMBER)" +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. +version-resolver: + major: + labels: + - "major" + minor: + labels: + - "minor" + patch: + labels: + - "patch" + default: patch +template: | + ## Changes + + $CHANGES +autolabeler: + - label: feature + branch: + - "/^feat(ure)?[/-].+/" + - label: bug + branch: + - "/^fix[/-].+/" + - label: refactor + branch: + - "/(refactor|refactoring)[/-].+/" + - label: documentation + branch: + - "/doc(s|umentation)[/-].+/" + - label: chore + branch: + - "/^chore[/-].+/" \ No newline at end of file diff --git a/dioxus-i18n/.github/workflows/publish.yml b/dioxus-i18n/.github/workflows/publish.yml new file mode 100644 index 0000000..553fe46 --- /dev/null +++ b/dioxus-i18n/.github/workflows/publish.yml @@ -0,0 +1,19 @@ +name: Cargo Publish + +on: + workflow_dispatch: + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + publish: + name: Publish to crate.io + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file diff --git a/dioxus-i18n/.github/workflows/release-drafter.yml b/dioxus-i18n/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..5613b07 --- /dev/null +++ b/dioxus-i18n/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +name: Release Drafter + +on: + push: + branches: + - main + + pull_request: + types: [opened, reopened, synchronize] + +jobs: + update_release_draft: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/dioxus-i18n/.github/workflows/test_runs.yml b/dioxus-i18n/.github/workflows/test_runs.yml new file mode 100644 index 0000000..9a06126 --- /dev/null +++ b/dioxus-i18n/.github/workflows/test_runs.yml @@ -0,0 +1,40 @@ +name: Test Runs + +on: + push: + branches: + - main + + pull_request: + types: [opened, reopened, synchronize] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Updates + run: | + sudo apt update + sudo apt install libwebkit2gtk-4.1-dev \ + build-essential \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libglib2.0-dev + + - name: Checkout + uses: actions/checkout@v4 + + - name: Lint + run: cargo clippy -- -D warnings + + - name: Test + run: cargo test + + - name: Compile + run: | + rustup target add wasm32-unknown-unknown + cargo build --target wasm32-unknown-unknown + cargo build --release diff --git a/dioxus-i18n/.gitignore b/dioxus-i18n/.gitignore new file mode 100644 index 0000000..06aba01 --- /dev/null +++ b/dioxus-i18n/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +/target diff --git a/dioxus-i18n/CHANGELOG.md b/dioxus-i18n/CHANGELOG.md new file mode 100644 index 0000000..aa0f3ce --- /dev/null +++ b/dioxus-i18n/CHANGELOG.md @@ -0,0 +1,117 @@ +# Changelog + +## [0.4.3] + +- [Issue #19](https://github.com/dioxus-community/dioxus-i18n/issues/19) Enable use of "message-id.attribute-id" + syntax in the `t!`, `te!`, `tid!` macros in order to extract attribute definition, e.g. `t!("mycomponent.placeholder")` + in: + ``` + mycomponent = Component Name + .placeholder = Some placeholder + .aria-text = Some aria text + ``` + +- Added examples for all fluent grammar constructs and configuration variants. + +## [0.4.2] 2025-02-08 + +### Fixed + +- [Issue #15](https://github.com/dioxus-community/dioxus-i18n/issues/15) Recent change to t! macro unnecessarily breaks v0.3 code. + +### Amended + +- t! macro amended to use unwrap_or_else rather than panic!. + +- Error messages made consistant across all macros. + +## [0.4.1] 2025-02-02 + +### Added + +- New methods (`I18nConfig::with_auto_locales`) to determine supported locales from deep search for translation files. + +- New methods returning `Result<_, Error>` rather than `panic!`, such that: + | __`panic!` version__ | __`Result<_, Error>` vesion__ | + |-----------------------------------------|------------------------------------------| + | `LocaleResource::to_resource_string` | `LocaleResource::try_to_resource_string` | + | `I18n::translate` | `I18n::try_translate` | + | `I18n::translate_with_args` | `I18n::try_translate_with_args` | + | `I18n::set_fallback_language` | `I18n::try_set_fallback_language` | + | `I18n::set_language` | `I18n::try_set_language` | + | `use_init_i18n` | `try_use_init_i18n` | + | `I18nConfig::with_auto_locales` | `I18nConfig::try_with_auto_locales` | + +- New `te!` macro which acts like `t!` but returns `Error`. + +- New `tid!` macro which acts like `t!` but returns the message-id. + +### Change + +- t! macro amended to use `try_translate` and `try_translate_with_args`, but will perform `.expect("..")` + and therefore panic! on error. This retains backwards compatibility for this macro. + +- Use of `set_fallback_language` / `try_set_fallback_language` without a corresponding locale + translation is treated as an error. + +## [0.4.0] 2025-01-25 + +### Added + +- Code: + - Doc comments + - Module tests for `cargo test` + +- Amended `I18nConfig::with_locale` so that the `Locale` dynamic or static + constructors no longer have to be _explicitly_ given. + They can be determined implicitly from `(LanguageIdentifier, &str)` or + `(LanguageIdentifer, PathBuf)`. + +- Enabled shared 'LocaleResource's, where two dialect can use the same translation file. + For example ["en", "en-GB"] share "en-GB.ftl". + +### Changed + +- The translations used are determined when `I18n::set_language` or + `I18n::set_fallback_language` is called, and not each time a message is translated. + +- __Fallback handling has changed__. It no longer just uses _fallback_language_ when the message + id is missing from the current _locale_. It performs a graceful fallback from + _-_ to __ before using the actual _fallback_ (in fact it + falls back along the _---_ + hiearchy). + + __Note:__ this is a breaking change which may impact the selected translation. + +- `LocaleResource::to_string` renamed to `LocaleResource::to_resource_string` + +## [0.3.0] 2024-12-10 + +- [Dioxus 0.6](https://dioxuslabs.com/) support + +## [0.2.4] 2024-09-11 + +- Hide new_dynamic in WASM +- New t!() macro + +## [0.2.3] 2024-09-04 + +- Support dynamic loading of locales + +## [0.2.2] 2024-09-02 + +- Enable macros instead of serde in unic-langid + +## [0.2.1] 2024-09-02 + +- Export unic_langid and fluent +- Use absolute path to import fluent in the translate macro +- Updated freya example + +## [0.2.0] 2024-09-01 + +- Now based in the [Fluent Project](https://github.com/projectfluent/fluent-rs) + +## [0.1.0] 2024-08-31 + +- Initial release diff --git a/dioxus-i18n/Cargo.toml b/dioxus-i18n/Cargo.toml new file mode 100644 index 0000000..0382ab5 --- /dev/null +++ b/dioxus-i18n/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "dioxus-i18n" +version = "0.5.1" +edition = "2021" +authors = ["Marc Espín "] +description = "i18n integration for Dioxus apps based on Fluent Project." +license = "MIT" +repository = "https://github.com/dioxus-community/dioxus-i18n" +readme = "./README.md" +categories = ["accessibility", "gui", "localization", "internationalization"] + +[dependencies] +dioxus = { git = "https://github.com/matous-volf/dioxus", rev = "627d5ca5b80aeed57c23e253024665f103117f5e", default-features = false, features = [ + "hooks", + "macro", + "signals", +] } +fluent = "0.17" +thiserror = "2.0" +unic-langid = { version = "0.9", features = ["macros"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +walkdir = "2.5.0" + +[dev-dependencies] +dioxus = { git = "https://github.com/matous-volf/dioxus", rev = "627d5ca5b80aeed57c23e253024665f103117f5e", features = ["desktop"] } +freya = "0.3" +futures = "0.3.31" +pretty_assertions = "1.4.1" +unic-langid = { version = "0.9.5", features = ["macros"] } diff --git a/dioxus-i18n/LICENSE.md b/dioxus-i18n/LICENSE.md new file mode 100644 index 0000000..1a54db7 --- /dev/null +++ b/dioxus-i18n/LICENSE.md @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) Marc Espín Sanz + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/dioxus-i18n/README.md b/dioxus-i18n/README.md new file mode 100644 index 0000000..b48f36c --- /dev/null +++ b/dioxus-i18n/README.md @@ -0,0 +1,85 @@ +# dioxus-i18n 🌍 + +i18n integration for Dioxus apps based on the [Project Fluent](https://github.com/projectfluent/fluent-rs). + +> This crate used to be in the [Dioxus SDK](https://github.com/DioxusLabs/sdk). + +## Support + +- **Dioxus v0.6** 🧬 +- Renderers: + - [web](https://dioxuslabs.com/learn/0.6/guides/web/), + - [desktop](https://dioxuslabs.com/learn/0.6/guides/desktop/), + - [freya](https://github.com/marc2332/freya) +- Both WASM and native targets + +## Example: + +```ftl +# en-US.ftl + +hello = Hello, {$name}! +``` + +```rs +// main.rs + +fn app() -> Element { + let i18 = use_init_i18n(|| { + I18nConfig::new(langid!("en-US")) + // implicit [`Locale`] + .with_locale(( // Embed + langid!("en-US"), + include_str!("./en-US.ftl") + )) + .with_locale(( // Load at launch + langid!("es-ES"), + PathBuf::from("./es-ES.ftl"), + )) + .with_locale(( // Locales will share duplicated locale_resources + langid!("en"), // which is useful to assign a specific region for + include_str!("./en-US.ftl") // the primary language + )) + // explicit [`Locale`] + .with_locale(Locale::new_static( // Embed + langid!("en-US"), + include_str!("./en-US.ftl"), + )) + .with_locale(Locale::new_dynamic( // Load at launch + langid!("es-ES"), + PathBuf::from("./es-ES.ftl"), + )) + }); + + rsx!( + label { { t!("hello", name: "World") } } + ) +} +``` + +## Further examples + +The examples folder contains a number of working examples: + +* Desktop examples: + * [Dioxus](./examples/dioxus-desktop.rs) + * [Freya](./examples/freya.rs) +* Configuration variants: + * [Auto locales](./examples/config-auto-locales.rs) + * [Dynamic (PathBuf)](./examples/config-dynamic-pathbuf.rs) + * [Static (include_str!)](./examples/config-static-includestr.rs) +* Fluent grammer: + * [Application](./examples/fluent-grammar.rs) + * [FTL file](./examples/data/fluent/en.ftl) + +## Development + +```bash +# Checks clean compile against `#[cfg(not(target_arch = "wasm32"))]` +cargo build --target wasm32-unknown-unknown + +# Runs all tests +cargo test +``` + +[MIT License](./LICENSE.md) diff --git a/dioxus-i18n/examples/config-auto-locales.rs b/dioxus-i18n/examples/config-auto-locales.rs new file mode 100644 index 0000000..ebd8d3d --- /dev/null +++ b/dioxus-i18n/examples/config-auto-locales.rs @@ -0,0 +1,47 @@ +//! This example demonstrates how to use an auto_locales derived I18nConfig. +//! This is useful when you have a lot of locales and you don't want to manually add them. + +use dioxus::prelude::*; +use dioxus_i18n::{prelude::*, t}; +use unic_langid::langid; + +use std::path::PathBuf; + +fn main() { + launch(app); +} + +#[allow(non_snake_case)] +fn Body() -> Element { + let mut i18n = i18n(); + + let change_to_english = move |_| i18n.set_language(langid!("en-US")); + let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); + + rsx!( + button { + onclick: change_to_english, + label { + "English" + } + } + button { + onclick: change_to_spanish, + label { + "Spanish" + } + } + p { { t!("hello_world") } } + p { { t!("hello", name: "Dioxus") } } + ) +} + +fn app() -> Element { + use_init_i18n(|| { + // This initialisation performs a deep search for all locales in the given path. + // It IS NOT supported in WASM targets. + I18nConfig::new(langid!("en-US")).with_auto_locales(PathBuf::from("./examples/data/i18n/")) + }); + + rsx!(Body {}) +} diff --git a/dioxus-i18n/examples/config-dynamic-pathbuf.rs b/dioxus-i18n/examples/config-dynamic-pathbuf.rs new file mode 100644 index 0000000..c23a212 --- /dev/null +++ b/dioxus-i18n/examples/config-dynamic-pathbuf.rs @@ -0,0 +1,62 @@ +//! This example demonstrates how to use pathbuf derived I18nConfig. +//! This is useful when the path to the translation files is not known at compile time. + +use dioxus::prelude::*; +use dioxus_i18n::{prelude::*, t}; +use unic_langid::langid; + +use std::path::PathBuf; + +fn main() { + launch(app); +} + +#[allow(non_snake_case)] +fn Body() -> Element { + let mut i18n = i18n(); + + let change_to_english = move |_| i18n.set_language(langid!("en-US")); + let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); + + rsx!( + button { + onclick: change_to_english, + label { + "English" + } + } + button { + onclick: change_to_spanish, + label { + "Spanish" + } + } + p { { t!("hello_world") } } + p { { t!("hello", name: "Dioxus") } } + ) +} + +fn app() -> Element { + use_init_i18n(|| { + // This initialisation allows individual translation files to be selected. + // The locales can be added with an implicitly derived locale (see config-static-includestr.rs for a comparison) + // or using an explicit Locale::new_dynamic call. + // + // The two examples are functionally equivalent. + // + // It IS NOT supported in WASM targets. + I18nConfig::new(langid!("en-US")) + // Implicit... + .with_locale(( + langid!("es-ES"), + PathBuf::from("./examples/data/i18n/es-ES.ftl"), + )) + // Explicit... + .with_locale(Locale::new_dynamic( + langid!("en-US"), + PathBuf::from("./examples/data/i18n/en-US.ftl"), + )) + }); + + rsx!(Body {}) +} diff --git a/dioxus-i18n/examples/config-static-includestr.rs b/dioxus-i18n/examples/config-static-includestr.rs new file mode 100644 index 0000000..9b756e5 --- /dev/null +++ b/dioxus-i18n/examples/config-static-includestr.rs @@ -0,0 +1,57 @@ +//! This example demonstrates how to use pathbuf derived I18nConfig. +//! This is useful for WASM targets; the paths to the translation files must be known at compile time. + +use dioxus::prelude::*; +use dioxus_i18n::{prelude::*, t}; +use unic_langid::langid; + +fn main() { + launch(app); +} + +#[allow(non_snake_case)] +fn Body() -> Element { + let mut i18n = i18n(); + + let change_to_english = move |_| i18n.set_language(langid!("en-US")); + let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); + + rsx!( + button { + onclick: change_to_english, + label { + "English" + } + } + button { + onclick: change_to_spanish, + label { + "Spanish" + } + } + p { { t!("hello_world") } } + p { { t!("hello", name: "Dioxus") } } + ) +} + +fn app() -> Element { + use_init_i18n(|| { + // This initialisation allows individual translation files to be selected. + // The locales can be added with an implicitly derived locale (see config-dynamic-pathbuf.rs for a comparison) + // or using an explicit Locale::new_static call. + // + // The two examples are functionally equivalent. + // + // It IS supported in WASM targets. + I18nConfig::new(langid!("en-US")) + // Implicit... + .with_locale((langid!("es-ES"), include_str!("./data/i18n/es-ES.ftl"))) + // Explicit... + .with_locale(Locale::new_static( + langid!("en-US"), + include_str!("./data/i18n/en-US.ftl"), + )) + }); + + rsx!(Body {}) +} diff --git a/dioxus-i18n/examples/data/fluent/en.ftl b/dioxus-i18n/examples/data/fluent/en.ftl new file mode 100644 index 0000000..528ff72 --- /dev/null +++ b/dioxus-i18n/examples/data/fluent/en.ftl @@ -0,0 +1,124 @@ +### Fluent grammar examples for dioxus-i18n. + +## These examples demonstrate Fluent file grammar and how dioxus-i18n can be +## used to access these translations. + +## Examples derived from: https://projectfluent.org/fluent/guide/index.html + +# Simple message +simple-message = This is a simple message. + +# $name (String) - The name you want to display. +message-with-variable = This is a message with a variable: { $name }. + +# Reference to a term. +-a-term = This is a common term used by many messages. +message-referencing-a-term = This is a message with a reference: { -a-term }. + +# Use of special characters. +message-with-special-character = This message contain opening curly brace {"{"} and a closing curly brace {"}"}. + +# Message with blanks. +blank-is-removed = This message starts with no blanks. +blank-is-preserved = {" "}This message starts with 4 spaces (note HTML contracts them). + +# Message with attributes. +message-with-attributes = Predefined value + .placeholder = email@example.com + .aria-label = Login input value + .title = Type your login email + +# Message with quotes. +literal-quote-cryptic = Text in {"\""}double quotes{"\""}. +literal-quote-preferred = Text in "double quotes". + +# Message with Unicode characters. +unicode-cryptic = {"\u2605"} {"\u2606"} {"\u2728"} {"\u262F"} {"\u263A"} +unicode-preferred = ★ ☆ ✨ ☯ ☺ + +# Message with a placeable. +single-line = Text can be written in a single line. + +multi-line = Text can also span multiple lines + as long as each new line is indented + by at least one space. + +block-line = + Sometimes it's more readable to format + multiline text as a "block", which means + starting it on a new line. All lines must + be indented by at least one space. + +# Message using functions. +# +# Note: Builtin functions are currently unsupported: See Fluent issue https://github.com/projectfluent/fluent-rs/issues/181 +# The Bundle::add_builtins() function is not published at the time of writing this example. +# +# Using a builtin currently results in an error. +# +# $duration (Number) - The duration in seconds. +time-elapsed-no-function = Time elapsed: { $duration }s. +time-elapsed-function = Currently unsupported: error raised: { NUMBER($duration) }. + +# Message reference. +referenced-message = Referenced message +message-referencing-another-message = Message referencing another message: { referenced-message }. + +# Message selection plurals. +message-selection-plurals = + { $value -> + *[one] Value is one: { $value }. + [other] Value is more than one: { $value }. + } + +# Message selection numeric. +# Argument must be numeric. +message-selection-numeric = + { NUMERIC($value) -> + [0.0] Zero: { $value }. + *[0.5] A half: { $value }. + [other] Other ($value) + } + +# Message selection number. +# +# Note: Builtin functions are currently unsupported: See Fluent issue https://github.com/projectfluent/fluent-rs/issues/181 +# The Bundle::add_builtins() function is not published at the time of writing this example. +# +# Using the NUMBER builtin always results in a default behaviour. +# +message-selection-number = { NUMBER($pos, type: "ordinal") -> + [1] First! + [one] {$pos}st + [two] {$pos}nd + [few] {$pos}rd + *[other] {$pos}th +} + + +# Variables in references. +-term-using-variable = https://{ $host } +message-using-term-with-variable = For example: { -term-using-variable(host: "example.com") }. + +-term-using-variable-2 = + { $case -> + *[nominative] Firefox + [locative] Firefoksie + } +message-using-term-with-variable-2-1 = Informacje o { -term-using-variable-2(case: "locative") }. +message-using-term-with-variable-2-2 = About { -term-using-variable-2(case: "nominative") }. +message-using-term-with-variable-2-default = About { -term-using-variable-2(case: "") }. +message-using-term-with-variable-2-not-provided = About { -term-using-variable-2 }. + +-brand-name = + { $case -> + *[nominative] Firefox + [locative] Firefoksie + } + +string-literal = { "string literal" } +number-literal-1 = { 1 } +number-literal-2 = { -123 } +number-literal-3 = { 3.14 } +inline-expression-placeable-1 = { { "string literal" } } +inline-expression-placeable-2 = { { 123 } } diff --git a/dioxus-i18n/examples/data/i18n/en-US.ftl b/dioxus-i18n/examples/data/i18n/en-US.ftl new file mode 100644 index 0000000..aa74414 --- /dev/null +++ b/dioxus-i18n/examples/data/i18n/en-US.ftl @@ -0,0 +1,3 @@ +hello_world = Hello, World! + +hello = Hello, {$name}! \ No newline at end of file diff --git a/dioxus-i18n/examples/data/i18n/es-ES.ftl b/dioxus-i18n/examples/data/i18n/es-ES.ftl new file mode 100644 index 0000000..66d6c7b --- /dev/null +++ b/dioxus-i18n/examples/data/i18n/es-ES.ftl @@ -0,0 +1,3 @@ +hello_world = Hola, Mundo! + +hello = Hola, {$name}! \ No newline at end of file diff --git a/dioxus-i18n/examples/dioxus-desktop.rs b/dioxus-i18n/examples/dioxus-desktop.rs new file mode 100644 index 0000000..2ab7865 --- /dev/null +++ b/dioxus-i18n/examples/dioxus-desktop.rs @@ -0,0 +1,47 @@ +use dioxus::prelude::*; +use dioxus_i18n::{prelude::*, t}; +use unic_langid::langid; + +use std::path::PathBuf; + +fn main() { + launch(app); +} + +#[allow(non_snake_case)] +fn Body() -> Element { + let mut i18n = i18n(); + + let change_to_english = move |_| i18n.set_language(langid!("en-US")); + let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); + + rsx!( + button { + onclick: change_to_english, + label { + "English" + } + } + button { + onclick: change_to_spanish, + label { + "Spanish" + } + } + p { { t!("hello_world") } } + p { { t!("hello", name: "Dioxus") } } + ) +} + +fn app() -> Element { + use_init_i18n(|| { + I18nConfig::new(langid!("en-US")) + .with_locale((langid!("en-US"), include_str!("./data/i18n/en-US.ftl"))) + .with_locale(( + langid!("es-ES"), + PathBuf::from("./examples/data/i18n/es-ES.ftl"), + )) + }); + + rsx!(Body {}) +} diff --git a/dioxus-i18n/examples/fluent-grammar.rs b/dioxus-i18n/examples/fluent-grammar.rs new file mode 100644 index 0000000..61d0adf --- /dev/null +++ b/dioxus-i18n/examples/fluent-grammar.rs @@ -0,0 +1,233 @@ +//! This example demonstrates many of the Fluent grammar constructs, and how they are +//! used in dioxus-i18n. +//! This performs a lookup only, no additional translation files are provided + +use dioxus::prelude::*; +use dioxus_i18n::{prelude::*, tid}; +use unic_langid::langid; + +use std::path::PathBuf; + +fn main() { + launch(app); +} + +#[allow(non_snake_case)] +#[component] +fn Body() -> Element { + rsx! { + table { + tbody { + tr { + td { "Simple message" } + td { {tid!("simple-message")} } + } + tr { + td { "Non-existing message: id provided by default when using tid! macro" } + td { {tid!("non-existing-message")} } + } + tr { + td { "Message with a variable" } + td { {tid!("message-with-variable", name: "Value 1")} } + } + tr { + td { } + td { {tid!("message-with-variable", name: "Value 2")} } + } + tr { + td { "Reference to a term" } + td { {tid!("message-referencing-a-term")} } + } + tr { + td { "Use of special characters." } + td { {tid!("message-with-special-character")} } + } + tr { + td { "Message with blanks." } + td { "'" {tid!("blank-is-removed")} "'" } + } + tr { + td { } + td { "'" {tid!("blank-is-preserved")} "'" } + } + tr { + td { "Message with attributes: root" } + td { {tid!("message-with-attributes")} } + } + tr { + td { "Message with attributes: attribute" } + td { {tid!("message-with-attributes.placeholder")} } + } + tr { + td { } + td { {tid!("message-with-attributes.aria-label")} } + } + tr { + td { } + td { {tid!("message-with-attributes.title")} } + } + tr { + td { "Message with attributes: not existing" } + td { {tid!("message-with-attributes.not-existing")} } + } + tr { + td { "Message with attributes: invalid" } + td { {tid!("message-with-attributes.placeholder.invalid")} } + } + tr { + td { "Message with quotes: cryptic" } + td { {tid!("literal-quote-cryptic")} } + } + tr { + td { "Message with quotes: preferred" } + td { {tid!("literal-quote-preferred")} } + } + tr { + td { "Message with Unicode characters: cryptic" } + td { {tid!("unicode-cryptic")} } + } + tr { + td { "Message with Unicode characters: preferred" } + td { {tid!("unicode-preferred")} } + } + tr { + td { "Message with a placeable: single-line" } + td { {tid!("line-single")} } + } + tr { + td { "Message with a placeable: single-line" } + td { {tid!("single-line")} } + } + tr { + td { "Message with a placeable: multi-line (1)" } + td { {tid!("multi-line")} } + } + tr { + td { "Message with a placeable: multi-line (2)" } + td { pre { {tid!("multi-line")} } } + } + tr { + td { "Message with a placeable: block-line (1)" } + td { {tid!("block-line")} } + } + tr { + td { "Message with a placeable: block-line (2)" } + td { pre { {tid!("block-line")} } } + } + tr { + td { "Message using functions: no function" } + td { pre { {tid!("time-elapsed-no-function", duration: 23.7114812589)} } } + } + tr { + td { "Message using functions: function" } + td { pre { {tid!("time-elapsed-function", duration: 23.7114812589)} } } + } + tr { + td { "Reference to a message" } + td { {tid!("message-referencing-another-message")} } + } + tr { + td { "Message selection: plurals" } + td { {tid!("message-selection-plurals", value: 1)} } + } + tr { + td { } + td { {tid!("message-selection-plurals", value: 2)} } + } + tr { + td { "Message selection: plurals (default: an 'empty' value must be provided...)" } + td { {tid!("message-selection-plurals", value: "")} } + } + tr { + td { "Message selection: plurals (default: ... otherwise an error is raised)" } + td { {tid!("message-selection-plurals")} } + } + tr { + td { "Message selection: numeric" } + td { {tid!("message-selection-numeric", value: 0.0)} } + } + tr { + td { } + td { {tid!("message-selection-numeric", value: 0.5)} } + } + tr { + td { } + td { {tid!("message-selection-numeric", value: 42.0)} } + } + tr { + td { "Message selection: numeric (default)" } + td { {tid!("message-selection-numeric", value: "")} } + } + tr { + td { "Message selection: number" } + td { {tid!("message-selection-number", pos: 1)} } + } + tr { + td { "" } + td { {tid!("message-selection-number", pos: 2)} } + } + tr { + td { "" } + td { {tid!("message-selection-number", pos: 3)} } + } + tr { + td { "" } + td { {tid!("message-selection-number", pos: 4)} } + } + tr { + td { "Variables in references (1)" } + td { {tid!("message-using-term-with-variable")} } + } + tr { + td { "Variables in references (2)" } + td { {tid!("message-using-term-with-variable-2-1")} } + } + tr { + td { } + td { {tid!("message-using-term-with-variable-2-2")} } + } + tr { + td { } + td { {tid!("message-using-term-with-variable-2-default")} } + } + tr { + td { } + td { {tid!("message-using-term-with-variable-2-not-provided")} } + } + tr { + td { "Literals: string" } + td { {tid!("string-literal")} } + } + tr { + td { } + td { {tid!("number-literal-1")} } + } + tr { + td { } + td { {tid!("number-literal-2")} } + } + tr { + td { } + td { {tid!("number-literal-3")} } + } + tr { + td { } + td { {tid!("inline-expression-placeable-1")} } + } + tr { + td { } + td { {tid!("inline-expression-placeable-2")} } + } + } + } + } +} + +fn app() -> Element { + use_init_i18n(|| { + // Only one example in this path, which contains the complete Fluent grammar. + I18nConfig::new(langid!("en")).with_auto_locales(PathBuf::from("./examples/data/fluent/")) + }); + + rsx!(Body {}) +} diff --git a/dioxus-i18n/examples/freya.rs b/dioxus-i18n/examples/freya.rs new file mode 100644 index 0000000..23608be --- /dev/null +++ b/dioxus-i18n/examples/freya.rs @@ -0,0 +1,57 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use dioxus_i18n::{prelude::*, t}; +use freya::prelude::*; +use unic_langid::langid; + +use std::path::PathBuf; + +fn main() { + launch_with_props(app, "freya + i18n", (300.0, 200.0)); +} + +#[allow(non_snake_case)] +fn Body() -> Element { + let mut i18n = i18n(); + + let change_to_english = move |_| i18n.set_language(langid!("en-US")); + let change_to_spanish = move |_| i18n.set_language(langid!("es-ES")); + + rsx!( + rect { + rect { + direction: "horizontal", + Button { + onclick: change_to_english, + label { + "English" + } + } + Button { + onclick: change_to_spanish, + label { + "Spanish" + } + } + } + label { { t!("hello_world") } } + label { { t!("hello", name: "Dioxus") } } + } + ) +} + +fn app() -> Element { + use_init_i18n(|| { + I18nConfig::new(langid!("en-US")) + .with_locale((langid!("en-US"), include_str!("./data/i18n/en-US.ftl"))) + .with_locale(( + langid!("es-ES"), + PathBuf::from("./examples/data/i18n/es-ES.ftl"), + )) + }); + + rsx!(Body {}) +} diff --git a/dioxus-i18n/src/error.rs b/dioxus-i18n/src/error.rs new file mode 100644 index 0000000..3d3deae --- /dev/null +++ b/dioxus-i18n/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +#[derive(Clone, Debug, Error)] +pub enum Error { + #[error("invalid message id: '{0}'")] + InvalidMessageId(String), + + #[error("message id not found for key: '{0}'")] + MessageIdNotFound(String), + + #[error("attribute id not found for key: '{0}'")] + AttributeIdNotFound(String), + + #[error("message pattern not found for key: '{0}'")] + MessagePatternNotFound(String), + + #[error("fluent errors during lookup:\n{0}")] + FluentErrorsDetected(String), + + #[error("failed to read locale resource from path: {0}")] + LocaleResourcePathReadFailed(String), + + #[error("fallback for \"{0}\" must have locale")] + FallbackMustHaveLocale(String), + + #[error("language id cannot be determined - reason: {0}")] + InvalidLanguageId(String), + + #[error("invalid path: {0}")] + InvalidPath(String), +} diff --git a/dioxus-i18n/src/i18n_macro.rs b/dioxus-i18n/src/i18n_macro.rs new file mode 100644 index 0000000..dfb4589 --- /dev/null +++ b/dioxus-i18n/src/i18n_macro.rs @@ -0,0 +1,101 @@ +//! Key translation macros. +//! +//! Using file: +//! +//! ```ftl +//! # en-US.ftl +//! # +//! hello = Hello, {$name}! +//! ``` + +/// Translate message from key, returning [`crate::prelude::DioxusI18nError`] if id not found... +/// +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_i18n::{te, prelude::*}; +/// # use unic_langid::langid; +/// # #[component] +/// # fn Example() -> Element { +/// # let lang = langid!("en-US"); +/// # let config = I18nConfig::new(lang.clone()).with_locale((lang.clone(), "hello = Hello, {$name}")).with_fallback(lang.clone()); +/// # let mut i18n = use_init_i18n(|| config); +/// let name = "Avery Gigglesworth"; +/// let hi = te!("hello", name: {name}).expect("message id 'name' should be present"); +/// assert_eq!(hi, "Hello, Avery Gigglesworth"); +/// # rsx! { "" } +/// # } +/// ``` +/// +#[macro_export] +macro_rules! te { + ($id:expr, $( $name:ident : $value:expr ),* ) => { + { + let mut params_map = $crate::fluent::FluentArgs::new(); + $( + params_map.set(stringify!($name), $value); + )* + $crate::prelude::i18n().try_translate_with_args($id, Some(¶ms_map)) + } + }; + + ($id:expr ) => {{ + $crate::prelude::i18n().try_translate($id) + }}; +} + +/// Translate message from key, panic! if id not found... +/// +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_i18n::{t, prelude::*}; +/// # use unic_langid::langid; +/// # #[component] +/// # fn Example() -> Element { +/// # let lang = langid!("en-US"); +/// # let config = I18nConfig::new(lang.clone()).with_locale((lang.clone(), "hello = Hello, {$name}")).with_fallback(lang.clone()); +/// # let mut i18n = use_init_i18n(|| config); +/// let name = "Avery Gigglesworth"; +/// let hi = t!("hello", name: {name}); +/// assert_eq!(hi, "Hello, Avery Gigglesworth"); +/// # rsx! { "" } +/// # } +/// ``` +/// +#[macro_export] +macro_rules! t { + ($id:expr, $( $name:ident : $value:expr ),* ) => { + $crate::te!($id, $( $name : $value ),*).unwrap_or_else(|e| panic!("{}", e.to_string())) + }; + + ($id:expr ) => {{ + $crate::te!($id).unwrap_or_else(|e| panic!("{}", e.to_string())) + }}; +} + +/// Translate message from key, return id if no translation found... +/// +/// ```rust +/// # use dioxus::prelude::*; +/// # use dioxus_i18n::{tid, prelude::*}; +/// # use unic_langid::langid; +/// # #[component] +/// # fn Example() -> Element { +/// # let lang = langid!("en-US"); +/// # let config = I18nConfig::new(lang.clone()).with_locale((lang.clone(), "hello = Hello, {$name}")).with_fallback(lang.clone()); +/// # let mut i18n = use_init_i18n(|| config); +/// let message = tid!("no-key"); +/// assert_eq!(message, "message-id: no-key should be translated"); +/// # rsx! { "" } +/// # } +/// ``` +/// +#[macro_export] +macro_rules! tid { + ($id:expr, $( $name:ident : $value:expr ),* ) => { + $crate::te!($id, $( $name : $value ),*).unwrap_or_else(|e| e.to_string()) + }; + + ($id:expr ) => {{ + $crate::te!($id).unwrap_or_else(|e| e.to_string()) + }}; +} diff --git a/dioxus-i18n/src/lib.rs b/dioxus-i18n/src/lib.rs new file mode 100644 index 0000000..f85c8ef --- /dev/null +++ b/dioxus-i18n/src/lib.rs @@ -0,0 +1,12 @@ +#![doc = include_str!("../README.md")] +mod error; +pub mod i18n_macro; +pub mod use_i18n; + +pub use fluent; +pub use unic_langid; + +pub mod prelude { + pub use crate::error::Error as DioxusI18nError; + pub use crate::use_i18n::*; +} diff --git a/dioxus-i18n/src/use_i18n.rs b/dioxus-i18n/src/use_i18n.rs new file mode 100644 index 0000000..0c04fb6 --- /dev/null +++ b/dioxus-i18n/src/use_i18n.rs @@ -0,0 +1,703 @@ +use super::error::Error; + +use dioxus::prelude::*; +use fluent::{FluentArgs, FluentBundle, FluentResource}; +use unic_langid::LanguageIdentifier; + +#[cfg(not(target_arch = "wasm32"))] +use walkdir::WalkDir; + +use std::collections::HashMap; + +#[cfg(not(target_arch = "wasm32"))] +use std::path::{Path, PathBuf}; + +/// `Locale` is a "place-holder" around what will eventually be a `fluent::FluentBundle` +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct Locale { + id: LanguageIdentifier, + resource: LocaleResource, +} + +impl Locale { + pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self { + Self { + id, + resource: LocaleResource::Static(str), + } + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn new_dynamic(id: LanguageIdentifier, path: impl Into) -> Self { + Self { + id, + resource: LocaleResource::Path(path.into()), + } + } +} + +impl From<(LanguageIdentifier, T)> for Locale +where + T: Into, +{ + fn from((id, resource): (LanguageIdentifier, T)) -> Self { + let resource = resource.into(); + Self { id, resource } + } +} + +/// A `LocaleResource` can be static text, or a filesystem file (not supported in WASM). +#[derive(Debug, PartialEq)] +pub enum LocaleResource { + Static(&'static str), + #[cfg(not(target_arch = "wasm32"))] + Path(PathBuf), +} + +impl LocaleResource { + pub fn try_to_resource_string(&self) -> Result { + match self { + Self::Static(str) => Ok(str.to_string()), + #[cfg(not(target_arch = "wasm32"))] + Self::Path(path) => std::fs::read_to_string(path) + .map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())), + } + } + + pub fn to_resource_string(&self) -> String { + let result = self.try_to_resource_string(); + match result { + Ok(string) => string, + Err(err) => panic!("failed to create resource string {:?}: {}", self, err), + } + } +} + +impl From<&'static str> for LocaleResource { + fn from(value: &'static str) -> Self { + Self::Static(value) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for LocaleResource { + fn from(value: PathBuf) -> Self { + Self::Path(value) + } +} + +/// The configuration for `I18n`. +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct I18nConfig { + /// The initial language, can be later changed with [`I18n::set_language`] + id: LanguageIdentifier, + + /// The final fallback language if no other locales are found for `id`. + /// A `Locale` must exist in `locales' if `fallback` is defined. + fallback: Option, + + /// The locale_resources added to the configuration. + locale_resources: Vec, + + /// The locales added to the configuration. + locales: HashMap, +} + +impl I18nConfig { + /// Create an i18n config with the selected [LanguageIdentifier]. + pub fn new(id: LanguageIdentifier) -> Self { + Self { + id, + fallback: None, + locale_resources: Vec::new(), + locales: HashMap::new(), + } + } + + /// Set a fallback [LanguageIdentifier]. + pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self { + self.fallback = Some(fallback); + self + } + + /// Add [Locale]. + /// It is possible to share locales resources. If this locale's resource + /// matches a previously added one, then this locale will use the existing one. + /// This is primarily for the static locale_resources to avoid string duplication. + pub fn with_locale(mut self, locale: T) -> Self + where + T: Into, + { + let locale = locale.into(); + let locale_resources_len = self.locale_resources.len(); + + let index = self + .locale_resources + .iter() + .position(|r| *r == locale.resource) + .unwrap_or(locale_resources_len); + + if index == locale_resources_len { + self.locale_resources.push(locale.resource) + }; + + self.locales.insert(locale.id, index); + self + } + + /// Add multiple locales from given folder, based on their filename. + /// + /// If the path represents a folder, then the folder will be deep traversed for + /// all '*.ftl' files. If the filename represents a [LanguageIdentifier] then it + /// will be added to the config. + /// + /// If the path represents a file, then the filename must represent a + /// unic_langid::LanguageIdentifier for it to be added to the config. + /// + /// The method is not available for `wasm32` builds. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_with_auto_locales(self, path: PathBuf) -> Result { + if path.is_dir() { + let files = find_ftl_files(&path)?; + files + .into_iter() + .try_fold(self, |acc, file| acc.with_auto_pathbuf(file)) + } else if is_ftl_file(&path) { + self.with_auto_pathbuf(path) + } else { + Err(Error::InvalidPath(path.to_string_lossy().to_string())) + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn with_auto_pathbuf(self, file: PathBuf) -> Result { + assert!(is_ftl_file(&file)); + + let stem = file.file_stem().ok_or_else(|| { + Error::InvalidLanguageId(format!("No file stem: '{}'", file.display())) + })?; + + let id_str = stem.to_str().ok_or_else(|| { + Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy())) + })?; + + let id = LanguageIdentifier::from_bytes(id_str.as_bytes()) + .map_err(|e| Error::InvalidLanguageId(e.to_string()))?; + + Ok(self.with_locale((id, file))) + } + + /// Add multiple locales from given folder, based on their filename. + /// + /// Will panic! on error. + /// + /// The method is not available for `wasm32` builds. + #[cfg(not(target_arch = "wasm32"))] + pub fn with_auto_locales(self, path: PathBuf) -> Self { + let path_name = path.display().to_string(); + let result = self.try_with_auto_locales(path); + match result { + Ok(result) => result, + Err(err) => panic!( + "with_auto_locales must have valid pathbuf {}: {}", + path_name, err + ), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn find_ftl_files(folder: &PathBuf) -> Result, Error> { + let ftl_files: Vec = WalkDir::new(folder) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| is_ftl_file(entry.path())) + .map(|entry| entry.path().to_path_buf()) + .collect(); + + Ok(ftl_files) +} + +#[cfg(not(target_arch = "wasm32"))] +fn is_ftl_file(entry: &Path) -> bool { + entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false) +} + +/// Initialize an i18n provider. +pub fn try_use_init_i18n(init: impl FnOnce() -> I18nConfig) -> Result { + use_context_provider(move || { + // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675 + let I18nConfig { + id, + fallback, + locale_resources, + locales, + } = init(); + + I18n::try_new(id, fallback, locale_resources, locales) + }) +} + +/// Initialize an i18n provider. +pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n { + use_context_provider(move || { + // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675 + let I18nConfig { + id, + fallback, + locale_resources, + locales, + } = init(); + + match I18n::try_new(id, fallback, locale_resources, locales) { + Ok(i18n) => i18n, + Err(e) => panic!("Failed to create I18n context: {}", e), + } + }) +} + +#[derive(Clone, Copy)] +pub struct I18n { + selected_language: Signal, + fallback_language: Signal>, + locale_resources: Signal>, + locales: Signal>, + active_bundle: Signal>, +} + +impl I18n { + pub fn try_new( + selected_language: LanguageIdentifier, + fallback_language: Option, + locale_resources: Vec, + locales: HashMap, + ) -> Result { + let bundle = try_create_bundle( + &selected_language, + &fallback_language, + &locale_resources, + &locales, + )?; + Ok(Self { + selected_language: Signal::new(selected_language), + fallback_language: Signal::new(fallback_language), + locale_resources: Signal::new(locale_resources), + locales: Signal::new(locales), + active_bundle: Signal::new(bundle), + }) + } + + pub fn new( + selected_language: LanguageIdentifier, + fallback_language: Option, + locale_resources: Vec, + locales: HashMap, + ) -> Self { + let result = Self::try_new( + selected_language, + fallback_language, + locale_resources, + locales, + ); + match result { + Ok(i18n) => i18n, + Err(err) => panic!("I18n cannot be created: {}", err), + } + } + + pub fn try_translate_with_args( + &self, + msg: &str, + args: Option<&FluentArgs>, + ) -> Result { + let (message_id, attribute_name) = Self::decompose_identifier(msg)?; + + let bundle = self.active_bundle.read(); + + let message = bundle + .get_message(message_id) + .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?; + + let pattern = if let Some(attribute_name) = attribute_name { + let attribute = message + .get_attribute(attribute_name) + .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?; + attribute.value() + } else { + message + .value() + .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))? + }; + + let mut errors = vec![]; + let translation = bundle + .format_pattern(pattern, args, &mut errors) + .to_string(); + + (errors.is_empty()) + .then_some(translation) + .ok_or_else(|| Error::FluentErrorsDetected(format!("{:#?}", errors))) + } + + pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> { + let parts: Vec<&str> = msg.split('.').collect(); + match parts.as_slice() { + [message_id] => Ok((message_id, None)), + [message_id, attribute_name] => Ok((message_id, Some(attribute_name))), + _ => Err(Error::InvalidMessageId(msg.to_string())), + } + } + + pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String { + let result = self.try_translate_with_args(msg, args); + match result { + Ok(translation) => translation, + Err(err) => panic!("Failed to translate {}: {}", msg, err), + } + } + + #[inline] + pub fn try_translate(&self, msg: &str) -> Result { + self.try_translate_with_args(msg, None) + } + + pub fn translate(&self, msg: &str) -> String { + let result = self.try_translate(msg); + match result { + Ok(translation) => translation, + Err(err) => panic!("Failed to translate {}: {}", msg, err), + } + } + + /// Get the selected language. + #[inline] + pub fn language(&self) -> LanguageIdentifier { + self.selected_language.read().clone() + } + + /// Get the fallback language. + pub fn fallback_language(&self) -> Option { + self.fallback_language.read().clone() + } + + /// Update the selected language. + pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> { + *self.selected_language.write() = id; + self.try_update_active_bundle() + } + + /// Update the selected language. + pub fn set_language(&mut self, id: LanguageIdentifier) { + let id_name = id.to_string(); + let result = self.try_set_language(id); + match result { + Ok(()) => (), + Err(err) => panic!("cannot set language {}: {}", id_name, err), + } + } + + /// Update the fallback language. + pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> { + self.locales + .read() + .get(&id) + .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?; + + *self.fallback_language.write() = Some(id); + self.try_update_active_bundle() + } + + /// Update the fallback language. + pub fn set_fallback_language(&mut self, id: LanguageIdentifier) { + let id_name = id.to_string(); + let result = self.try_set_fallback_language(id); + match result { + Ok(()) => (), + Err(err) => panic!("cannot set fallback language {}: {}", id_name, err), + } + } + + fn try_update_active_bundle(&mut self) -> Result<(), Error> { + let bundle = try_create_bundle( + &self.selected_language.peek(), + &self.fallback_language.peek(), + &self.locale_resources.peek(), + &self.locales.peek(), + )?; + + self.active_bundle.set(bundle); + Ok(()) + } +} + +fn try_create_bundle( + selected_language: &LanguageIdentifier, + fallback_language: &Option, + locale_resources: &[LocaleResource], + locales: &HashMap, +) -> Result, Error> { + let add_resource = move |bundle: &mut FluentBundle, + langid: &LanguageIdentifier, + locale_resources: &[LocaleResource]| { + if let Some(&i) = locales.get(langid) { + let resource = &locale_resources[i]; + let resource = + FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| { + Error::FluentErrorsDetected(format!("resource langid: {}\n{:#?}", langid, e)) + })?; + bundle.add_resource_overriding(resource); + }; + Ok(()) + }; + + let mut bundle = FluentBundle::new(vec![selected_language.clone()]); + if let Some(fallback_language) = fallback_language { + add_resource(&mut bundle, fallback_language, locale_resources)?; + } + + let (language, script, region, variants) = selected_language.clone().into_parts(); + let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants); + let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]); + let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]); + let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]); + + add_resource(&mut bundle, &language_lang, locale_resources)?; + add_resource(&mut bundle, &script_lang, locale_resources)?; + add_resource(&mut bundle, ®ion_lang, locale_resources)?; + add_resource(&mut bundle, &variants_lang, locale_resources)?; + + /* Add this code when the fluent crate includes FluentBundle::add_builtins. + * This will allow the use of built-in functions like `NUMBER` and `DATETIME`. + * See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information. + bundle + .add_builtins() + .map_err(|e| Error::FluentErrorsDetected(e.to_string()))?; + */ + + Ok(bundle) +} + +pub fn i18n() -> I18n { + consume_context() +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use unic_langid::langid; + + #[test] + fn can_add_locale_to_config_explicit_locale() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + const LANG_B: LanguageIdentifier = langid!("la-LB"); + const LANG_C: LanguageIdentifier = langid!("la-LC"); + + let config = I18nConfig::new(LANG_A) + .with_locale(Locale::new_static(LANG_B, "lang = lang_b")) + .with_locale(Locale::new_dynamic(LANG_C, PathBuf::new())); + + assert_eq!( + config, + I18nConfig { + id: LANG_A, + fallback: None, + locale_resources: vec![ + LocaleResource::Static("lang = lang_b"), + LocaleResource::Path(PathBuf::new()), + ], + locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]), + } + ); + } + + #[test] + fn can_add_locale_to_config_implicit_locale() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + const LANG_B: LanguageIdentifier = langid!("la-LB"); + const LANG_C: LanguageIdentifier = langid!("la-LC"); + + let config = I18nConfig::new(LANG_A) + .with_locale((LANG_B, "lang = lang_b")) + .with_locale((LANG_C, PathBuf::new())); + + assert_eq!( + config, + I18nConfig { + id: LANG_A, + fallback: None, + locale_resources: vec![ + LocaleResource::Static("lang = lang_b"), + LocaleResource::Path(PathBuf::new()) + ], + locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]), + } + ); + } + + #[test] + fn can_add_locale_string_to_config() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + const LANG_B: LanguageIdentifier = langid!("la-LB"); + + let config = I18nConfig::new(LANG_A).with_locale((LANG_B, "lang = lang_b")); + + assert_eq!( + config, + I18nConfig { + id: LANG_A, + fallback: None, + locale_resources: vec![LocaleResource::Static("lang = lang_b")], + locales: HashMap::from([(LANG_B, 0)]), + } + ); + } + + #[test] + fn can_add_shared_locale_string_to_config() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + const LANG_B: LanguageIdentifier = langid!("la-LB"); + const LANG_C: LanguageIdentifier = langid!("la-LC"); + + let shared_string = "lang = a language"; + let config = I18nConfig::new(LANG_A) + .with_locale((LANG_B, shared_string)) + .with_locale((LANG_C, shared_string)); + + assert_eq!( + config, + I18nConfig { + id: LANG_A, + fallback: None, + locale_resources: vec![LocaleResource::Static(shared_string)], + locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]), + } + ); + } + + #[test] + fn can_add_locale_pathbuf_to_config() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + const LANG_C: LanguageIdentifier = langid!("la-LC"); + + let config = I18nConfig::new(LANG_A) + .with_locale((LANG_C, PathBuf::from("./test/data/fallback/la.ftl"))); + + assert_eq!( + config, + I18nConfig { + id: LANG_A, + fallback: None, + locale_resources: vec![LocaleResource::Path(PathBuf::from( + "./test/data/fallback/la.ftl" + ))], + locales: HashMap::from([(LANG_C, 0)]), + } + ); + } + + #[test] + fn can_add_shared_locale_pathbuf_to_config() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + const LANG_B: LanguageIdentifier = langid!("la-LB"); + const LANG_C: LanguageIdentifier = langid!("la-LC"); + + let shared_pathbuf = PathBuf::from("./test/data/fallback/la.ftl"); + + let config = I18nConfig::new(LANG_A) + .with_locale((LANG_B, shared_pathbuf.clone())) + .with_locale((LANG_C, shared_pathbuf.clone())); + + assert_eq!( + config, + I18nConfig { + id: LANG_A, + fallback: None, + locale_resources: vec![LocaleResource::Path(shared_pathbuf)], + locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]), + } + ); + } + + #[test] + fn can_auto_add_locales_folder_to_config() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + + let root_path_str = &format!("{}/tests/data/fallback/", env!("CARGO_MANIFEST_DIR")); + let pathbuf = PathBuf::from(root_path_str); + + let config = I18nConfig::new(LANG_A) + .try_with_auto_locales(pathbuf) + .ok() + .unwrap(); + + let expected_locales = [ + "fb-FB", + "la", + "la-Scpt", + "la-Scpt-LA", + "la-Scpt-LA-variants", + ]; + + assert_eq!(config.locales.len(), expected_locales.len()); + assert_eq!(config.locale_resources.len(), expected_locales.len()); + + expected_locales.into_iter().for_each(|l| { + let expected_filename = format!("{root_path_str}/{l}.ftl"); + let id = LanguageIdentifier::from_bytes(l.as_bytes()).unwrap(); + assert!(config.locales.get(&id).is_some()); + assert!(config + .locale_resources + .contains(&LocaleResource::Path(PathBuf::from(expected_filename)))); + }); + } + + #[test] + fn can_auto_add_locales_file_to_config() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + + let path_str = &format!( + "{}/tests/data/fallback/fb-FB.ftl", + env!("CARGO_MANIFEST_DIR") + ); + let pathbuf = PathBuf::from(path_str); + + let config = I18nConfig::new(LANG_A) + .try_with_auto_locales(pathbuf.clone()) + .ok() + .unwrap(); + + assert_eq!(config.locales.len(), 1); + assert!(config.locales.get(&langid!("fb-FB")).is_some()); + + assert_eq!(config.locale_resources.len(), 1); + assert!(config + .locale_resources + .contains(&LocaleResource::Path(pathbuf))); + } + + #[test] + fn will_fail_auto_locales_with_invalid_folder() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + + let root_path_str = &format!("{}/non_existing_path/", env!("CARGO_MANIFEST_DIR")); + let pathbuf = PathBuf::from(root_path_str); + + let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf); + assert_eq!(config.is_err(), true); + } + + #[test] + fn will_fail_auto_locales_with_invalid_file() { + const LANG_A: LanguageIdentifier = langid!("la-LA"); + + let path_str = &format!( + "{}/tests/data/fallback/invalid_language_id.ftl", + env!("CARGO_MANIFEST_DIR") + ); + let pathbuf = PathBuf::from(path_str); + + let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf); + assert_eq!(config.is_err(), true); + } +} diff --git a/dioxus-i18n/tests/README.md b/dioxus-i18n/tests/README.md new file mode 100644 index 0000000..13b201b --- /dev/null +++ b/dioxus-i18n/tests/README.md @@ -0,0 +1,13 @@ +# Note + +//***************************************************************************** +// +// This set of tests takes a heavy handed approach to errors, whereby the +// process is exited. This is done because panic! and assert_eq! failures +// are trapped within `dioxus::runtime::RuntimeGuard`. +// Unfortunately panic! is still made silent. +// +// Errors will be shown with: +// cargo test -- --nocapture +// +//***************************************************************************** diff --git a/dioxus-i18n/tests/common/mod.rs b/dioxus-i18n/tests/common/mod.rs new file mode 100644 index 0000000..541407f --- /dev/null +++ b/dioxus-i18n/tests/common/mod.rs @@ -0,0 +1,3 @@ +mod test_hook; + +pub(crate) use test_hook::test_hook; diff --git a/dioxus-i18n/tests/common/test_hook.rs b/dioxus-i18n/tests/common/test_hook.rs new file mode 100644 index 0000000..be88689 --- /dev/null +++ b/dioxus-i18n/tests/common/test_hook.rs @@ -0,0 +1,85 @@ +// Lifted from: https://dioxuslabs.com/learn/0.6/cookbook/testing +// +// Much curtialed functionality and massaged to use in the local testing +// here. This hook isn't intended for reuse. +// + +use dioxus::{dioxus_core::NoOpMutations, prelude::*}; +use futures::FutureExt; + +use std::{cell::RefCell, fmt::Debug, rc::Rc}; + +pub(crate) fn test_hook( + initialize: impl FnMut() -> V + 'static, + check: impl FnMut(V, &mut Assertions) + 'static, +) { + #[derive(Props)] + struct MockAppComponent { + hook: Rc>, + check: Rc>, + } + + impl PartialEq for MockAppComponent { + fn eq(&self, _: &Self) -> bool { + true + } + } + + impl Clone for MockAppComponent { + fn clone(&self) -> Self { + Self { + hook: self.hook.clone(), + check: self.check.clone(), + } + } + } + + fn mock_app V, C: FnMut(V, &mut Assertions), V>( + props: MockAppComponent, + ) -> Element { + let value = props.hook.borrow_mut()(); + + let mut assertions = Assertions::new(); + + props.check.borrow_mut()(value, &mut assertions); + + rsx! { div {} } + } + + let mut vdom = VirtualDom::new_with_props( + mock_app, + MockAppComponent { + hook: Rc::new(RefCell::new(initialize)), + check: Rc::new(RefCell::new(check)), + }, + ); + + vdom.rebuild_in_place(); + + while vdom.wait_for_work().now_or_never().is_some() { + vdom.render_immediate(&mut NoOpMutations); + } + +} + +#[derive(Debug)] +pub(crate) struct Assertions {} + +impl Assertions { + pub fn new() -> Self { + Self {} + } + + pub fn assert(&mut self, actual: T, expected: T, id: &str) + where + T: PartialEq + Debug, + { + if actual != expected { + eprintln!( + "***** ERROR in {}: actual: '{:?}' != expected: '{:?}' *****\n", + id, actual, expected + ); + std::process::exit(-1); + }; + } +} diff --git a/dioxus-i18n/tests/data/fallback/fb-FB.ftl b/dioxus-i18n/tests/data/fallback/fb-FB.ftl new file mode 100644 index 0000000..1d0304e --- /dev/null +++ b/dioxus-i18n/tests/data/fallback/fb-FB.ftl @@ -0,0 +1,5 @@ +fallback = fallback only +language = fallback language +script = fallback script +region = fallback region +variants = fallback variants diff --git a/dioxus-i18n/tests/data/fallback/la-Scpt-LA-variants.ftl b/dioxus-i18n/tests/data/fallback/la-Scpt-LA-variants.ftl new file mode 100644 index 0000000..7b727e5 --- /dev/null +++ b/dioxus-i18n/tests/data/fallback/la-Scpt-LA-variants.ftl @@ -0,0 +1 @@ +variants = variants only diff --git a/dioxus-i18n/tests/data/fallback/la-Scpt-LA.ftl b/dioxus-i18n/tests/data/fallback/la-Scpt-LA.ftl new file mode 100644 index 0000000..86bd82a --- /dev/null +++ b/dioxus-i18n/tests/data/fallback/la-Scpt-LA.ftl @@ -0,0 +1,2 @@ +region = region only +variants = region variants diff --git a/dioxus-i18n/tests/data/fallback/la-Scpt.ftl b/dioxus-i18n/tests/data/fallback/la-Scpt.ftl new file mode 100644 index 0000000..b635911 --- /dev/null +++ b/dioxus-i18n/tests/data/fallback/la-Scpt.ftl @@ -0,0 +1,3 @@ +script = script only +region = script region +variants = script variants diff --git a/dioxus-i18n/tests/data/fallback/la.ftl b/dioxus-i18n/tests/data/fallback/la.ftl new file mode 100644 index 0000000..f40c8b6 --- /dev/null +++ b/dioxus-i18n/tests/data/fallback/la.ftl @@ -0,0 +1,4 @@ +language = language only +script = language script +region = language region +variants = language variants diff --git a/dioxus-i18n/tests/data/i18n/en.ftl b/dioxus-i18n/tests/data/i18n/en.ftl new file mode 100644 index 0000000..0286e49 --- /dev/null +++ b/dioxus-i18n/tests/data/i18n/en.ftl @@ -0,0 +1,5 @@ +hello = Hello, {$name}! +simple = Hello, Zaphod! +my_component = My Component + .placeholder = Component's placeholder + .hint = Component's hint with parameter {$name} diff --git a/dioxus-i18n/tests/defects_spec.rs b/dioxus-i18n/tests/defects_spec.rs new file mode 100644 index 0000000..866d746 --- /dev/null +++ b/dioxus-i18n/tests/defects_spec.rs @@ -0,0 +1,44 @@ +mod common; +use common::*; + +use dioxus_i18n::{ + prelude::{use_init_i18n, I18n, I18nConfig}, + t, +}; +use unic_langid::{langid, LanguageIdentifier}; + +#[test] +fn issue_15_recent_change_to_t_macro_unnecessarily_breaks_v0_3_code_test_attr() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let name = "World"; + t!(&format!("hello"), name: name) + }); + proxy.assert(panic.is_ok(), true, "translate_from_static_source"); + proxy.assert( + panic.ok().unwrap(), + "Hello, \u{2068}World\u{2069}!".to_string(), + "translate_from_static_source", + ); + }); +} + +#[test] +fn issue_15_recent_change_to_t_macro_unnecessarily_breaks_v0_3_code_test_no_attr() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| t!(&format!("simple"))); + proxy.assert(panic.is_ok(), true, "translate_from_static_source"); + proxy.assert( + panic.ok().unwrap(), + "Hello, Zaphod!".to_string(), + "translate_from_static_source", + ); + }); +} + +const EN: LanguageIdentifier = langid!("en"); + +fn i18n_from_static() -> I18n { + let config = I18nConfig::new(EN).with_locale((EN, include_str!("./data/i18n/en.ftl"))); + use_init_i18n(|| config) +} diff --git a/dioxus-i18n/tests/graceful_fallback_spec.rs b/dioxus-i18n/tests/graceful_fallback_spec.rs new file mode 100644 index 0000000..08376be --- /dev/null +++ b/dioxus-i18n/tests/graceful_fallback_spec.rs @@ -0,0 +1,99 @@ +mod common; +use common::*; + +use dioxus_i18n::prelude::{use_init_i18n, I18n, I18nConfig}; +use unic_langid::{langid, LanguageIdentifier}; + +#[test] +fn exact_locale_match_will_use_translation() { + test_hook(i18n, |value, proxy| { + proxy.assert( + value + .try_translate("variants") + .expect("test message id must exist"), + "variants only".to_string(), + "exact_locale_match_will_use_translation", + ); + }); +} + +#[test] +fn non_exact_locale_match_will_use_region() { + test_hook(i18n, |value, proxy| { + proxy.assert( + value + .try_translate("region") + .expect("test message id must exist"), + "region only".to_string(), + "non_exact_locale_match_will_use_region", + ); + }); +} + +#[test] +fn non_exact_locale_match_will_use_script() { + test_hook(i18n, |value, proxy| { + proxy.assert( + value + .try_translate("script") + .expect("test message id must exist"), + "script only".to_string(), + "non_exact_locale_match_will_use_script", + ); + }); +} + +#[test] +fn non_exact_locale_match_will_use_language() { + test_hook(i18n, |value, proxy| { + proxy.assert( + value + .try_translate("language") + .expect("test message id must exist"), + "language only".to_string(), + "non_exact_locale_match_will_use_language", + ); + }); +} + +#[test] +fn no_locale_match_will_use_fallback() { + test_hook(i18n, |value, proxy| { + proxy.assert( + value + .try_translate("fallback") + .expect("test message id must exist"), + "fallback only".to_string(), + "no_locale_match_will_use_fallback", + ); + }); +} + +fn i18n() -> I18n { + const FALLBACK_LANG: LanguageIdentifier = langid!("fb-FB"); + const LANGUAGE_LANG: LanguageIdentifier = langid!("la"); + const SCRIPT_LANG: LanguageIdentifier = langid!("la-Scpt"); + const REGION_LANG: LanguageIdentifier = langid!("la-Scpt-LA"); + let variants_lang: LanguageIdentifier = langid!("la-Scpt-LA-variants"); + + let config = I18nConfig::new(variants_lang.clone()) + .with_locale((LANGUAGE_LANG, include_str!("../tests/data/fallback/la.ftl"))) + .with_locale(( + SCRIPT_LANG, + include_str!("../tests/data/fallback/la-Scpt.ftl"), + )) + .with_locale(( + REGION_LANG, + include_str!("../tests/data/fallback/la-Scpt-LA.ftl"), + )) + .with_locale(( + variants_lang.clone(), + include_str!("../tests/data/fallback/la-Scpt-LA-variants.ftl"), + )) + .with_locale(( + FALLBACK_LANG, + include_str!("../tests/data/fallback/fb-FB.ftl"), + )) + .with_fallback(FALLBACK_LANG); + use_init_i18n(|| config) +} diff --git a/dioxus-i18n/tests/macro_reexport_spec.rs b/dioxus-i18n/tests/macro_reexport_spec.rs new file mode 100644 index 0000000..42ce377 --- /dev/null +++ b/dioxus-i18n/tests/macro_reexport_spec.rs @@ -0,0 +1,85 @@ +// Test that macros work correctly when re-exported from another module +// This verifies that $crate is used correctly instead of hard-coded dioxus_i18n + +mod reexport_module { + // Re-export the macros as if they were from a different crate + pub use dioxus_i18n::{t, te, tid}; +} + +mod common; +use common::*; + +use dioxus_i18n::prelude::{use_init_i18n, I18n, I18nConfig}; +use unic_langid::{langid, LanguageIdentifier}; + +#[test] +fn reexported_t_macro_works() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let name = "World"; + reexport_module::t!("hello", name: name) + }); + proxy.assert(panic.is_ok(), true, "reexported_t_macro_works"); + proxy.assert( + panic.ok().unwrap(), + "Hello, \u{2068}World\u{2069}!".to_string(), + "reexported_t_macro_works", + ); + }); +} + +#[test] +fn reexported_te_macro_works() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let name = "World"; + reexport_module::te!("hello", name: name) + }); + proxy.assert(panic.is_ok(), true, "reexported_te_macro_works"); + proxy.assert( + panic.ok().unwrap().ok().unwrap(), + "Hello, \u{2068}World\u{2069}!".to_string(), + "reexported_te_macro_works", + ); + }); +} + +#[test] +fn reexported_tid_macro_works() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let name = "World"; + reexport_module::tid!("hello", name: name) + }); + proxy.assert(panic.is_ok(), true, "reexported_tid_macro_works"); + proxy.assert( + panic.ok().unwrap(), + "Hello, \u{2068}World\u{2069}!".to_string(), + "reexported_tid_macro_works", + ); + }); +} + +#[test] +fn reexported_macro_with_invalid_key_as_error() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| reexport_module::te!("invalid")); + proxy.assert( + panic.is_ok(), + true, + "reexported_macro_with_invalid_key_as_error", + ); + proxy.assert( + panic.ok().unwrap().err().unwrap().to_string(), + "message id not found for key: 'invalid'".to_string(), + "reexported_macro_with_invalid_key_as_error", + ); + }); +} + +const EN: LanguageIdentifier = langid!("en"); + +fn i18n_from_static() -> I18n { + let config = I18nConfig::new(EN).with_locale((EN, include_str!("./data/i18n/en.ftl"))); + use_init_i18n(|| config) +} diff --git a/dioxus-i18n/tests/translations_spec.rs b/dioxus-i18n/tests/translations_spec.rs new file mode 100644 index 0000000..0bddf7c --- /dev/null +++ b/dioxus-i18n/tests/translations_spec.rs @@ -0,0 +1,343 @@ +mod common; +use common::*; + +use dioxus_i18n::{ + prelude::{use_init_i18n, I18n, I18nConfig}, + t, te, tid, +}; +use unic_langid::{langid, LanguageIdentifier}; + +use std::path::PathBuf; + +#[test] +fn translate_from_static_source() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let name = "World"; + t!("hello", name: name) + }); + proxy.assert(panic.is_ok(), true, "translate_from_static_source"); + proxy.assert( + panic.ok().unwrap(), + "Hello, \u{2068}World\u{2069}!".to_string(), + "translate_from_static_source", + ); + }); +} + +#[test] +fn failed_to_translate_with_invalid_key() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let _ = &t!("invalid"); + }); + proxy.assert(panic.is_err(), true, "failed_to_translate_with_invalid_key"); + }); +} + +#[test] +fn failed_to_translate_with_invalid_key_as_error() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| te!("invalid")); + proxy.assert( + panic.is_ok(), + true, + "failed_to_translate_with_invalid_key_as_error", + ); + proxy.assert( + panic.ok().unwrap().err().unwrap().to_string(), + "message id not found for key: 'invalid'".to_string(), + "failed_to_translate_with_invalid_key_as_error", + ); + }); +} + +#[test] +fn failed_to_translate_with_invalid_key_with_args_as_error() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| te!("invalid", name: "")); + proxy.assert( + panic.is_ok(), + true, + "failed_to_translate_with_invalid_key_with_args_as_error", + ); + proxy.assert( + panic.ok().unwrap().err().unwrap().to_string(), + "message id not found for key: 'invalid'".to_string(), + "failed_to_translate_with_invalid_key_with_args_as_error", + ); + }); +} + +#[test] +fn failed_to_translate_with_invalid_key_as_id() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("invalid")); + proxy.assert( + panic.is_ok(), + true, + "failed_to_translate_with_invalid_key_as_id", + ); + proxy.assert( + panic.ok().unwrap(), + "message id not found for key: 'invalid'".to_string(), + "failed_to_translate_with_invalid_key_as_id", + ); + }); +} + +#[test] +fn failed_to_translate_with_invalid_key_with_args_as_id() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("invalid", name: "")); + proxy.assert( + panic.is_ok(), + true, + "failed_to_translate_with_invalid_key_with_args_as_id", + ); + proxy.assert( + panic.ok().unwrap(), + "message id not found for key: 'invalid'".to_string(), + "failed_to_translate_with_invalid_key_with_args_as_id", + ); + }); +} + +#[test] +fn translate_root_message_in_attributed_definition() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("my_component")); + proxy.assert( + panic.is_ok(), + true, + "translate_root_message_in_attributed_definition", + ); + proxy.assert( + panic.ok().unwrap(), + "My Component".to_string(), + "translate_root_message_in_attributed_definition", + ); + }); +} + +#[test] +fn translate_attribute_with_no_args_in_attributed_definition() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("my_component.placeholder")); + proxy.assert( + panic.is_ok(), + true, + "translate_attribute_with_no_args_in_attributed_definition", + ); + proxy.assert( + panic.ok().unwrap(), + "Component's placeholder".to_string(), + "translate_attribute_with_no_args_in_attributed_definition", + ); + }); +} + +#[test] +fn translate_attribute_with_args_in_attributed_definition() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("my_component.hint", name: "Zaphod")); + proxy.assert( + panic.is_ok(), + true, + "translate_attribute_with_args_in_attributed_definition", + ); + proxy.assert( + panic.ok().unwrap(), + "Component's hint with parameter \u{2068}Zaphod\u{2069}".to_string(), + "translate_attribute_with_args_in_attributed_definition", + ); + }); +} + +#[test] +fn fail_translate_invalid_attribute_with_no_args_in_attributed_definition() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("my_component.not_a_placeholder")); + proxy.assert( + panic.is_ok(), + true, + "fail_translate_invalid_attribute_with_no_args_in_attributed_definition", + ); + proxy.assert( + panic.ok().unwrap(), + "attribute id not found for key: 'my_component.not_a_placeholder'".to_string(), + "fail_translate_invalid_attribute_with_no_args_in_attributed_definition", + ); + }); +} + +#[test] +fn fail_translate_invalid_attribute_with_args_in_attributed_definition() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("my_component.not_a_hint", name: "Zaphod")); + proxy.assert( + panic.is_ok(), + true, + "fail_translate_invalid_attribute_with_args_in_attributed_definition", + ); + proxy.assert( + panic.ok().unwrap(), + "attribute id not found for key: 'my_component.not_a_hint'".to_string(), + "fail_translate_invalid_attribute_with_args_in_attributed_definition", + ); + }); +} + +#[test] +fn fail_translate_with_invalid_attribute_key() { + test_hook(i18n_from_static, |_, proxy| { + let panic = std::panic::catch_unwind(|| tid!("my_component.placeholder.invalid")); + proxy.assert( + panic.is_ok(), + true, + "fail_translate_with_invalid_attribute_key", + ); + proxy.assert( + panic.ok().unwrap(), + "invalid message id: 'my_component.placeholder.invalid'".to_string(), + "fail_translate_with_invalid_attribute_key", + ); + }); +} + +#[test] +fn translate_from_dynamic_source() { + test_hook(i18n_from_dynamic, |_, proxy| { + let panic = std::panic::catch_unwind(|| { + let name = "World"; + t!("hello", name: name) + }); + proxy.assert(panic.is_ok(), true, "translate_from_dynamic_source"); + proxy.assert( + panic.ok().unwrap(), + "Hello, \u{2068}World\u{2069}!".to_string(), + "translate_from_dynamic_source", + ); + }); +} + +#[test] +#[should_panic] +#[ignore] // Panic hidden within test_hook. +fn fail_translate_from_dynamic_source_when_file_does_not_exist() { + test_hook(i18n_from_dynamic_none_existing, |_, _| unreachable!()); +} + +#[test] +fn initial_language_is_set() { + test_hook(i18n_from_static, |value, proxy| { + proxy.assert(value.language(), EN, "initial_language_is_set"); + }); +} + +#[test] +fn language_can_be_set() { + test_hook(i18n_from_static, |mut value, proxy| { + value + .try_set_language(JP) + .expect("set_language must succeed"); + proxy.assert(value.language(), JP, "language_can_be_set"); + }); +} + +#[test] +fn no_default_fallback_language() { + test_hook(i18n_from_static, |value, proxy| { + proxy.assert( + format!("{:?}", value.fallback_language()), + "None".to_string(), + "no_default_fallback_language", + ); + }); +} + +#[test] +fn some_default_fallback_language() { + test_hook(i18n_from_static_with_fallback, |value, proxy| { + proxy.assert( + format!("{:?}", value.fallback_language().map(|l| l.to_string())), + "Some(\"jp\")".to_string(), + "some_default_fallback_language", + ); + }); +} + +#[test] +fn fallback_language_can_be_set() { + test_hook(i18n_from_static_with_fallback, |mut value, proxy| { + value + .try_set_fallback_language(EN) + .expect("try_set_fallback_language must succeed"); + proxy.assert( + format!("{:?}", value.fallback_language().map(|l| l.to_string())), + "Some(\"en\")".to_string(), + "fallback_language_can_be_set", + ); + }); +} + +#[test] +fn fallback_language_must_have_locale_translation() { + test_hook(i18n_from_static_with_fallback, |mut value, proxy| { + let result = value.try_set_fallback_language(IT); + + proxy.assert( + result.is_err(), + true, + "fallback_language_must_have_locale_translation", + ); + proxy.assert( + result.err().unwrap().to_string(), + "fallback for \"it\" must have locale".to_string(), + "fallback_language_must_have_locale_translation", + ); + proxy.assert( + format!("{:?}", value.fallback_language().map(|l| l.to_string())), + "Some(\"jp\")".to_string(), + "fallback_language_must_have_locale_translation", + ); + }); +} + +const EN: LanguageIdentifier = langid!("en"); +const IT: LanguageIdentifier = langid!("it"); +const JP: LanguageIdentifier = langid!("jp"); + +fn i18n_from_static() -> I18n { + let config = I18nConfig::new(EN).with_locale((EN, include_str!("./data/i18n/en.ftl"))); + use_init_i18n(|| config) +} + +fn i18n_from_static_with_fallback() -> I18n { + let config = I18nConfig::new(EN) + .with_locale((EN, include_str!("./data/i18n/en.ftl"))) + .with_fallback(JP); + use_init_i18n(|| config) +} + +fn i18n_from_dynamic() -> I18n { + let config = I18nConfig::new(EN).with_locale(( + EN, + PathBuf::from(format!( + "{}/tests/data/i18n/en.ftl", + env!("CARGO_MANIFEST_DIR") + )), + )); + use_init_i18n(|| config) +} + +fn i18n_from_dynamic_none_existing() -> I18n { + let config = I18nConfig::new(EN).with_locale(( + EN, + PathBuf::from(format!( + "{}/tests/data/i18n/non_existing.ftl", + env!("CARGO_MANIFEST_DIR") + )), + )); + use_init_i18n(|| config) +} diff --git a/docker/dev/app/Dockerfile b/docker/dev/app/Dockerfile index 133e8b9..68d9b9a 100644 --- a/docker/dev/app/Dockerfile +++ b/docker/dev/app/Dockerfile @@ -6,7 +6,7 @@ RUN useradd -m -u 1000 -s /bin/bash app_user \ USER app_user RUN cargo install --git https://github.com/diesel-rs/diesel --rev 2e85ba060d3d70ea605ea58a79b8a435749a7adc --locked diesel_cli \ - && cargo install --git https://github.com/DioxusLabs/dioxus --rev 8f8b58ea80ba0ec8057807bcd58fb609f7a5f2b1 --locked dioxus-cli + && cargo install --git https://github.com/DioxusLabs/dioxus --rev 22b06badde44ba1af0fcf339c91b66483175b660 --locked dioxus-cli COPY --chown=app_user . /srv/app WORKDIR /srv/app diff --git a/docker/prod/app/Dockerfile b/docker/prod/app/Dockerfile index f480346..1676086 100644 --- a/docker/prod/app/Dockerfile +++ b/docker/prod/app/Dockerfile @@ -1,7 +1,7 @@ FROM rust:1.92.0-bookworm@sha256:9676d0547a259997add8f5924eb6b959c589ed39055338e23b99aba7958d6d31 AS builder_base RUN cargo install --git https://github.com/diesel-rs/diesel --rev 2e85ba060d3d70ea605ea58a79b8a435749a7adc --locked diesel_cli \ - && cargo install --git https://github.com/DioxusLabs/dioxus --rev 8f8b58ea80ba0ec8057807bcd58fb609f7a5f2b1 --locked dioxus-cli --features disable-telemetry + && cargo install --git https://github.com/DioxusLabs/dioxus --rev 22b06badde44ba1af0fcf339c91b66483175b660 --locked dioxus-cli --features disable-telemetry COPY . /srv/app WORKDIR /srv/app diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 89e3355..447b3ee 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -1,6 +1,9 @@ +use std::fmt::Display; + use dioxus::{ CapturedError, - fullstack::{Loader, Loading, WebSocketOptions, use_websocket}, + document::document, + fullstack::{Loader, Loading, WebSocketOptions, WebsocketState, use_websocket}, prelude::*, }; use serde::{Serialize, de::DeserializeOwned}; @@ -13,6 +16,35 @@ use crate::{ }, }; +fn use_on_document_visibility_change(mut callback: impl FnMut() + 'static) { + let callback = use_callback(move |_| callback()); + use_effect(move || { + spawn(async move { + let mut eval = document::eval( + r#" + document.addEventListener("visibilitychange", () => { + dioxus.send(0); + }); + + // window.addEventListener("focus", () => resume()); + + // Keep this eval alive so dioxus.send keeps working. + // while (true) { + // await new Promise(r => setTimeout(r, 3600_000)); + // } + "#, + ); + loop { + eval.recv::() + .await + .expect("The JS code returned a value not parsable to `u8`."); + callback.call(()); + log("received resume"); + } + }); + }); +} + #[allow(clippy::result_large_err)] fn sort_loader_result( result: Result>, Loading>, @@ -24,6 +56,19 @@ fn sort_loader_result( }) } +pub static LOG_MESSAGES: GlobalSignal> = + Signal::global(|| vec!["=== LOG ===".to_string()]); + +fn log(message: impl Display) { + let mut old = LOG_MESSAGES(); + old.push(format!( + "[{}] {}", + chrono::Local::now().format("%H:%M:%S"), + message.to_string() + )); + *LOG_MESSAGES.write() = old; +} + #[allow(clippy::result_large_err)] fn use_loader_with_update_subscription( mut future: impl FnMut() -> F + 'static, @@ -34,17 +79,41 @@ where E: Into + 'static, { let mut refresh_tick = use_signal(|| 0u64); + let mut websocket_reset_tick = use_signal(|| 0u64); let loader = use_loader(move || { let _ = refresh_tick(); // Read => dependency. future() }); - let mut socket = use_websocket(|| subscribe_to_updates(WebSocketOptions::default())); - use_future(move || async move { - while socket.recv().await.is_ok() { - refresh_tick += 1; - } + use_effect(move || { + let initial_websocket_reset_tick = websocket_reset_tick(); + spawn(async move { + log("spawning based on ws_gen"); + let Ok(socket) = + subscribe_to_updates(WebSocketOptions::new().with_automatic_reconnect()).await + else { + log("failed to spawn"); + return; + }; + while socket.recv().await.is_ok() { + log("running the future recv"); + if websocket_reset_tick() != initial_websocket_reset_tick { + log("new WS gen, breaking"); + // A new WebSocket has been created (a new task spawned), cleaning this one up. + break; + } + refresh_tick += 1; + } + log("future ending"); + }); + }); + + /* So that when the device goes to sleep or suspends the app, the WebSocket gets recreated on + waking up. */ + use_on_document_visibility_change(move || { + websocket_reset_tick += 1; + refresh_tick += 1; }); loader