Internationalization

One of the most useful features of Perseus for larger apps is its inbuilt support for internatinalization, or i18n for short, which means making your app available in multiple languages. This is typically done by replacing all instances of human language in your code (e.g. the Hello World! string) with translation IDs, which are then resolved automatically to the correct text based on what locale the user is viewing the page in. Locales are defined in Perseus, as in other systems, as consisting of a language code and a region code: for example, en-US represents United States English, whereas en-GB represents British English. Note that this locale system is far from perfect, but it's currently a global standard, and it's used by browsers for declaring the preferred languages of their users.

When you make your app available in multiple languages, Perseus will automatically take each of the locales you've specified and build every page in every single one of those locales (this will increase build times, but this is usually imperceptible, especially since everything is aggressively parallelized). Let's say your app is available in three languages: US English, Spanish, and French. This would mean your three locales might be en-US, fr-FR, and es-ES (es for Español). This leads to Perseus taking your landing page (previously available at /), and localizing it to /en-US/, /fr-FR/, and /es-ES/. Similarly, your about page (formerly at /about) will become /en-US/about, /fr-FR/about, and /es-ES/about. You get the picture.

But how do we know what language a user wants their pages in? Some sites figure this out by detecting what country you're in, to the peril of anyone using a VPN who slowly starts to learn Dutch against their will. The much better way of doing this is to just ask the browser, because users can configure their browsers with an arbitrary number of ordered locale preferences. For example, a Chinese native speaker who lives in Germany but is fluent in English might number her preferences as: zh-CN, de-DE, en, in that order. Notice the lack of a region code on the final preference (this is common). The process of locale detection is a complex one that requires comparing the languages an app has available with those a user would like to see. Unlike all other current frameworks, Perseus performs this process totally automatically according to web standards (see RFC 4647). So, if our Chinese-German English speaker from before goes to /about, she will be redirected to /en-US/about automatically (since her first two preferences are unavailable). From here, any links will keep her in the en-US locale.

You can set up internationalization in your app through PerseusApp like so:

mod templates;

use perseus::prelude::*;

#[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
    PerseusApp::new()
        .template(crate::templates::index::get_template())
        .template(crate::templates::about::get_template())
        .template(crate::templates::post::get_template())
        .error_views(ErrorViews::unlocalized_development_default())
        .locales_and_translations_manager("en-US", &["fr-FR", "es-ES"])
}

Translations

Translations in Perseus are handled through the TranslationsManager trait, which is described in further detail here, but you'll usually store them in a folder called translations/ at the root of your project. The translator you're using will determine the format of these.

In Perseus, translators are controlled by feature flags, which are mutually exclusive. Currently, there are just two: the Fluent translator, and the simple translator. The former uses .ftl files, which are a complex system of defining translations that can handle gender, pluralization, and all sorts of other linguistic difficulties, whereas the latter is a drop-dead-simple JSON file of translation IDs with very basic variable interpolation. Generally, it's recommended to only use the Fluent translator if you really need it, because it will add about 100kB of extra Wasm to your bundle.wasm, which will slow down initial loads a little (this is pre-compression, however). The Fluent translator is enabled by the translator-fluent feature flag, and the simple one corresponds to translator-lightweight.

Take a look at this example for how a full i18n-ed app looks (or you can take a look at the source code of this website!). Once you've defined some translations IDs, you can use them like so:

use perseus::prelude::*;
use sycamore::prelude::*;

fn index_page<G: Html>(cx: Scope) -> View<G> {
    let username = "User";

    view! { cx,
        p { (t!(cx, "hello", {
            "user" = username
        })) }
        a(href = link!(cx, "/about")) { "About" }
    }
}

pub fn get_template<G: Html>() -> Template<G> {
    Template::build("index").view(index_page).build()
}

The critical point here is the use of t! macro, which takes in the render context and a translation ID, and outputs the localized version of the ID in the current locale (assuming it exists, otherwise it will panic). Variables can be interpolated by providing a third object, as shown in the above example.

Localized routing

To write an href or imperative routing call to another page in an app using i18n, you want to make sure you're going to the right locale, and not causing locale detection all over again. To do this, you can use the link! macro, which automatically prepends the correct locale.

Switching locales

Switching locales is actually incredibly easy: there's no context to update, or special subroutine to inform, you just navigate appropriately, and Perseus figures it out (because it's in charge of routing). By not using the link! macro, and instead navigating directly to a page like /fr-FR/about, users will be switched into the fr-FR locale, which the link! macro will then automatically apply after that.

If you're using a component to perform locale switching (often included in the header or footer), you'll want to check what path a user is currently on so you switch the locale for the current page. This is typically done through a Reactor convenience method:

Reactor::<G>::from_cx(cx).switch_locale("fr-FR")

Here, we're of course switching to fr-FR. This will implicitly involve a navigation and the fetching of the new translations.