From af095684a147f4370b29f10df8191fcd371fc14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sat, 24 Jan 2026 12:15:23 +0100 Subject: [PATCH] temp --- Cargo.lock | 122 +-- 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 +++++++++ 37 files changed, 2595 insertions(+), 85 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 8a7b955..d45a4b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,8 +634,7 @@ dependencies = [ [[package]] name = "const-serialize" version = "0.8.0-alpha.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +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", @@ -656,8 +655,7 @@ dependencies = [ [[package]] name = "const-serialize-macro" version = "0.8.0-alpha.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "proc-macro2", "quote", @@ -1076,8 +1074,7 @@ dependencies = [ [[package]] name = "dioxus" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-asset-resolver", "dioxus-cli-config", @@ -1110,8 +1107,7 @@ dependencies = [ [[package]] name = "dioxus-asset-resolver" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "http", @@ -1131,8 +1127,7 @@ dependencies = [ [[package]] name = "dioxus-cli-config" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "wasm-bindgen", ] @@ -1140,8 +1135,7 @@ dependencies = [ [[package]] name = "dioxus-config-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "proc-macro2", "quote", @@ -1150,14 +1144,12 @@ dependencies = [ [[package]] name = "dioxus-config-macros" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" [[package]] name = "dioxus-core" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "const_format", @@ -1178,8 +1170,7 @@ dependencies = [ [[package]] name = "dioxus-core-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "convert_case 0.8.0", "dioxus-rsx", @@ -1191,14 +1182,12 @@ dependencies = [ [[package]] name = "dioxus-core-types" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" [[package]] name = "dioxus-desktop" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "async-trait", "base64", @@ -1252,8 +1241,7 @@ dependencies = [ [[package]] name = "dioxus-devtools" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1272,8 +1260,7 @@ dependencies = [ [[package]] name = "dioxus-devtools-types" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "serde", @@ -1283,8 +1270,7 @@ dependencies = [ [[package]] name = "dioxus-document" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-core-macro", @@ -1310,8 +1296,7 @@ dependencies = [ [[package]] name = "dioxus-fullstack" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db1f8b70338072ec408b48d09c96559cf071f87847465d8161294197504c498" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "async-stream", @@ -1375,8 +1360,7 @@ dependencies = [ [[package]] name = "dioxus-fullstack-core" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda8b152e85121243741b9d5f2a3d8cb3c47a7b2299e902f98b6a7719915b0a2" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "axum-core", @@ -1403,8 +1387,7 @@ dependencies = [ [[package]] name = "dioxus-fullstack-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "255104d4a4f278f1a8482fa30536c91d22260c561c954b753e72987df8d65b2e" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "const_format", "convert_case 0.8.0", @@ -1417,8 +1400,7 @@ dependencies = [ [[package]] name = "dioxus-history" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "tracing", @@ -1427,8 +1409,7 @@ dependencies = [ [[package]] name = "dioxus-hooks" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1443,8 +1424,7 @@ dependencies = [ [[package]] name = "dioxus-html" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "async-trait", "bytes", @@ -1470,8 +1450,7 @@ dependencies = [ [[package]] name = "dioxus-html-internal-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1482,8 +1461,6 @@ dependencies = [ [[package]] name = "dioxus-i18n" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceebf715471a986307cdfe422d645c0784602003758171102ba9225624be9f55" dependencies = [ "dioxus", "fluent", @@ -1495,8 +1472,7 @@ dependencies = [ [[package]] name = "dioxus-interpreter-js" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-core-types", @@ -1515,8 +1491,7 @@ dependencies = [ [[package]] name = "dioxus-liveview" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "axum", "dioxus-cli-config", @@ -1543,8 +1518,7 @@ dependencies = [ [[package]] name = "dioxus-logger" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "tracing", @@ -1555,8 +1529,7 @@ dependencies = [ [[package]] name = "dioxus-router" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d5b31f9e27231389bf5a117b7074d22d8c58358b484a2558e56fbab20e64ca4" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1576,8 +1549,7 @@ dependencies = [ [[package]] name = "dioxus-router-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "838b9b441a95da62b39cae4defd240b5ebb0ec9f2daea1126099e00a838dc86f" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "base16", "digest", @@ -1591,8 +1563,7 @@ dependencies = [ [[package]] name = "dioxus-rsx" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "proc-macro2", "proc-macro2-diagnostics", @@ -1604,8 +1575,7 @@ dependencies = [ [[package]] name = "dioxus-server" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adb2d4e0f0f3a157bda6af2d90f22bac40070e509a66e3ea58abf3b35f904c" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "anyhow", "async-trait", @@ -1662,8 +1632,7 @@ dependencies = [ [[package]] name = "dioxus-signals" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "futures-channel", @@ -1678,8 +1647,7 @@ dependencies = [ [[package]] name = "dioxus-ssr" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44cf9294a21fcd1098e02ad7a3ba61b99be8310ad3395fecf8210387c83f26b9" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "askama_escape", "dioxus-core", @@ -1690,8 +1658,7 @@ dependencies = [ [[package]] name = "dioxus-stores" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1702,8 +1669,7 @@ dependencies = [ [[package]] name = "dioxus-stores-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1714,8 +1680,7 @@ dependencies = [ [[package]] name = "dioxus-web" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -2345,8 +2310,7 @@ dependencies = [ [[package]] name = "generational-box" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "parking_lot", "tracing", @@ -3150,8 +3114,7 @@ dependencies = [ [[package]] name = "lazy-js-bundle" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" [[package]] name = "lazy_static" @@ -3333,8 +3296,7 @@ dependencies = [ [[package]] name = "manganis" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", @@ -3345,8 +3307,7 @@ dependencies = [ [[package]] name = "manganis-core" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", @@ -3359,8 +3320,7 @@ dependencies = [ [[package]] name = "manganis-macro" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "dunce", "macro-string", @@ -5144,8 +5104,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subsecond" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "js-sys", "libc", @@ -5163,8 +5122,7 @@ dependencies = [ [[package]] name = "subsecond-types" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +source = "git+https://github.com/matous-volf/dioxus?rev=627d5ca5b80aeed57c23e253024665f103117f5e#627d5ca5b80aeed57c23e253024665f103117f5e" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 18c840c..44c77a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,9 @@ edition = "2024" [dependencies] chrono = { version = "0.4.43", features = ["serde", "unstable-locales"] } # Remember to update the CLI as well. -dioxus = { version = "0.7.3", 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.3", 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.149" } @@ -35,7 +35,7 @@ time = { version = "0.3.45", optional = true } tokio = { version = "1.49.0", optional = true } async-std = { version = "1.13.2", optional = true } -dioxus-i18n = "0.5.1" +dioxus-i18n = { path = "dioxus-i18n" } voca_rs = "1.15.2" load-dotenv = "0.1.2" # TODO: Switch to upstream once it merges the changes. 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) +}