|
| 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 | +} |
0 commit comments