Skip to content

Commit

Permalink
Merge pull request #165 from Baptistemontan/i18n_path
Browse files Browse the repository at this point in the history
Localize route paths
  • Loading branch information
Baptistemontan authored Nov 18, 2024
2 parents 8cf693a + 1e789d4 commit 959d365
Show file tree
Hide file tree
Showing 9 changed files with 570 additions and 166 deletions.
92 changes: 87 additions & 5 deletions docs/book/src/usage/07_router.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ use leptos_router::*;

view! {
<Router>
<Routes>
<I18nRoute>
<Route path="/" view=Home />
<Route path="/counter" view=Counter />
<Routes fallback=||"Page not found">
<I18nRoute view=Outlet>
<Route path=path!("") view=Home />
<Route path=path!("counter") view=Counter />
</I18nRoute>
</Routes>
</Router>
Expand Down Expand Up @@ -50,7 +50,8 @@ Switching locale updates the prefix accordingly, switching from `en` to `fr` wil

Switching locale will trigger a navigation, update the `Location` returned by `use_location`, but will not refresh the component tree.

This means that if `Counter` keep a count as a state, and you switch locale from `fr` to `en`, this will trigger a navigation from `"/fr/counter"` to `"/counter"`, but the component will not be rerendered.
This means that if `Counter` keep a count as a state, and you switch locale from `fr` to `en`, this will trigger a navigation from `"/fr/counter"` to `"/counter"`,
but the component will not be rerendered and the count state will be preserved.

## Navigation

Expand All @@ -61,3 +62,84 @@ This redirection also set `NavigateOptions.replace` to `true` so the intermediat

Basically, if you are at `"/fr/counter"` and trigger a redirection to `"/"`, this will trigger another redirection to `"/fr"`
and the history will look like you directly navigated from `"/fr/counter"` to `"/fr"`.

## Localized path segments

You can use inside the `i18nRoute` the `i18n_path!` to create localized path segments:

```rust
use leptos_i18n::i18n_path;

<I18nRoute view=Outlet>
<Route path=i18n_path!(Locale, |locale| td_string(locale, segment_path_name)) view={/* */} />
</I18nRoute>
```

If you have `segment_path_name = "search"` for english, and `segment_path_name = "rechercher"` for french, the `I18nRoute` will produce 3 paths:

- "/search" (if default = "en")
- "/en/search"
- "/fr/rechercher"

It can be used at any depth, and if not used inside a `i18nRoute` it will default to the default locale.

## Caveat

If you have a layout like this:

```rust
view! {
<I18nContextProvider>
<Menu />
<Router>
<Routes fallback=||"Page not found">
<I18nRoute view=Outlet>
<Route path=path!("") view=Home />
</I18nRoute>
</Routes>
</Router>
</I18nContextProvider>
}
```

And the `Menu` component use localization, you could be suprised to see that sometimes there is a mismatch beetween the locale used by the `Menu` and the one inside the router.
This is due to the locale being read from the URL only when the `i18nRoute` is rendered. So the context may be initialized with another locale, and then hit the router that update it.

You have multiple solutions, you can either use the `Menu` component inside the `i18nRoute`:

```rust
view! {
<I18nContextProvider>
<Router>
<Routes fallback=||"Page not found">
<I18nRoute view=|| view! {
<Menu />
<Outlet />
}>
<Route path=path!("") view=Home />
</I18nRoute>
</Routes>
</Router>
</I18nContextProvider>
}
```

Or you can use the `parse_locale_from_path` option on the `I18nContextProvider`:

```rust
view! {
<I18nContextProvider parse_locale_from_path="">
<Menu />
<Router>
<Routes fallback=||"Page not found">
<I18nRoute view=Outlet>
<Route path=path!("") view=Home />
</I18nRoute>
</Routes>
</Router>
</I18nContextProvider>
}
```

This option force the context to initialize itself with the locale from the URL. It is not enabled by default because the only time it is neededis this particular case.
It expect the base_path argument you would pass to the `I18nRoute`.
24 changes: 20 additions & 4 deletions leptos_i18n/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ where
cookie_options: CookieOptions<L>,
/// Options to pass to `leptos_use::use_locales`.
ssr_lang_header_getter: UseLocalesOptions,
/// Try to parse the locale from the URL pathname, expect the basepath. (default to `None`).
/// If `None` do nothing, if `Some(base_path)` strip the URL from `base_path` then expect to found a path segment that represent a locale.
/// This is usefull when using the `I18nRoute` with usage of the context outside the router.
parse_locale_from_path: Option<Cow<'static, str>>,
}

impl<L: Locale> Default for I18nContextOptions<'_, L> {
Expand All @@ -175,6 +179,7 @@ impl<L: Locale> Default for I18nContextOptions<'_, L> {
cookie_name: Cow::Borrowed(COOKIE_PREFERED_LANG),
cookie_options: Default::default(),
ssr_lang_header_getter: Default::default(),
parse_locale_from_path: None,
}
}
}
Expand All @@ -187,6 +192,7 @@ pub fn init_i18n_context_with_options<L: Locale>(options: I18nContextOptions<L>)
cookie_name,
cookie_options,
ssr_lang_header_getter,
parse_locale_from_path,
} = options;
let (lang_cookie, set_lang_cookie) = if ENABLE_COOKIE && enable_cookie {
leptos_use::use_cookie_with_options::<L, FromToStringCodec>(&cookie_name, cookie_options)
Expand All @@ -195,8 +201,11 @@ pub fn init_i18n_context_with_options<L: Locale>(options: I18nContextOptions<L>)
(lang_cookie.into(), set_lang_cookie)
};

let initial_locale =
fetch_locale::fetch_locale(lang_cookie.get_untracked(), ssr_lang_header_getter);
let initial_locale = fetch_locale::fetch_locale(
lang_cookie.get_untracked(),
ssr_lang_header_getter,
parse_locale_from_path,
);

init_context_inner::<L>(set_lang_cookie, initial_locale)
}
Expand Down Expand Up @@ -268,7 +277,7 @@ fn init_subcontext_with_options<L: Locale>(
};

let fetch_locale_memo =
fetch_locale::fetch_locale(None, ssr_lang_header_getter.unwrap_or_default());
fetch_locale::fetch_locale(None, ssr_lang_header_getter.unwrap_or_default(), None);

let parent_locale = use_context::<I18nContext<L>>().map(|ctx| ctx.get_locale_untracked());

Expand Down Expand Up @@ -439,21 +448,23 @@ macro_rules! fill_options {
}

#[track_caller]
#[allow(clippy::too_many_arguments)]
fn provide_i18n_context_component_inner<L: Locale, Chil: IntoView>(
set_lang_attr_on_html: Option<bool>,
set_dir_attr_on_html: Option<bool>,
enable_cookie: Option<bool>,
cookie_name: Option<Cow<str>>,
cookie_options: Option<CookieOptions<L>>,
ssr_lang_header_getter: Option<UseLocalesOptions>,
parse_locale_from_path: Option<Cow<'static, str>>,
children: impl FnOnce() -> Chil,
) -> impl IntoView {
#[cfg(all(feature = "dynamic_load", feature = "hydrate", not(feature = "ssr")))]
let embed_translations = crate::fetch_translations::init_translations::<L>();
#[cfg(all(feature = "dynamic_load", feature = "ssr"))]
let reg_ctx = crate::fetch_translations::RegisterCtx::<L>::provide_context();
let options = fill_options!(
I18nContextOptions::<L>::default(),
I18nContextOptions::<L>::default().parse_locale_from_path(parse_locale_from_path),
enable_cookie,
cookie_name,
cookie_options,
Expand Down Expand Up @@ -481,6 +492,7 @@ fn provide_i18n_context_component_inner<L: Locale, Chil: IntoView>(
}

#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
#[track_caller]
pub fn provide_i18n_context_component<L: Locale, Chil: IntoView>(
set_lang_attr_on_html: Option<bool>,
Expand All @@ -489,6 +501,7 @@ pub fn provide_i18n_context_component<L: Locale, Chil: IntoView>(
cookie_name: Option<Cow<str>>,
cookie_options: Option<CookieOptions<L>>,
ssr_lang_header_getter: Option<UseLocalesOptions>,
parse_locale_from_path: Option<Cow<'static, str>>,
children: TypedChildren<Chil>,
) -> impl IntoView {
provide_i18n_context_component_inner(
Expand All @@ -498,6 +511,7 @@ pub fn provide_i18n_context_component<L: Locale, Chil: IntoView>(
cookie_name,
cookie_options,
ssr_lang_header_getter,
parse_locale_from_path,
children.into_inner(),
)
}
Expand All @@ -509,6 +523,7 @@ pub fn provide_i18n_context_component_island<L: Locale>(
set_dir_attr_on_html: Option<bool>,
enable_cookie: Option<bool>,
cookie_name: Option<Cow<str>>,
parse_locale_from_path: Option<Cow<'static, str>>,
children: children::Children,
) -> impl IntoView {
provide_i18n_context_component_inner::<L, AnyView>(
Expand All @@ -518,6 +533,7 @@ pub fn provide_i18n_context_component_island<L: Locale>(
cookie_name,
None,
None,
parse_locale_from_path,
children,
)
}
Expand Down
45 changes: 38 additions & 7 deletions leptos_i18n/src/fetch_locale.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,44 @@
use std::borrow::Cow;

use leptos::prelude::*;
use leptos_router::location::{BrowserUrl, LocationProvider, RequestUrl};
use leptos_use::UseLocalesOptions;

use crate::Locale;

pub fn fetch_locale<L: Locale>(current_cookie: Option<L>, options: UseLocalesOptions) -> Memo<L> {
pub fn fetch_locale<L: Locale>(
current_cookie: Option<L>,
options: UseLocalesOptions,
parse_locale_from_path: Option<Cow<'static, str>>,
) -> Memo<L> {
let accepted_locales = leptos_use::use_locales_with_options(options);
let accepted_locale =
Memo::new(move |_| accepted_locales.with(|accepted| L::find_locale(accepted)));

let url_locale = get_locale_from_path::<L>(parse_locale_from_path);

if cfg!(feature = "ssr") {
fetch_locale_ssr(current_cookie, accepted_locale)
fetch_locale_ssr(current_cookie, accepted_locale, url_locale)
} else if cfg!(feature = "hydrate") {
fetch_locale_hydrate(current_cookie, accepted_locale)
} else {
fetch_locale_csr(current_cookie, accepted_locale)
fetch_locale_csr(current_cookie, accepted_locale, url_locale)
}
}

fn get_locale_from_path<L: Locale>(parse_locale_from_path: Option<Cow<'static, str>>) -> Option<L> {
let base_path = parse_locale_from_path?;
let url = if cfg!(feature = "ssr") {
let req = use_context::<RequestUrl>().expect("no RequestUrl provided");
req.parse().expect("could not parse RequestUrl")
} else {
let location = BrowserUrl::new().expect("could not access browser navigation");
location.as_url().get_untracked()
};

crate::routing::get_locale_from_path(url.path(), &base_path)
}

pub fn signal_once_then<T: Clone + PartialEq + Send + Sync + 'static>(
start: T,
then: Memo<T>,
Expand All @@ -41,8 +64,12 @@ pub fn signal_maybe_once_then<T: Clone + PartialEq + Send + Sync + 'static>(
}

// ssr fetch
fn fetch_locale_ssr<L: Locale>(current_cookie: Option<L>, accepted_locale: Memo<L>) -> Memo<L> {
signal_maybe_once_then(current_cookie, accepted_locale)
fn fetch_locale_ssr<L: Locale>(
current_cookie: Option<L>,
accepted_locale: Memo<L>,
url_locale: Option<L>,
) -> Memo<L> {
signal_maybe_once_then(url_locale.or(current_cookie), accepted_locale)
}

// hydrate fetch
Expand All @@ -63,6 +90,10 @@ fn fetch_locale_hydrate<L: Locale>(current_cookie: Option<L>, accepted_locale: M
}

// csr fetch
fn fetch_locale_csr<L: Locale>(current_cookie: Option<L>, accepted_locale: Memo<L>) -> Memo<L> {
signal_maybe_once_then(current_cookie, accepted_locale)
fn fetch_locale_csr<L: Locale>(
current_cookie: Option<L>,
accepted_locale: Memo<L>,
url_locale: Option<L>,
) -> Memo<L> {
signal_maybe_once_then(url_locale.or(current_cookie), accepted_locale)
}
2 changes: 1 addition & 1 deletion leptos_i18n/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ pub mod __private {
#[cfg(feature = "plurals")]
pub use crate::formatting::get_plural_rules;
pub use crate::macro_helpers::*;
pub use crate::routing::{i18n_routing, BaseRoute, I18nNestedRoute};
pub use crate::routing::{i18n_routing, make_i18n_segment, BaseRoute, I18nSegment};
pub use leptos_i18n_macro as macros_reexport;
}

Expand Down
12 changes: 0 additions & 12 deletions leptos_i18n/src/locale_traits.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use icu_locid::{LanguageIdentifier, Locale as IcuLocale};
use leptos_router::ChooseView;
use std::str::FromStr;
use std::{fmt::Debug, hash::Hash};

Expand Down Expand Up @@ -31,9 +30,6 @@ pub trait Locale<L: Locale = Self>:
/// The associated struct containing the translations
type Keys: LocaleKeys<Locale = L>;

/// Associated routes for routing
type Routes<View, Chil>;

/// Associated `#[server]` function type to request the translations
#[cfg(feature = "dynamic_load")]
type ServerFn: leptos::server_fn::ServerFn;
Expand Down Expand Up @@ -91,14 +87,6 @@ pub trait Locale<L: Locale = Self>:
Self::from_base_locale(locale)
}

/// Make the routes
fn make_routes<View, Chil>(
base_route: crate::routing::BaseRoute<View, Chil>,
base_path: &'static str,
) -> Self::Routes<View, Chil>
where
View: ChooseView;

/// Associated `#[server]` function to request the translations
#[cfg(feature = "dynamic_load")]
fn request_translations(
Expand Down
12 changes: 12 additions & 0 deletions leptos_i18n/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,3 +944,15 @@ macro_rules! tu_plural_ordinal {
$crate::__private::macros_reexport::tu_plural_ordinal!{$($tt)*}
};
}

/// Create a route segment that is possible to define based on a locale.
///
/// ```rust, ignore
/// <Route path=i18n_path!(Locale, |locale| td_string(locale, path_name)) view=.. />
/// ```
#[macro_export]
macro_rules! i18n_path {
($t:ty, $func:expr) => {{
leptos_i18n::__private::make_i18n_segment::<$t, _>($func)
}};
}
Loading

0 comments on commit 959d365

Please sign in to comment.