Skip to content

Commit b7ad607

Browse files
committed
feat(i18n): ✨ added locale detection
Currently redirects to blank page that needs reloading until Sycamore v0.6.0.
1 parent cbfe50c commit b7ad607

File tree

8 files changed

+102
-14
lines changed

8 files changed

+102
-14
lines changed

examples/cli/.perseus/src/lib.rs

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use app::{get_error_pages, get_locales, get_routes, APP_ROUTE};
22
use perseus::router::{RouteInfo, RouteVerdict};
3-
use perseus::{app_shell, ClientTranslationsManager, DomNode};
3+
use perseus::{app_shell, detect_locale, ClientTranslationsManager, DomNode};
44
use std::cell::RefCell;
55
use std::rc::Rc;
66
use sycamore::prelude::template;
@@ -49,14 +49,13 @@ pub fn run() -> Result<(), JsValue> {
4949
path,
5050
template_fn,
5151
locale,
52-
// We give the app shell a translations manager and let it get the `Rc<Translator>` (because it can do async safely)
52+
// We give the app shell a translations manager and let it get the `Rc<Translator>` itself (because it can do async safely)
5353
Rc::clone(&translations_manager),
5454
Rc::clone(&error_pages)
5555
),
5656
// If the user is using i18n, then they'll want to detect the locale on any paths missing a locale
5757
// Those all go to the same system that redirects to the appropriate locale
58-
// TODO locale detection
59-
RouteVerdict::LocaleDetection(_) => get_error_pages().get_template_for_page("", &400, "locale detection not yet supported", None),
58+
RouteVerdict::LocaleDetection(path) => detect_locale(path, get_locales()),
6059
// We handle the 404 for the user for convenience
6160
// To get a translator here, we'd have to go async and dangerously check the URL
6261
RouteVerdict::NotFound => get_error_pages().get_template_for_page("", &404, "not found", None),

examples/i18n/src/templates/about.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use perseus::{Template, Translator, t};
1+
use perseus::{t, Template, Translator};
22
use std::rc::Rc;
33
use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate};
44

examples/i18n/src/templates/index.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use perseus::{Template, Translator, link, t};
1+
use perseus::{link, t, Template, Translator};
22
use std::rc::Rc;
33
use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate};
44

packages/perseus/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ categories = ["wasm", "web-programming", "development-tools", "asynchronous", "g
1616
[dependencies]
1717
sycamore = { version = "0.5", features = ["ssr"] }
1818
sycamore-router = "0.5"
19-
web-sys = { version = "0.3", features = ["Headers", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window"] }
19+
web-sys = { version = "0.3", features = ["Headers", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window"] }
2020
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
2121
wasm-bindgen-futures = "0.4"
2222
serde = { version = "1", features = ["derive"] }

packages/perseus/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ mod client_translations_manager;
4242
pub mod config_manager;
4343
mod decode_time_str;
4444
pub mod errors;
45+
mod locale_detector;
4546
mod locales;
4647
mod log;
4748
mod macros;
@@ -69,6 +70,7 @@ pub use crate::build::{build_app, build_template, build_templates_for_locale};
6970
pub use crate::client_translations_manager::ClientTranslationsManager;
7071
pub use crate::config_manager::{ConfigManager, FsConfigManager};
7172
pub use crate::errors::{err_to_status_code, ErrorCause};
73+
pub use crate::locale_detector::detect_locale;
7274
pub use crate::locales::Locales;
7375
pub use crate::serve::{get_page, get_render_cfg};
7476
pub use crate::shell::{app_shell, ErrorPages};
+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use crate::DomNode;
2+
use crate::Locales;
3+
use sycamore::prelude::{template, Template as SycamoreTemplate};
4+
use sycamore_router::navigate;
5+
6+
/// Detects which locale the user should be served and redirects appropriately. This should only be used when the user navigates to a
7+
/// page like `/about`, without a locale. This will only work on the client-side (needs access to browser i18n settings). Any pages
8+
/// that direct to this should be explicitly excluded from search engines (they don't show anything until redirected). This is guided
9+
/// by [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt), but is not yet fully compliant (only supports `xx-XX` form locales).
10+
pub fn detect_locale(url: String, locales: Locales) -> SycamoreTemplate<DomNode> {
11+
// If nothing matches, we'll use the default locale
12+
let mut locale = locales.default.clone();
13+
14+
// We'll use `navigator.languages` to figure out the best locale, falling back to `navigator.language` if necessary
15+
let navigator = web_sys::window().unwrap().navigator();
16+
let langs = navigator.languages().to_vec();
17+
if langs.is_empty() {
18+
// We'll fall back to `language`, which only gives us one locale to compare with
19+
// If that isn't supported, we'll automatically fall back to the default locale
20+
if let Some(lang) = navigator.language() {
21+
locale = match compare_locale(&lang, locales.get_all()) {
22+
LocaleMatch::Exact(matched) | LocaleMatch::Language(matched) => matched,
23+
LocaleMatch::None => locales.default,
24+
}
25+
}
26+
} else {
27+
// We'll match each language individually, remembering that any exact match is preferable to a language-only match
28+
for cmp in langs {
29+
// We can reasonably assume that the user's locales are strings
30+
let cmp_str = cmp.as_string().unwrap();
31+
// As per RFC 4647, the first match (exact or language-only) is the one we'll use
32+
if let LocaleMatch::Exact(matched) | LocaleMatch::Language(matched) =
33+
compare_locale(&cmp_str, locales.get_all())
34+
{
35+
locale = matched;
36+
break;
37+
}
38+
}
39+
}
40+
41+
// Imperatively navigate to the localized route
42+
navigate(&format!("/{}/{}", locale, url));
43+
// We'll never actually get here, but we need a sensible return type
44+
template! {}
45+
}
46+
47+
/// The possible outcomes of trying to match a locale.
48+
enum LocaleMatch {
49+
/// The language and region match to a supported locale.
50+
Exact(String),
51+
/// The language (but not the region) matches a supported locale, the first supported locale with that language will be used.
52+
Language(String),
53+
/// The given locale isn't supported at all. If all the user's requested locales return this, we should fall back to the default.
54+
None,
55+
}
56+
57+
/// Compares the given locale with the given vector of locales, identifying the closest match. This handles possible case discrepancies
58+
/// automatically (e.g. Safari before iOS 10.2 returned all locales in lower-case).
59+
///
60+
/// Exact matches with any supported locale are preferred to language-only (and not region) matches. Remember that this function
61+
/// only matches a single locale, not the list of the preferred locales (in which the first of either kind of match is used as per
62+
/// [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt)).
63+
///
64+
/// This does NOT comply fully with [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt) yet, as only `xx-XX` form locales are
65+
/// currently supported. This functionality will eventually be broken out into a separate module for ease of use.
66+
fn compare_locale(cmp: &str, locales: Vec<&String>) -> LocaleMatch {
67+
let mut outcome = LocaleMatch::None;
68+
// Split into language and region (e.g. `en-US`) if possible
69+
let cmp_parts: Vec<&str> = cmp.split('-').collect();
70+
71+
for locale in locales {
72+
// Split into language and region (e.g. `en-US`) if possible
73+
let parts: Vec<&str> = locale.split('-').collect();
74+
if locale == cmp {
75+
outcome = LocaleMatch::Exact(locale.to_string());
76+
// Any exact match voids anything after it (it'll be further down the list or only a partial match from here on)
77+
break;
78+
} else if cmp_parts.get(0) == parts.get(0) {
79+
// If we've already had a partial match higher up the chain, this is void
80+
// But we shouldn't break in case there's an exact match coming up
81+
if !matches!(outcome, LocaleMatch::Language(_)) {
82+
outcome = LocaleMatch::Language(locale.to_string())
83+
}
84+
}
85+
// If there's no match, just continue on for now
86+
}
87+
88+
outcome
89+
}

packages/perseus/src/template.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use futures::Future;
77
use std::collections::HashMap;
88
use std::pin::Pin;
99
use std::rc::Rc;
10-
use sycamore::prelude::{GenericNode, Template as SycamoreTemplate, template};
10+
use sycamore::prelude::{template, GenericNode, Template as SycamoreTemplate};
1111
use sycamore::rx::{ContextProvider, ContextProviderProps};
1212

1313
/// Represents all the different states that can be generated for a single template, allowing amalgamation logic to be run with the knowledge

packages/perseus/src/translator/mod.rs

+4-6
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ macro_rules! t {
4242
/// Gets the link to the given resource in internationalized form conveniently.
4343
#[macro_export]
4444
macro_rules! link {
45-
($url:expr) => {
46-
{
47-
let translator = ::sycamore::rx::use_context::<Rc<Translator>>();
48-
translator.url($url)
49-
}
50-
};
45+
($url:expr) => {{
46+
let translator = ::sycamore::rx::use_context::<Rc<Translator>>();
47+
translator.url($url)
48+
}};
5149
}

0 commit comments

Comments
 (0)