diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11d150c4..c0c6546a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,6 +43,8 @@ repos: --, -D, clippy::print_stdout, + -D, + warnings, ] - id: clippy alias: clippy-hydrate @@ -55,6 +57,8 @@ repos: --, -D, clippy::print_stdout, + -D, + warnings, ] - id: clippy alias: clippy-ssr-actix @@ -68,6 +72,8 @@ repos: --, -D, clippy::print_stdout, + -D, + warnings, ] - id: clippy alias: clippy-ssr-axum @@ -81,6 +87,8 @@ repos: --, -D, clippy::print_stdout, + -D, + warnings, ] - repo: https://github.com/mondeja/rust-pc-hooks rev: v1.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c290049..9ca80ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -## Unreleased - [0.0.29] +## 2024-06-03 - [0.0.29] ### Breaking changes @@ -8,7 +8,14 @@ Use `` instead of ``. - Removed `I18n.default_language()` method. Use `i18n.languages[0]`. -- Removed `I18n.tr()` and `I18n.trs()` methods. Use `tr!` macro. +- Removed `I18n.tr()` and `I18n.trs()` methods. Use the `tr!` macro. +- `tr!` and `move_tr!` macros only accepts literal strings as the message name + (first argument) and in the keys of translation arguments. + +### New features + +- Add `check_translations` argument to `leptos_fluent!` macro to check + translations at compile time. ## 2024-06-01 - [0.0.28] @@ -132,7 +139,7 @@ - Added all ISO-639-1 and ISO-639-2 languages. -[0.0.29]: https://github.com/mondeja/leptos-fluent/compare/v0.0.28...master +[0.0.29]: https://github.com/mondeja/leptos-fluent/compare/v0.0.28...v0.0.29 [0.0.28]: https://github.com/mondeja/leptos-fluent/compare/v0.0.27...v0.0.28 [0.0.27]: https://github.com/mondeja/leptos-fluent/compare/v0.0.26...v0.0.27 [0.0.26]: https://github.com/mondeja/leptos-fluent/compare/v0.0.25...v0.0.26 diff --git a/Cargo.lock b/Cargo.lock index 51600278..9f0bd8c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,6 +1059,12 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "globset" version = "0.4.14" @@ -1404,9 +1410,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "leptos" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f79fe71c41f5a0506c273f6698a1971bb994ef52a88aeaf4eccb159fcd1e11" +checksum = "b5fae88b21265cbb847c891d7cf660e284a1282da15459691992be9358e906fb" dependencies = [ "cfg-if", "leptos_config", @@ -1424,12 +1430,11 @@ dependencies = [ [[package]] name = "leptos-fluent" -version = "0.0.28" +version = "0.0.29" dependencies = [ "fluent-templates", "leptos", "leptos-fluent-macros", - "once_cell", "wasm-bindgen", "web-sys", ] @@ -1457,8 +1462,14 @@ dependencies = [ [[package]] name = "leptos-fluent-macros" -version = "0.0.28" +version = "0.0.29" dependencies = [ + "fluent-syntax", + "fluent-templates", + "flume", + "glob", + "ignore", + "pathdiff", "proc-macro2", "quote", "serde_json", @@ -1519,9 +1530,9 @@ dependencies = [ [[package]] name = "leptos_actix" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be151f99f7fb3a220152d976d23f815da93ae5879cfe8e6fa921455d1ed597c" +checksum = "4e1c8b5dd701f0354f61997d9efb05c08e7d81077f3b4021a8f2d08ef8738d95" dependencies = [ "actix-http", "actix-web", @@ -1541,9 +1552,9 @@ dependencies = [ [[package]] name = "leptos_axum" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590b945e92fe5790820f348f82c0937ea9b58ba5976459377134946eb8ed449b" +checksum = "9ac4250852926ccc78245dea46196217977342dda58cf7f19d35d4efcb0cd3ba" dependencies = [ "axum", "cfg-if", @@ -1565,9 +1576,9 @@ dependencies = [ [[package]] name = "leptos_config" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3caa62f62e8e575051305ed6ac5648dc695f202c7220a98aca21cf4e9a978cf" +checksum = "ec33b6f994829469ba7c62bfd5fb572a639071d0de715a41c2aa0df86301a4fa" dependencies = [ "config", "regex", @@ -1578,9 +1589,9 @@ dependencies = [ [[package]] name = "leptos_dom" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e84abb02efd711f0842ff3e444292bfa9963811c37e7be3980a052628ed63b" +checksum = "867d2afc153cc0f1d6f775872d5dfc409385f4d8544831ee45f720d88f363e6b" dependencies = [ "async-recursion", "cfg-if", @@ -1608,9 +1619,9 @@ dependencies = [ [[package]] name = "leptos_hot_reload" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ee917deba2522a7f22ca826df84a8800d66ac918e58b489875e1f4fb8bc6b8" +checksum = "ec5ce56051f2eff2c4736b7a2056177e67be19597b767ff72fbab20917a7422d" dependencies = [ "anyhow", "camino", @@ -1626,9 +1637,9 @@ dependencies = [ [[package]] name = "leptos_integration_utils" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f504afe3e2ac30ca15ba9b74d27243e8919e93d1f78bad32e5e8ec23eaca4b" +checksum = "2ea6256cc3c42b516ee6a7f5885c6e4dcfc66ec84d1ae725dea17e3d22a3803d" dependencies = [ "futures", "leptos", @@ -1640,9 +1651,9 @@ dependencies = [ [[package]] name = "leptos_macro" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31197c2c624c405bec5f1dc8dd5d903a6030d1f0b8e362a01a3a215fcbad5051" +checksum = "019016cc0193831660a7794aa046e4c01617d97ccb2f403a5c10b21744391a71" dependencies = [ "attribute-derive", "cfg-if", @@ -1663,9 +1674,9 @@ dependencies = [ [[package]] name = "leptos_meta" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00900e82a4ca892828db93fce1d4c009480ff3959406e6965aa937c8bab7403" +checksum = "873ec368535d9971df4848ca21e91aa96fa00a6884258ab8f926db69ea97f547" dependencies = [ "cfg-if", "indexmap", @@ -1677,15 +1688,16 @@ dependencies = [ [[package]] name = "leptos_reactive" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057de706568ce8f1f223ae69f796c10ad0563ad270d10717e70c2b2d22eefa60" +checksum = "076064e3c84e3aa12d4bad283e82ba5968675f5f9714d04a5c38f169cd4f26b5" dependencies = [ "base64", "cfg-if", "futures", "indexmap", "js-sys", + "oco_ref", "paste", "pin-project", "rustc-hash", @@ -1704,9 +1716,9 @@ dependencies = [ [[package]] name = "leptos_router" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcc2a95a20c8f41adb39770e65c48ffe33cd9503b83669c54edd9b33ba8aa8" +checksum = "d66db10225f0caf60b0183935b288791ac3494ca6e1306d415ea97287b6c2db5" dependencies = [ "cached", "cfg-if", @@ -1725,7 +1737,7 @@ dependencies = [ "send_wrapper", "serde", "serde_json", - "serde_qs", + "serde_qs 0.13.0", "thiserror", "tracing", "url", @@ -1736,9 +1748,9 @@ dependencies = [ [[package]] name = "leptos_server" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f197d9cbf7db3a09a5d6c561ad0547ad6bf4326bc6bc454171d5f6ee94f745a" +checksum = "603064a2d7ac46dba4b3ed5397a475076f9738918dd605670869dfe877d5966c" dependencies = [ "inventory", "lazy_static", @@ -1938,6 +1950,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "oco_ref" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51ebcefb2f0b9a5e0bea115532c8ae4215d1b01eff176d0f4ba4192895c2708" +dependencies = [ + "serde", + "thiserror", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -2093,9 +2115,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.84" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96c6a92621310b51366f1e28d05ef11489516e93be030060e5fc12024a49d6" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" dependencies = [ "unicode-ident", ] @@ -2380,6 +2402,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + [[package]] name = "serde_spanned" version = "0.6.6" @@ -2425,9 +2458,9 @@ dependencies = [ [[package]] name = "server_fn" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "536a5b959673643ee01e59ae41bf01425482c8070dee95d7061ee2d45296b59c" +checksum = "b06e6e5467a2cd93ce1accfdfd8b859404f0b3b2041131ffd774fabf666b8219" dependencies = [ "actix-web", "axum", @@ -2446,7 +2479,7 @@ dependencies = [ "send_wrapper", "serde", "serde_json", - "serde_qs", + "serde_qs 0.12.0", "server_fn_macro_default", "thiserror", "tower", @@ -2461,9 +2494,9 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064dd9b256e78bf2886774f265cc34d2aefdd05b430c58c78a69eceef21b5e60" +checksum = "09c216bb1c1ac890151397643c663c875a1836adf0b269be4e389cb1b48c173c" dependencies = [ "const_format", "convert_case 0.6.0", @@ -2475,9 +2508,9 @@ dependencies = [ [[package]] name = "server_fn_macro_default" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad11700cbccdbd313703916eb8c97301ee423c4a06e5421b77956fdcb36a9f" +checksum = "00783df297ec85ea605779f2fef9cbec98981dffe2e01e1a9845c102ee1f1ae6" dependencies = [ "server_fn_macro", "syn 2.0.66", diff --git a/Cargo.toml b/Cargo.toml index 1b525f98..5533e54c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,8 @@ members = [ ] resolver = "2" -[workspace.package] -edition = "2021" -version = "0.1.0" -license = "MIT" -documentation = "https://docs.rs/leptos-fluent" -repository = "https://github.com/mondeja/leptos-fluent" +[workspace.dependencies] +fluent-templates = "0.9" [profile.wasm-release] inherits = "release" diff --git a/README.md b/README.md index 67371e00..df5826ea 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add the following to your `Cargo.toml` file: ```toml [dependencies] -leptos-fluent = "0.0.28" +leptos-fluent = "0.0.29" fluent-templates = "0.9" [features] diff --git a/examples/csr-complete/src/lib.rs b/examples/csr-complete/src/lib.rs index db866553..dd88a7cd 100644 --- a/examples/csr-complete/src/lib.rs +++ b/examples/csr-complete/src/lib.rs @@ -14,6 +14,8 @@ pub fn App() -> impl IntoView { leptos_fluent! {{ translations: TRANSLATIONS, languages: "./locales/languages.json", + locales: "./locales", + check_translations: "./src/**/*.rs", sync_html_tag_lang: true, url_param: "lang", initial_language_from_url_param: true, diff --git a/examples/ssr-hydrate-actix/src/app.rs b/examples/ssr-hydrate-actix/src/app.rs index 4f5ffd85..ba6578a6 100644 --- a/examples/ssr-hydrate-actix/src/app.rs +++ b/examples/ssr-hydrate-actix/src/app.rs @@ -17,6 +17,7 @@ pub fn App() -> impl IntoView { leptos_fluent! {{ translations: TRANSLATIONS, locales: "./locales", + check_translations: "./src/**/*.rs", cookie_name: "lang", initial_language_from_cookie: true, set_language_to_cookie: true, diff --git a/examples/ssr-hydrate-axum/locales/en/main.ftl b/examples/ssr-hydrate-axum/locales/en/main.ftl index d4cf2070..8ce537dc 100644 --- a/examples/ssr-hydrate-axum/locales/en/main.ftl +++ b/examples/ssr-hydrate-axum/locales/en/main.ftl @@ -4,3 +4,4 @@ an-error-happened = An error happened error-msg = Error message: { $msg } error-code = Error code: { $code } a-translated-header = A translated header +not-found = Not found diff --git a/examples/ssr-hydrate-axum/locales/es/main.ftl b/examples/ssr-hydrate-axum/locales/es/main.ftl index 2f547ee4..2c85ddaa 100644 --- a/examples/ssr-hydrate-axum/locales/es/main.ftl +++ b/examples/ssr-hydrate-axum/locales/es/main.ftl @@ -4,3 +4,4 @@ an-error-happened = Ocurrió un error error-msg = Mensaje de error: { $msg } error-code = Código de error: { $code } a-translated-header = Un encabezado traducido +not-found = No encontrado diff --git a/examples/ssr-hydrate-axum/src/app.rs b/examples/ssr-hydrate-axum/src/app.rs index e1783b29..63bdab8b 100644 --- a/examples/ssr-hydrate-axum/src/app.rs +++ b/examples/ssr-hydrate-axum/src/app.rs @@ -18,6 +18,8 @@ pub fn App() -> impl IntoView { leptos_fluent! {{ translations: TRANSLATIONS, languages: "./locales/languages.yaml", + locales: "./locales", + check_translations: "./src/**/*.rs", cookie_name: "lang", initial_language_from_cookie: true, set_language_to_cookie: true, diff --git a/leptos-fluent-macros/Cargo.toml b/leptos-fluent-macros/Cargo.toml index 97d6b1e9..fedde814 100644 --- a/leptos-fluent-macros/Cargo.toml +++ b/leptos-fluent-macros/Cargo.toml @@ -2,7 +2,7 @@ name = "leptos-fluent-macros" description = "Macros for leptos-fluent" edition = "2021" -version = "0.0.28" +version = "0.0.29" license = "MIT" documentation = "https://docs.rs/leptos-fluent" repository = "https://github.com/mondeja/leptos-fluent" @@ -15,7 +15,13 @@ path = "src/lib.rs" [dependencies] proc-macro2 = "1" quote = "1" -syn = "2" +syn = { version = "2", features = ["visit", "full"] } +ignore = "0.4" +glob = "0.3" +flume = { version = "0.11", default-features = false } +fluent-templates.workspace = true +fluent-syntax = "0.11" +pathdiff = "0.2" serde_json = { version = "1", optional = true } serde_yaml = { version = ">=0.7", optional = true } diff --git a/leptos-fluent-macros/src/languages.rs b/leptos-fluent-macros/src/languages.rs index 204c4096..d18aad07 100644 --- a/leptos-fluent-macros/src/languages.rs +++ b/leptos-fluent-macros/src/languages.rs @@ -23,7 +23,7 @@ pub(crate) fn read_languages_file(path: &PathBuf) -> Vec<(String, String)> { } } - #[cfg(feature = "yaml")] + #[cfg(all(not(feature = "json"), feature = "yaml"))] { let file_extension = path.extension().unwrap_or_default(); if file_extension == "yaml" || file_extension == "yml" { @@ -37,14 +37,6 @@ pub(crate) fn read_languages_file(path: &PathBuf) -> Vec<(String, String)> { .map(|lang| (lang[0].clone(), lang[1].clone())) .collect::>(); } else { - #[cfg(feature = "json")] - { - panic!( - "The languages file should be a JSON or YAML file. Found file extension {:?}", - file_extension - ); - } - #[cfg(not(feature = "json"))] { panic!( "The languages file should be a YAML file. Found file extension {:?}", diff --git a/leptos-fluent-macros/src/lib.rs b/leptos-fluent-macros/src/lib.rs index 01bba783..2a1a374e 100644 --- a/leptos-fluent-macros/src/lib.rs +++ b/leptos-fluent-macros/src/lib.rs @@ -1,5 +1,6 @@ #![deny(missing_docs)] #![forbid(unsafe_code)] + //! Macros for the leptos-fluent crate. //! //! See [leptos-fluent] for more information. @@ -9,417 +10,13 @@ extern crate proc_macro; mod languages; +mod loader; +#[cfg(not(feature = "ssr"))] +mod translations_checker; -use languages::{ - generate_code_for_static_language, read_languages_file, read_locales_folder, -}; -use proc_macro2::TokenStream; +use languages::generate_code_for_static_language; +use loader::I18nLoader; use quote::quote; -use std::path::PathBuf; -use syn::{ - braced, - parse::{Parse, ParseStream}, - parse_macro_input, token, Ident, Result, -}; - -fn parse_litstr_or_expr_param( - fields: ParseStream, - strlit: &mut Option, - expr: &mut Option, - param_name: &'static str, -) -> Option { - match fields.parse::() { - Ok(lit) => { - *strlit = Some(lit); - None - } - Err(_) => match fields.parse::() { - Ok(e) => { - *expr = Some(e); - None - } - Err(_) => Some(syn::Error::new( - fields.span(), - format!( - concat!( - "Not a valid value for '{}' of leptos_fluent! macro.", - " Must be a literal string or a valid expression.", - " Found {:?}", - ), - param_name, fields, - ), - )), - }, - } -} - -fn parse_litbool_or_expr_param( - fields: ParseStream, - litbool: &mut Option, - expr: &mut Option, - param_name: &'static str, -) -> Option { - match fields.parse::() { - Ok(lit) => { - *litbool = Some(lit); - None - } - Err(_) => match fields.parse::() { - Ok(e) => { - *expr = Some(e); - None - } - Err(_) => Some(syn::Error::new( - fields.span(), - format!( - concat!( - "Not a valid value for '{}' of leptos_fluent! macro.", - " Must be a literal boolean or a valid expression.", - " Found {:?}", - ), - param_name, fields, - ), - )), - }, - } -} - -struct I18nLoader { - languages: Vec<(String, String)>, - translations_ident: syn::Ident, - sync_html_tag_lang_bool: Option, - sync_html_tag_lang_expr: Option, - url_param_str: Option, - url_param_expr: Option, - initial_language_from_url_param_bool: Option, - initial_language_from_url_param_expr: Option, - initial_language_from_url_param_to_localstorage_bool: Option, - initial_language_from_url_param_to_localstorage_expr: Option, - set_language_to_url_param_bool: Option, - set_language_to_url_param_expr: Option, - localstorage_key_str: Option, - localstorage_key_expr: Option, - initial_language_from_localstorage_bool: Option, - initial_language_from_localstorage_expr: Option, - set_language_to_localstorage_bool: Option, - set_language_to_localstorage_expr: Option, - initial_language_from_navigator_bool: Option, - initial_language_from_navigator_expr: Option, - initial_language_from_accept_language_header_bool: Option, - initial_language_from_accept_language_header_expr: Option, - cookie_name_str: Option, - cookie_name_expr: Option, - initial_language_from_cookie_bool: Option, - initial_language_from_cookie_expr: Option, - set_language_to_cookie_bool: Option, - set_language_to_cookie_expr: Option, -} - -impl Parse for I18nLoader { - fn parse(input: ParseStream) -> Result { - let workspace_path = PathBuf::from( - std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| "./".into()), - ); - - let fields; - braced!(fields in input); - let mut locales_path: Option = None; - let mut translations_identifier: Option = None; - let mut languages_path: Option = None; - let mut sync_html_tag_lang_bool: Option = None; - let mut sync_html_tag_lang_expr: Option = None; - let mut url_param_str: Option = None; - let mut url_param_expr: Option = None; - let mut initial_language_from_url_param_bool: Option = - None; - let mut initial_language_from_url_param_expr: Option = None; - let mut initial_language_from_url_param_to_localstorage_bool: Option< - syn::LitBool, - > = None; - let mut initial_language_from_url_param_to_localstorage_expr: Option< - syn::Expr, - > = None; - let mut set_language_to_url_param_bool: Option = None; - let mut set_language_to_url_param_expr: Option = None; - let mut localstorage_key_str: Option = None; - let mut localstorage_key_expr: Option = None; - let mut initial_language_from_localstorage_bool: Option = - None; - let mut initial_language_from_localstorage_expr: Option = - None; - let mut set_language_to_localstorage_bool: Option = None; - let mut set_language_to_localstorage_expr: Option = None; - let mut initial_language_from_navigator_bool: Option = - None; - let mut initial_language_from_navigator_expr: Option = None; - let mut initial_language_from_accept_language_header_bool: Option< - syn::LitBool, - > = None; - let mut initial_language_from_accept_language_header_expr: Option< - syn::Expr, - > = None; - let mut cookie_name_str: Option = None; - let mut cookie_name_expr: Option = None; - let mut initial_language_from_cookie_bool: Option = None; - let mut initial_language_from_cookie_expr: Option = None; - let mut set_language_to_cookie_bool: Option = None; - let mut set_language_to_cookie_expr: Option = None; - - while !fields.is_empty() { - let k = fields.parse::()?; - fields.parse::()?; - - if k == "translations" { - translations_identifier = Some(fields.parse()?); - } else if k == "locales" { - locales_path = Some(fields.parse()?); - } else if k == "languages" { - languages_path = Some(fields.parse()?); - } else if k == "sync_html_tag_lang" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut sync_html_tag_lang_bool, - &mut sync_html_tag_lang_expr, - "sync_html_tag_lang", - ) { - return Err(err); - } - } else if k == "url_param" { - if let Some(err) = parse_litstr_or_expr_param( - &fields, - &mut url_param_str, - &mut url_param_expr, - "url_param", - ) { - return Err(err); - } - } else if k == "initial_language_from_url_param" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut initial_language_from_url_param_bool, - &mut initial_language_from_url_param_expr, - "initial_language_from_url_param", - ) { - return Err(err); - } - } else if k == "initial_language_from_url_param_to_localstorage" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut initial_language_from_url_param_to_localstorage_bool, - &mut initial_language_from_url_param_to_localstorage_expr, - "initial_language_from_url_param_to_localstorage", - ) { - return Err(err); - } - } else if k == "set_language_to_url_param" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut set_language_to_url_param_bool, - &mut set_language_to_url_param_expr, - "set_language_to_url_param", - ) { - return Err(err); - } - } else if k == "localstorage_key" { - if let Some(err) = parse_litstr_or_expr_param( - &fields, - &mut localstorage_key_str, - &mut localstorage_key_expr, - "localstorage_key", - ) { - return Err(err); - } - } else if k == "initial_language_from_localstorage" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut initial_language_from_localstorage_bool, - &mut initial_language_from_localstorage_expr, - "initial_language_from_localstorage", - ) { - return Err(err); - } - } else if k == "set_language_to_localstorage" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut set_language_to_localstorage_bool, - &mut set_language_to_localstorage_expr, - "set_language_to_localstorage", - ) { - return Err(err); - } - } else if k == "initial_language_from_navigator" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut initial_language_from_navigator_bool, - &mut initial_language_from_navigator_expr, - "initial_language_from_navigator", - ) { - return Err(err); - } - } else if k == "initial_language_from_accept_language_header" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut initial_language_from_accept_language_header_bool, - &mut initial_language_from_accept_language_header_expr, - "initial_language_from_accept_language_header", - ) { - return Err(err); - } - } else if k == "cookie_name" { - if let Some(err) = parse_litstr_or_expr_param( - &fields, - &mut cookie_name_str, - &mut cookie_name_expr, - "cookie_name", - ) { - return Err(err); - } - } else if k == "initial_language_from_cookie" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut initial_language_from_cookie_bool, - &mut initial_language_from_cookie_expr, - "initial_language_from_cookie", - ) { - return Err(err); - } - } else if k == "set_language_to_cookie" { - if let Some(err) = parse_litbool_or_expr_param( - &fields, - &mut set_language_to_cookie_bool, - &mut set_language_to_cookie_expr, - "set_language_to_cookie", - ) { - return Err(err); - } - } else { - return Err(syn::Error::new( - k.span(), - "Not a valid parameter for leptos_fluent! macro.", - )); - } - - if fields.is_empty() { - break; - } - fields.parse::()?; - } - - // translations - let translations_ident = translations_identifier.ok_or_else(|| { - syn::Error::new(input.span(), "Missing `translations` field") - })?; - - // languages - if languages_path.is_none() && locales_path.is_none() { - return Err(syn::Error::new( - input.span(), - concat!( - "Either `languages` or `locales` field is required", - " by leptos_fluent! macro.", - ), - )); - } - - let mut languages = Vec::new(); - - let languages_path_copy = languages_path.clone(); - let languages_file = languages_path - .map(|languages| workspace_path.join(languages.value())); - - if let Some(ref file) = languages_file { - if std::fs::metadata(file).is_err() { - return Err(syn::Error::new( - languages_path_copy.unwrap().span(), - format!( - concat!( - "Couldn't read languages file, this path should", - " be relative to your crate's `Cargo.toml`.", - " Looking for: {:?}", - ), - // TODO: Use std::path::absolute from - // #![feature(absolute_path)] when stable, - // see https://github.com/rust-lang/rust/issues/92750 - file, - ), - )); - } else { - languages = read_languages_file(&languages_file.unwrap()); - - if languages.len() < 2 { - return Err(syn::Error::new( - languages_path_copy.unwrap().span(), - "Languages file must contain at least two languages.", - )); - } - } - } else { - // locales - let locales_path_copy = locales_path.clone(); - let locales_folder = locales_path - .map(|locales| workspace_path.join(locales.value())); - - if let Some(ref folder) = locales_folder { - if std::fs::metadata(folder).is_err() { - return Err(syn::Error::new( - locales_path_copy.unwrap().span(), - format!( - concat!( - "Couldn't read locales folder, this path should", - " be relative to your crate's `Cargo.toml`.", - " Looking for: {:?}", - ), - // TODO: Use std::path::absolute from - // #![feature(absolute_path)] when stable, - // see https://github.com/rust-lang/rust/issues/92750 - folder, - ), - )); - } else { - languages = read_locales_folder(&locales_folder.unwrap()); - - if languages.len() < 2 { - return Err(syn::Error::new( - locales_path_copy.unwrap().span(), - "Locales folder must contain at least two languages.", - )); - } - } - } - } - - Ok(Self { - translations_ident, - languages, - sync_html_tag_lang_bool, - sync_html_tag_lang_expr, - url_param_str, - url_param_expr, - initial_language_from_url_param_bool, - initial_language_from_url_param_expr, - initial_language_from_url_param_to_localstorage_bool, - initial_language_from_url_param_to_localstorage_expr, - set_language_to_url_param_bool, - set_language_to_url_param_expr, - localstorage_key_str, - localstorage_key_expr, - initial_language_from_localstorage_bool, - initial_language_from_localstorage_expr, - set_language_to_localstorage_bool, - set_language_to_localstorage_expr, - initial_language_from_navigator_bool, - initial_language_from_navigator_expr, - initial_language_from_accept_language_header_bool, - initial_language_from_accept_language_header_expr, - cookie_name_str, - cookie_name_expr, - initial_language_from_cookie_bool, - initial_language_from_cookie_expr, - set_language_to_cookie_bool, - set_language_to_cookie_expr, - }) - } -} /// Create the i18n context for internationalization. /// @@ -471,6 +68,10 @@ impl Parse for I18nLoader { /// - **`locales`**: Path to the locales folder, which must contain the translations /// for each language in your application. Is expected to be a path relative from /// `Cargo.toml` file. Either `locales` or `languages` is required. +/// - **`check_translations`**: Path to the files to check if all translations are +/// being used and their placeholders are correct. Is expected to be a glob +/// pattern relative from `Cargo.toml` file. Tipically, you should use +/// `./src/**/*.rs`. If defined, `locales` is required. /// - **`languages`**: Path to a languages file, which should an array of arrays /// where each inner array contains a language identifier and a language name, /// respectively. The language identifier should be a valid language tag, such as @@ -571,7 +172,7 @@ pub fn leptos_fluent( initial_language_from_cookie_expr, set_language_to_cookie_bool, set_language_to_cookie_expr, - } = parse_macro_input!(input as I18nLoader); + } = syn::parse_macro_input!(input as I18nLoader); let n_languages = languages.len(); @@ -583,7 +184,7 @@ pub fn leptos_fluent( .collect::>() .join(",") ) - .parse::() + .parse::() .unwrap(); #[cfg(not(feature = "ssr"))] diff --git a/leptos-fluent-macros/src/loader.rs b/leptos-fluent-macros/src/loader.rs new file mode 100644 index 00000000..b080462d --- /dev/null +++ b/leptos-fluent-macros/src/loader.rs @@ -0,0 +1,445 @@ +use crate::languages::{read_languages_file, read_locales_folder}; +use std::path::PathBuf; +use syn::{ + braced, + parse::{Parse, ParseStream}, + token, Ident, Result, +}; + +fn parse_litstr_or_expr_param( + fields: ParseStream, + strlit: &mut Option, + expr: &mut Option, + param_name: &'static str, +) -> Option { + match fields.parse::() { + Ok(lit) => { + *strlit = Some(lit); + None + } + Err(_) => match fields.parse::() { + Ok(e) => { + *expr = Some(e); + None + } + Err(_) => Some(syn::Error::new( + fields.span(), + format!( + concat!( + "Not a valid value for '{}' of leptos_fluent! macro.", + " Must be a literal string or a valid expression.", + " Found {:?}", + ), + param_name, fields, + ), + )), + }, + } +} + +fn parse_litbool_or_expr_param( + fields: ParseStream, + litbool: &mut Option, + expr: &mut Option, + param_name: &'static str, +) -> Option { + match fields.parse::() { + Ok(lit) => { + *litbool = Some(lit); + None + } + Err(_) => match fields.parse::() { + Ok(e) => { + *expr = Some(e); + None + } + Err(_) => Some(syn::Error::new( + fields.span(), + format!( + concat!( + "Not a valid value for '{}' of leptos_fluent! macro.", + " Must be a literal boolean or a valid expression.", + " Found {:?}", + ), + param_name, fields, + ), + )), + }, + } +} + +pub(crate) struct I18nLoader { + pub(crate) languages: Vec<(String, String)>, + pub(crate) translations_ident: syn::Ident, + pub(crate) sync_html_tag_lang_bool: Option, + pub(crate) sync_html_tag_lang_expr: Option, + pub(crate) url_param_str: Option, + pub(crate) url_param_expr: Option, + pub(crate) initial_language_from_url_param_bool: Option, + pub(crate) initial_language_from_url_param_expr: Option, + pub(crate) initial_language_from_url_param_to_localstorage_bool: + Option, + pub(crate) initial_language_from_url_param_to_localstorage_expr: + Option, + pub(crate) set_language_to_url_param_bool: Option, + pub(crate) set_language_to_url_param_expr: Option, + pub(crate) localstorage_key_str: Option, + pub(crate) localstorage_key_expr: Option, + pub(crate) initial_language_from_localstorage_bool: Option, + pub(crate) initial_language_from_localstorage_expr: Option, + pub(crate) set_language_to_localstorage_bool: Option, + pub(crate) set_language_to_localstorage_expr: Option, + pub(crate) initial_language_from_navigator_bool: Option, + pub(crate) initial_language_from_navigator_expr: Option, + pub(crate) initial_language_from_accept_language_header_bool: + Option, + pub(crate) initial_language_from_accept_language_header_expr: + Option, + pub(crate) cookie_name_str: Option, + pub(crate) cookie_name_expr: Option, + pub(crate) initial_language_from_cookie_bool: Option, + pub(crate) initial_language_from_cookie_expr: Option, + pub(crate) set_language_to_cookie_bool: Option, + pub(crate) set_language_to_cookie_expr: Option, +} + +impl Parse for I18nLoader { + fn parse(input: ParseStream) -> Result { + let workspace_path = PathBuf::from( + std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| "./".into()), + ); + + let fields; + braced!(fields in input); + let mut locales_path: Option = None; + let mut languages_path: Option = None; + let mut translations_identifier: Option = None; + let mut check_translations: Option = None; + let mut sync_html_tag_lang_bool: Option = None; + let mut sync_html_tag_lang_expr: Option = None; + let mut url_param_str: Option = None; + let mut url_param_expr: Option = None; + let mut initial_language_from_url_param_bool: Option = + None; + let mut initial_language_from_url_param_expr: Option = None; + let mut initial_language_from_url_param_to_localstorage_bool: Option< + syn::LitBool, + > = None; + let mut initial_language_from_url_param_to_localstorage_expr: Option< + syn::Expr, + > = None; + let mut set_language_to_url_param_bool: Option = None; + let mut set_language_to_url_param_expr: Option = None; + let mut localstorage_key_str: Option = None; + let mut localstorage_key_expr: Option = None; + let mut initial_language_from_localstorage_bool: Option = + None; + let mut initial_language_from_localstorage_expr: Option = + None; + let mut set_language_to_localstorage_bool: Option = None; + let mut set_language_to_localstorage_expr: Option = None; + let mut initial_language_from_navigator_bool: Option = + None; + let mut initial_language_from_navigator_expr: Option = None; + let mut initial_language_from_accept_language_header_bool: Option< + syn::LitBool, + > = None; + let mut initial_language_from_accept_language_header_expr: Option< + syn::Expr, + > = None; + let mut cookie_name_str: Option = None; + let mut cookie_name_expr: Option = None; + let mut initial_language_from_cookie_bool: Option = None; + let mut initial_language_from_cookie_expr: Option = None; + let mut set_language_to_cookie_bool: Option = None; + let mut set_language_to_cookie_expr: Option = None; + + while !fields.is_empty() { + let k = fields.parse::()?; + fields.parse::()?; + + if k == "translations" { + translations_identifier = Some(fields.parse()?); + } else if k == "locales" { + locales_path = Some(fields.parse()?); + } else if k == "languages" { + languages_path = Some(fields.parse()?); + } else if k == "check_translations" { + check_translations = Some(fields.parse()?); + } else if k == "sync_html_tag_lang" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut sync_html_tag_lang_bool, + &mut sync_html_tag_lang_expr, + "sync_html_tag_lang", + ) { + return Err(err); + } + } else if k == "url_param" { + if let Some(err) = parse_litstr_or_expr_param( + &fields, + &mut url_param_str, + &mut url_param_expr, + "url_param", + ) { + return Err(err); + } + } else if k == "initial_language_from_url_param" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut initial_language_from_url_param_bool, + &mut initial_language_from_url_param_expr, + "initial_language_from_url_param", + ) { + return Err(err); + } + } else if k == "initial_language_from_url_param_to_localstorage" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut initial_language_from_url_param_to_localstorage_bool, + &mut initial_language_from_url_param_to_localstorage_expr, + "initial_language_from_url_param_to_localstorage", + ) { + return Err(err); + } + } else if k == "set_language_to_url_param" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut set_language_to_url_param_bool, + &mut set_language_to_url_param_expr, + "set_language_to_url_param", + ) { + return Err(err); + } + } else if k == "localstorage_key" { + if let Some(err) = parse_litstr_or_expr_param( + &fields, + &mut localstorage_key_str, + &mut localstorage_key_expr, + "localstorage_key", + ) { + return Err(err); + } + } else if k == "initial_language_from_localstorage" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut initial_language_from_localstorage_bool, + &mut initial_language_from_localstorage_expr, + "initial_language_from_localstorage", + ) { + return Err(err); + } + } else if k == "set_language_to_localstorage" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut set_language_to_localstorage_bool, + &mut set_language_to_localstorage_expr, + "set_language_to_localstorage", + ) { + return Err(err); + } + } else if k == "initial_language_from_navigator" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut initial_language_from_navigator_bool, + &mut initial_language_from_navigator_expr, + "initial_language_from_navigator", + ) { + return Err(err); + } + } else if k == "initial_language_from_accept_language_header" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut initial_language_from_accept_language_header_bool, + &mut initial_language_from_accept_language_header_expr, + "initial_language_from_accept_language_header", + ) { + return Err(err); + } + } else if k == "cookie_name" { + if let Some(err) = parse_litstr_or_expr_param( + &fields, + &mut cookie_name_str, + &mut cookie_name_expr, + "cookie_name", + ) { + return Err(err); + } + } else if k == "initial_language_from_cookie" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut initial_language_from_cookie_bool, + &mut initial_language_from_cookie_expr, + "initial_language_from_cookie", + ) { + return Err(err); + } + } else if k == "set_language_to_cookie" { + if let Some(err) = parse_litbool_or_expr_param( + &fields, + &mut set_language_to_cookie_bool, + &mut set_language_to_cookie_expr, + "set_language_to_cookie", + ) { + return Err(err); + } + } else { + return Err(syn::Error::new( + k.span(), + "Not a valid parameter for leptos_fluent! macro.", + )); + } + + if fields.is_empty() { + break; + } + fields.parse::()?; + } + + // translations + let translations_ident = translations_identifier.ok_or_else(|| { + syn::Error::new(input.span(), "Missing `translations` field") + })?; + + // languages + if languages_path.is_none() && locales_path.is_none() { + return Err(syn::Error::new( + input.span(), + concat!( + "Either `languages` or `locales` field is required", + " by leptos_fluent! macro.", + ), + )); + } + + let mut languages = Vec::new(); + + let languages_path_copy = languages_path.clone(); + let languages_file = languages_path + .map(|languages| workspace_path.join(languages.value())); + + if let Some(ref file) = languages_file { + if std::fs::metadata(file).is_err() { + return Err(syn::Error::new( + languages_path_copy.unwrap().span(), + format!( + concat!( + "Couldn't read languages file, this path should", + " be relative to your crate's `Cargo.toml`.", + " Looking for: {:?}", + ), + // TODO: Use std::path::absolute from + // #![feature(absolute_path)] when stable, + // see https://github.com/rust-lang/rust/issues/92750 + file, + ), + )); + } else { + languages = read_languages_file(&languages_file.unwrap()); + + if languages.len() < 2 { + return Err(syn::Error::new( + languages_path_copy.unwrap().span(), + "Languages file must contain at least two languages.", + )); + } + } + } else { + // locales + let locales_path_copy = locales_path.clone(); + let locales_folder = locales_path + .as_ref() + .map(|locales| workspace_path.join(locales.value())); + + if let Some(ref folder) = locales_folder { + if std::fs::metadata(folder).is_err() { + return Err(syn::Error::new( + locales_path_copy.unwrap().span(), + format!( + concat!( + "Couldn't read locales folder, this path should", + " be relative to your crate's `Cargo.toml`.", + " Looking for: {:?}", + ), + // TODO: Use std::path::absolute from + // #![feature(absolute_path)] when stable, + // see https://github.com/rust-lang/rust/issues/92750 + folder, + ), + )); + } else { + languages = read_locales_folder(&locales_folder.unwrap()); + + if languages.len() < 2 { + return Err(syn::Error::new( + locales_path_copy.unwrap().span(), + "Locales folder must contain at least two languages.", + )); + } + } + } + } + + if let Some(check_translations_globstr) = check_translations { + if locales_path.is_none() { + return Err(syn::Error::new( + check_translations_globstr.span(), + concat!( + "You must provide a `locales` parameter", + " to use the `check_translations` parameter.", + ), + )); + } + + #[cfg(not(feature = "ssr"))] + { + let translations_error_messages = + crate::translations_checker::run( + &check_translations_globstr.value(), + &locales_path.unwrap().value(), + &workspace_path, + )?; + if !translations_error_messages.is_empty() { + return Err(syn::Error::new( + check_translations_globstr.span(), + format!( + "Translations check failed:\n- {}", + translations_error_messages.join("\n- "), + ), + )); + } + } + } + + Ok(Self { + translations_ident, + languages, + sync_html_tag_lang_bool, + sync_html_tag_lang_expr, + url_param_str, + url_param_expr, + initial_language_from_url_param_bool, + initial_language_from_url_param_expr, + initial_language_from_url_param_to_localstorage_bool, + initial_language_from_url_param_to_localstorage_expr, + set_language_to_url_param_bool, + set_language_to_url_param_expr, + localstorage_key_str, + localstorage_key_expr, + initial_language_from_localstorage_bool, + initial_language_from_localstorage_expr, + set_language_to_localstorage_bool, + set_language_to_localstorage_expr, + initial_language_from_navigator_bool, + initial_language_from_navigator_expr, + initial_language_from_accept_language_header_bool, + initial_language_from_accept_language_header_expr, + cookie_name_str, + cookie_name_expr, + initial_language_from_cookie_bool, + initial_language_from_cookie_expr, + set_language_to_cookie_bool, + set_language_to_cookie_expr, + }) + } +} diff --git a/leptos-fluent-macros/src/translations_checker/fluent_entries.rs b/leptos-fluent-macros/src/translations_checker/fluent_entries.rs new file mode 100644 index 00000000..93fb6070 --- /dev/null +++ b/leptos-fluent-macros/src/translations_checker/fluent_entries.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::path::Path; + +#[derive(Debug)] +pub(crate) struct FluentEntry { + pub(crate) message_name: String, + pub(crate) placeables: Vec, +} + +pub(crate) fn gather_fluent_entries_from_locales_path( + workspace_path: &Path, + locales_path: &str, +) -> HashMap> { + let mut fluent_entries: HashMap> = HashMap::new(); + + let fluent_resources = build_resources(workspace_path.join(locales_path)); + for (lang, resources_paths) in fluent_resources.iter() { + fluent_entries.insert(lang.to_owned(), vec![]); + for path in resources_paths { + let resource_str = std::fs::read_to_string(path).unwrap(); + let resource = + fluent_templates::fluent_bundle::FluentResource::try_new( + resource_str.to_owned(), + ) + .unwrap(); + for entry in resource.entries() { + if let fluent_syntax::ast::Entry::Message(msg) = entry { + if let Some(value) = &msg.value { + let mut placeables = Vec::new(); + for element in &value.elements { + if let fluent_syntax::ast::PatternElement::Placeable { + expression: fluent_syntax::ast::Expression::Inline( + fluent_syntax::ast::InlineExpression::VariableReference { + id + } + ) + } = element { + placeables.push(id.name.to_string()); + } + } + fluent_entries.get_mut(lang).unwrap().push( + FluentEntry { + message_name: msg.id.name.to_string(), + placeables, + }, + ); + } + } + } + } + } + fluent_entries +} + +/// Copied from `fluent_templates/macros` to ensure that the same implementation +/// is followed. +fn build_resources( + dir: impl AsRef, +) -> HashMap> { + let mut all_resources = HashMap::new(); + for entry in std::fs::read_dir(dir) + .unwrap() + .filter_map(|rs| rs.ok()) + .filter(|entry| entry.file_type().unwrap().is_dir()) + { + if let Some(lang) = entry.file_name().into_string().ok().filter(|l| { + l.parse::().is_ok() + }) { + let resources = read_from_dir(entry.path()); + all_resources.insert(lang, resources); + } + } + all_resources +} + +/// Copied from `fluent_templates/macros` to ensure that the same implementation +/// is followed. +pub(crate) fn read_from_dir>(path: P) -> Vec { + let (tx, rx) = flume::unbounded(); + + ignore::WalkBuilder::new(path) + .follow_links(true) + .build_parallel() + .run(|| { + let tx = tx.clone(); + Box::new(move |result| { + if let Ok(entry) = result { + if entry.file_type().as_ref().map_or(false, |e| e.is_file()) + && entry + .path() + .extension() + .map_or(false, |e| e == "ftl") + { + tx.send(entry.path().display().to_string()).unwrap(); + } + } + + ignore::WalkState::Continue + }) + }); + + rx.drain().collect::>() +} diff --git a/leptos-fluent-macros/src/translations_checker/mod.rs b/leptos-fluent-macros/src/translations_checker/mod.rs new file mode 100644 index 00000000..2df8eeb0 --- /dev/null +++ b/leptos-fluent-macros/src/translations_checker/mod.rs @@ -0,0 +1,196 @@ +mod fluent_entries; +mod tr_macros; + +use fluent_entries::{gather_fluent_entries_from_locales_path, FluentEntry}; +use std::collections::HashMap; +use std::path::Path; +use tr_macros::{gather_tr_macro_defs_from_rs_files, TranslationMacro}; + +pub(crate) fn run( + check_translations_globstr: &str, + locales_path: &str, + workspace_path: &Path, +) -> Result, syn::Error> { + let tr_macros: Vec = gather_tr_macro_defs_from_rs_files( + &workspace_path.join(check_translations_globstr), + #[cfg(not(test))] + workspace_path, + )?; + + let fluent_entries: HashMap> = + gather_fluent_entries_from_locales_path(workspace_path, locales_path); + + let mut error_messages = + check_tr_macros_against_fluent_entries(&tr_macros, &fluent_entries); + error_messages.extend(check_fluent_entries_against_tr_macros( + &tr_macros, + &fluent_entries, + )); + // TODO: Include the core.ftl file in the check + // TODO: Currently, the fluent-syntax parser does not offer a CST + // parser so we don't know the spans of the entries. + // See https://github.com/projectfluent/fluent-rs/issues/270 + Ok(error_messages) +} + +fn format_macro_call( + macro_name: &str, + message_name: &str, + has_placeables: bool, +) -> String { + if has_placeables { + return format!(r#"`{}!("{}", {{ ... }})`"#, macro_name, message_name); + } + format!(r#"`{}!("{}")`"#, macro_name, message_name) +} + +fn check_tr_macros_against_fluent_entries( + tr_macros: &Vec, + fluent_entries: &HashMap>, +) -> Vec { + let mut error_messages: Vec = Vec::new(); + + for tr_macro in tr_macros { + for (lang, entries) in fluent_entries.iter() { + // tr macro message must be defined for each language + let mut message_name_found = false; + for entry in entries { + if tr_macro.message_name == entry.message_name { + message_name_found = true; + + // Check if all variables in the tr macro are present in the fluent entry + for placeable in &tr_macro.placeables { + if !entry.placeables.contains(placeable) { + let file_path = { + #[cfg(not(test))] + { + tr_macro.file_path.clone() + } + + #[cfg(test)] + { + "[test content]".to_string() + } + }; + + error_messages.push(format!( + concat!( + r#"Variable "{}" defined at {} macro"#, + r#" call in {} not found in message"#, + r#" "{}" of locale "{}"."#, + ), + placeable, + format_macro_call( + &tr_macro.name, + &tr_macro.message_name, + !tr_macro.placeables.is_empty(), + ), + file_path, + tr_macro.message_name, + lang, + )); + } + } + + break; + } + } + if !message_name_found { + let file_path = { + #[cfg(not(test))] + { + tr_macro.file_path.clone() + } + + #[cfg(test)] + { + "[test content]".to_string() + } + }; + + error_messages.push(format!( + concat!( + r#"Message "{}" defined at {} macro call in {}"#, + r#" not found in files for locale "{}"."#, + ), + tr_macro.message_name, + format_macro_call( + &tr_macro.name, + &tr_macro.message_name, + !tr_macro.placeables.is_empty(), + ), + file_path, + lang, + )); + } + } + } + error_messages +} + +fn check_fluent_entries_against_tr_macros( + tr_macros: &Vec, + fluent_entries: &HashMap>, +) -> Vec { + let mut error_messages: Vec = Vec::new(); + + for (lang, entries) in fluent_entries.iter() { + for entry in entries { + // fluent entry message must be defined for each language + let mut message_name_found = false; + for tr_macro in tr_macros { + if tr_macro.message_name == entry.message_name { + message_name_found = true; + + // Check if all variables in the entry are present in the tr macro + for placeable in &entry.placeables { + if !tr_macro.placeables.contains(placeable) { + let file_path = { + #[cfg(not(test))] + { + tr_macro.file_path.clone() + } + + #[cfg(test)] + { + "[test content]".to_string() + } + }; + + error_messages.push( + format!( + concat!( + r#"Variable "{}" defined in message "{}" of"#, + r#" locale "{}" not found in arguments of"#, + r#" {} macro call at file {}."#, + ), + placeable, + entry.message_name, + lang, + format_macro_call( + &tr_macro.name, + &tr_macro.message_name, + !tr_macro.placeables.is_empty(), + ), + file_path, + ) + ); + } + } + + break; + } + } + if !message_name_found { + error_messages.push(format!( + concat!( + r#"Message "{}" of locale "{}" not found in any"#, + r#" `tr!` or `move_tr!` macro calls."#, + ), + entry.message_name, lang, + )); + } + } + } + error_messages +} diff --git a/leptos-fluent-macros/src/translations_checker/tr_macros.rs b/leptos-fluent-macros/src/translations_checker/tr_macros.rs new file mode 100644 index 00000000..ee246e0a --- /dev/null +++ b/leptos-fluent-macros/src/translations_checker/tr_macros.rs @@ -0,0 +1,381 @@ +#[cfg(not(test))] +use pathdiff::diff_paths; +use quote::ToTokens; +use std::path::{Path, PathBuf}; +use syn::visit::Visit; + +pub(crate) fn gather_tr_macro_defs_from_rs_files( + check_translations_globstr: &Path, + #[cfg(not(test))] workspace_path: &Path, +) -> Result, syn::Error> { + // TODO: handle errors + let glob_pattern = + glob::glob(check_translations_globstr.to_str().unwrap()).unwrap(); + + let mut tr_macros = Vec::new(); + for path in glob_pattern.flatten() { + tr_macros.extend(tr_macros_from_file_path( + &path, + #[cfg(not(test))] + workspace_path.to_str().unwrap(), + )); + } + + Ok(tr_macros) +} + +#[derive(Debug)] +pub(crate) struct TranslationMacro { + pub(crate) name: String, + pub(crate) message_name: String, + pub(crate) placeables: Vec, + + // On tests is easier to not use file paths + #[cfg(not(test))] + pub(crate) file_path: String, +} + +impl PartialEq for TranslationMacro { + fn eq(&self, other: &Self) -> bool { + let equal = self.name == other.name + && self.message_name == other.message_name + && self.placeables == other.placeables; + #[cfg(not(test))] + return equal && self.file_path == other.file_path; + #[cfg(test)] + return equal; + } +} + +pub(crate) struct TranslationsMacrosVisitor { + pub(crate) tr_macros: Vec, + current_tr_macro: Option, + current_message_name: Option, + current_placeables: Vec, + + #[cfg(not(test))] + file_path: PathBuf, + #[cfg(not(test))] + workspace_path: String, +} + +impl TranslationsMacrosVisitor { + fn new( + #[cfg(not(test))] file_path: PathBuf, + #[cfg(not(test))] workspace_path: &str, + ) -> Self { + Self { + tr_macros: Vec::new(), + current_tr_macro: None, + current_message_name: None, + current_placeables: Vec::new(), + #[cfg(not(test))] + file_path, + #[cfg(not(test))] + workspace_path: workspace_path.to_string(), + } + } +} + +fn tr_macros_from_file_path( + file_path: &PathBuf, + #[cfg(not(test))] workspace_path: &str, +) -> Vec { + let file_content = std::fs::read_to_string(file_path).unwrap(); + let ast = syn::parse_file(&file_content).unwrap(); + let mut visitor = TranslationsMacrosVisitor::new( + #[cfg(not(test))] + file_path.clone(), + #[cfg(not(test))] + workspace_path, + ); + visitor.visit_file(&ast); + visitor.tr_macros +} + +impl<'ast> TranslationsMacrosVisitor { + fn visit_maybe_macro_tokens_stream( + &mut self, + tokens: &'ast proc_macro2::TokenStream, + ) { + // Inside a macro group like `view!` + for token in tokens.clone().into_iter() { + if let proc_macro2::TokenTree::Ident(ident) = token { + let ident_str = ident.to_string(); + if ident_str == "move_tr" || ident_str == "tr" { + self.current_tr_macro = Some(ident.to_string()); + } + } else if let proc_macro2::TokenTree::Group(group) = token { + if let Some(ref tr_macro) = &self.current_tr_macro { + for tr_token in group.stream() { + if let proc_macro2::TokenTree::Literal(literal) = + tr_token + { + self.current_message_name = Some( + literal + .to_string() + .strip_prefix('"') + .unwrap() + .strip_suffix('"') + .unwrap() + .to_string(), + ); + } else if let proc_macro2::TokenTree::Group( + placeables_group, + ) = tr_token + { + let mut after_comma_punct = true; + for arg_token in placeables_group.stream() { + if let proc_macro2::TokenTree::Literal( + arg_literal, + ) = arg_token + { + if after_comma_punct { + self.current_placeables.push( + arg_literal + .to_string() + .strip_prefix('"') + .unwrap() + .strip_suffix('"') + .unwrap() + .to_string(), + ); + after_comma_punct = false; + } + } else if let proc_macro2::TokenTree::Punct( + punct, + ) = arg_token + { + if punct.as_char() == ',' { + after_comma_punct = true; + } + } + } + } + } + + let new_tr_macro = TranslationMacro { + name: tr_macro.clone(), + message_name: self + .current_message_name + .clone() + .unwrap(), + placeables: self.current_placeables.clone(), + #[cfg(not(test))] + file_path: diff_paths( + self.file_path.as_path().to_str().unwrap(), + &self.workspace_path, + ) + .unwrap() + .as_path() + .to_str() + .unwrap() + .to_string(), + }; + // TODO: this is expensive because we're executing + // it recursively for each group + if !self.tr_macros.contains(&new_tr_macro) { + self.tr_macros.push(new_tr_macro); + } + self.current_tr_macro = None; + self.current_message_name = None; + self.current_placeables = Vec::new(); + break; + } else { + self.visit_maybe_macro_tokens_stream(&group.stream()); + } + } + } + } +} + +impl<'ast> Visit<'ast> for TranslationsMacrosVisitor { + fn visit_macro(&mut self, node: &'ast syn::Macro) { + for token in node.tokens.clone() { + if let proc_macro2::TokenTree::Group(group) = token { + self.visit_maybe_macro_tokens_stream(&group.stream()); + } + } + + syn::visit::visit_macro(self, node); + } + + fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) { + let stream = node.to_token_stream(); + self.visit_maybe_macro_tokens_stream(&stream); + + syn::visit::visit_stmt_macro(self, node); + } + + fn visit_stmt(&mut self, node: &'ast syn::Stmt) { + let stream = node + .to_token_stream() + .into_iter() + .skip(2) + .collect::(); + self.visit_maybe_macro_tokens_stream(&stream); + + syn::visit::visit_stmt(self, node); + } +} + +#[cfg(test)] +mod tests { + use super::{TranslationMacro, TranslationsMacrosVisitor}; + use quote::quote; + use syn::visit::Visit; + + fn tr_macros_from_file_content( + file_content: &str, + ) -> Vec { + let ast = syn::parse_file(file_content).unwrap(); + let mut visitor = TranslationsMacrosVisitor::new(); + visitor.visit_file(&ast); + visitor.tr_macros + } + + macro_rules! tr_macro { + ($name:literal, $message_name:literal, $placeables:expr) => { + TranslationMacro { + name: $name.to_string(), + message_name: $message_name.to_string(), + placeables: $placeables, + } + }; + } + + #[test] + fn tr_macros_from_view() { + let content = quote! { + fn App() -> impl IntoView { + view! { +

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

+

{move_tr!("html-tag-lang-is", { "foo" => "value1", "bar" => "value2" })}

+ } + } + }; + let tr_macros = tr_macros_from_file_content(&content.to_string()); + + assert_eq!( + tr_macros, + vec![ + tr_macro!("move_tr", "select-a-language", Vec::new()), + tr_macro!( + "move_tr", + "html-tag-lang-is", + vec!["foo".to_string(), "bar".to_string(),] + ), + ] + ); + } + + #[test] + fn tr_macros_from_closure() { + let content = quote! { + fn App() -> impl IntoView { + let closure_a = move || tr!("select-a-language"); + let closure_b = move || { + tr!("html-tag-lang-is", { "foo" => "value1", "bar" => "value2" }); + }; + let closure_c = || tr!("select-another-language"); + let closure_d = || { + tr!("other-html-tag-lang-is", { "foo" => "value1", "bar" => "value2" }); + }; + } + }; + let tr_macros = tr_macros_from_file_content(&content.to_string()); + + assert_eq!( + tr_macros, + vec![ + tr_macro!("tr", "select-a-language", Vec::new()), + tr_macro!( + "tr", + "html-tag-lang-is", + vec!["foo".to_string(), "bar".to_string(),] + ), + tr_macro!("tr", "select-another-language", Vec::new()), + tr_macro!( + "tr", + "other-html-tag-lang-is", + vec!["foo".to_string(), "bar".to_string(),] + ), + ] + ); + } + + #[test] + fn tr_macros_from_stmt_macros() { + let content = quote! { + fn App() -> impl IntoView { + // for completeness, this is not idiomatic + tr!("select-a-language"); + tr!("html-tag-lang-is", { "foo" => "value1", "bar" => "value2" }); + } + }; + let tr_macros = tr_macros_from_file_content(&content.to_string()); + + assert_eq!( + tr_macros, + vec![ + tr_macro!("tr", "select-a-language", Vec::new()), + tr_macro!( + "tr", + "html-tag-lang-is", + vec!["foo".to_string(), "bar".to_string(),] + ), + ] + ); + } + + #[test] + fn tr_macros_from_stmt() { + let content = quote! { + fn App() -> impl IntoView { + let a = tr!("select-a-language"); + let b = tr!("html-tag-lang-is", { "foo" => "value1", "bar" => "value2" }); + } + }; + let tr_macros = tr_macros_from_file_content(&content.to_string()); + + assert_eq!( + tr_macros, + vec![ + tr_macro!("tr", "select-a-language", Vec::new()), + tr_macro!( + "tr", + "html-tag-lang-is", + vec!["foo".to_string(), "bar".to_string(),] + ), + ] + ); + } + + #[test] + fn tr_macros_from_if_inside_view_macro() { + let content = quote! { + fn App() -> impl IntoView { + view! { +

+ { + if errors.len() > 1 { + move_tr!("some-errors-happened") + } else { + move_tr!("an-error-happened") + } + } +

+ } + } + }; + let tr_macros = tr_macros_from_file_content(&content.to_string()); + + assert_eq!( + tr_macros, + vec![ + tr_macro!("move_tr", "some-errors-happened", Vec::new()), + tr_macro!("move_tr", "an-error-happened", Vec::new()), + ] + ); + } +} diff --git a/leptos-fluent/Cargo.toml b/leptos-fluent/Cargo.toml index 2afc6b4c..92468956 100644 --- a/leptos-fluent/Cargo.toml +++ b/leptos-fluent/Cargo.toml @@ -2,7 +2,7 @@ name = "leptos-fluent" description = "Fluent framework for internationalization of Leptos applications" edition = "2021" -version = "0.0.28" +version = "0.0.29" license = "MIT" documentation = "https://docs.rs/leptos-fluent" repository = "https://github.com/mondeja/leptos-fluent" @@ -10,9 +10,8 @@ readme = "README.md" [dependencies] leptos-fluent-macros = { path = "../leptos-fluent-macros" } -fluent-templates = "0.9" +fluent-templates.workspace = true leptos = ">=0.6" -once_cell = "1" web-sys = { version = ">=0.1", features = [ "HtmlDocument", "Navigator", diff --git a/leptos-fluent/README.md b/leptos-fluent/README.md index 67371e00..df5826ea 100644 --- a/leptos-fluent/README.md +++ b/leptos-fluent/README.md @@ -19,7 +19,7 @@ Add the following to your `Cargo.toml` file: ```toml [dependencies] -leptos-fluent = "0.0.28" +leptos-fluent = "0.0.29" fluent-templates = "0.9" [features] diff --git a/leptos-fluent/src/lib.rs b/leptos-fluent/src/lib.rs index b8f8b083..dc4548bf 100644 --- a/leptos-fluent/src/lib.rs +++ b/leptos-fluent/src/lib.rs @@ -14,7 +14,7 @@ //! //! ```toml //! [dependencies] -//! leptos-fluent = "0.0.28" +//! leptos-fluent = "0.0.29" //! fluent-templates = "0.9" //! //! [features] @@ -221,10 +221,11 @@ pub mod url; use core::hash::{Hash, Hasher}; use core::str::FromStr; -use fluent_templates::{LanguageIdentifier, StaticLoader}; +use fluent_templates::{ + once_cell::sync::Lazy, LanguageIdentifier, StaticLoader, +}; use leptos::{use_context, Attribute, IntoAttribute, Oco, RwSignal, SignalGet}; pub use leptos_fluent_macros::leptos_fluent; -use once_cell::sync::Lazy; /// Each language supported by your application. #[derive(Clone, Debug)] @@ -277,7 +278,7 @@ impl FromStr for Language { fn from_str(s: &str) -> Result { language_from_str_between_languages(s, expect_i18n().languages) .ok_or(()) - .map(|lang| lang.clone()) + .cloned() } } @@ -350,12 +351,12 @@ pub fn i18n() -> I18n { /// ``` #[macro_export] macro_rules! tr { - ($text_id:expr$(,)?) => {{ + ($text_id:literal$(,)?) => {{ use fluent_templates::loader::Loader; let i18n = $crate::expect_i18n(); i18n.translations.lookup(&i18n.language.get().id, $text_id) }}; - ($text_id:expr, {$($key:expr => $value:expr),*$(,)?}$(,)?) => {{ + ($text_id:literal, {$($key:literal => $value:expr),*$(,)?}$(,)?) => {{ use fluent_templates::loader::Loader; let i18n = $crate::expect_i18n(); let args = &{ @@ -379,10 +380,10 @@ macro_rules! tr { /// [`leptos::Signal`]: https://docs.rs/leptos/latest/leptos/struct.Signal.html #[macro_export] macro_rules! move_tr { - ($text_id:expr$(,)?) => { + ($text_id:literal$(,)?) => { ::leptos::Signal::derive(move || $crate::tr!($text_id)) }; - ($text_id:expr, {$($key:expr => $value:expr),*$(,)?}$(,)?) => { + ($text_id:literal, {$($key:literal => $value:expr),*$(,)?}$(,)?) => { ::leptos::Signal::derive(move || $crate::tr!($text_id, { $( $key => $value, diff --git a/leptos-fluent/src/localstorage.rs b/leptos-fluent/src/localstorage.rs index d270588a..73f954c8 100644 --- a/leptos-fluent/src/localstorage.rs +++ b/leptos-fluent/src/localstorage.rs @@ -10,7 +10,7 @@ pub fn get(key: &str) -> Option { #[cfg(feature = "ssr")] { _ = key; - return None; + None } }