Skip to content

Commit

Permalink
feat(i18n): added lightweight translator
Browse files Browse the repository at this point in the history
This can be used in simple cases to reduce i18n bundle sizes by around
150kb, but it's not suitable for more advanced use-cases.
  • Loading branch information
arctic-hen7 committed Jul 17, 2022
1 parent f5b5c28 commit b5bb075
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 9 deletions.
1 change: 1 addition & 0 deletions examples/core/i18n/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
# Note: this example can be used with `translator-fluent` or `translator-lightweight`
perseus = { path = "../../../packages/perseus", features = [ "translator-fluent", "hydrate" ] }
sycamore = "=0.8.0-beta.7"
serde = { version = "1", features = [ "derive" ] }
Expand Down
2 changes: 1 addition & 1 deletion examples/core/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

This example shows a very basic Perseus app using internationalization (abbreviated *i18n*) in three languages: English, French, and Spanish. This shows how to use translations, access them, and how to insert variables into them.

Note that this i18n in this example uses the [Fluent](https://projectfluent.org) integration.
Note that this i18n in this example can use either the [Fluent](https://projectfluent.org) translator or the lightweight translator, so the `translations/` directory has both `.ftl` files (for Fluent), and `.json` files (for the lightweight translator). The optimized produced bundle sizes are 405.3kb and 289.4kb respectively.
4 changes: 4 additions & 0 deletions examples/core/i18n/translations/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"hello": "Hello, { $user }!",
"about": "Welcome to the about page (English)!"
}
4 changes: 4 additions & 0 deletions examples/core/i18n/translations/es-ES.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"hello": "Hola!",
"about": "Welcome to the about page (Spanish)!"
}
4 changes: 4 additions & 0 deletions examples/core/i18n/translations/fr-FR.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"hello": "Bonjour!",
"about": "Welcome to the about page (French)!"
}
1 change: 1 addition & 0 deletions packages/perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ wasm-bindgen-futures = "0.4"
# BUG This adds 1.9kB to the production bundle (that's without size optimizations though)
default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine" ]
translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"]
translator-lightweight = []
# This feature adds support for a number of macros that will make your life MUCH easier (read: use this unless you have very specific needs or are completely insane)
macros = [ "perseus-macro" ]
# This feature enable support for functions that make using the default engine configuration much easier.
Expand Down
161 changes: 161 additions & 0 deletions packages/perseus/src/translator/lightweight.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use crate::translator::errors::*;
use std::collections::HashMap;
use sycamore::prelude::{use_context, Scope, Signal};

/// The file extension used by the lightweight translator, which expects JSON
/// files.
pub const LIGHTWEIGHT_TRANSLATOR_FILE_EXT: &str = "json";

/// Manages translations for a single locale using a custom lightweight
/// translations management system optimized for systems that don't need
/// [Fluent]()'s complexity. If you need control over things like
/// pluralization, gender, etc., you should use the `translator-fluent`
/// feature instead.
///
/// The reason this exists is to enable systems that don't need those features
/// to access i18n with smaller Wasm bundle sizes, since Fluent tends to create
/// substantial bloat.
///
/// Translations for this system should be specified in JSON form, with simple
/// key-value pairs from translation ID to actual translation, with `{ $variable
/// }` syntax used for variables (spacing matters!). If you need to do something
/// like pluralization with this system, you should use multiple separate
/// translation IDs.
///
/// This system supports variants only in the msot basic way: you could create
/// multiple 'sub-ids' on ID `x` by having one ID called `x.y` and another
/// called `x.z`, etc., but the system doesn't particularly care, unlike Fluent,
/// which explicitly handles these cases.
#[derive(Clone)]
pub struct LightweightTranslator {
/// The locale for which translations are being managed by this instance.
locale: String,
/// An internal store of the key-value pairs of translation IDs to
/// translations.
translations: HashMap<String, String>,
}
impl std::fmt::Debug for LightweightTranslator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LightweightTranslator")
.field("locale", &self.locale)
.finish()
}
}
impl LightweightTranslator {
/// Creates a new translator for a given locale, passing in translations in
/// JSON form.
pub fn new(locale: String, json_string: String) -> Result<Self, TranslatorError> {
// Deserialize the JSON
let translations =
serde_json::from_str::<HashMap<String, String>>(&json_string).map_err(|err| {
TranslatorError::TranslationsStrSerFailed {
locale: locale.to_string(),
source: err.into(),
}
})?;

Ok(Self {
translations,
locale,
})
}
/// Gets the path to the given URL in whatever locale the instance is
/// configured for. This also applies the path prefix.
pub fn url(&self, url: &str) -> String {
format!("{}{}", self.locale, url)
}
/// Gets the locale for which this instancce is configured.
pub fn get_locale(&self) -> String {
self.locale.clone()
}
/// Translates the given ID. This additionally takes any arguments that
/// should be interpolated. If your i18n system also has variants,
/// they should be specified somehow in the ID.
///
/// # Panics
/// This will `panic!` if any errors occur while trying to prepare the given
/// ID. Therefore, this method should only be used for hardcoded IDs
/// that can be confirmed as valid. If you need to parse arbitrary IDs, use
/// `.translate_checked()` instead.
pub fn translate(&self, id: &str, args: Option<TranslationArgs>) -> String {
let translation_res = self.translate_checked(id, args);
match translation_res {
Ok(translation) => translation,
Err(_) => panic!("translation id '{}' not found for locale '{}' (if you're not hardcoding the id, use `.translate_checked()` instead)", id, self.locale)
}
}
/// Translates the given ID, returning graceful errors. This additionally
/// takes any arguments that should be interpolated. If your i18n system
/// also has variants, they should be specified somehow in the ID.
pub fn translate_checked(
&self,
id: &str,
args: Option<TranslationArgs>,
) -> Result<String, TranslatorError> {
match self.translations.get(id) {
Some(translation) => {
let mut translation = translation.to_string();
// Loop through each of the arguments and interpolate them
if let Some(args) = args {
for (k, v) in args.0.iter() {
// Replace `${<k>}`, with `v`
translation = translation.replace(&format!("{{ ${} }}", k), v);
}
}
Ok(translation)
}
None => Err(TranslatorError::TranslationIdNotFound {
locale: self.locale.to_string(),
id: id.to_string(),
}),
}
}
/// Gets the underlying translations for more advanced translation
/// requirements.
///
/// Most of the time, if you need to call this, you should seriously
/// consider using `translator-fluent` instead.
pub fn get_bundle(&self) -> &HashMap<String, String> {
&self.translations
}
}

/// A *very* simple argument interpolation system based on a `HashMap`. Any more
/// complex functionality shoudl use `translator-fluent` instead.
#[doc(hidden)]
#[allow(missing_debug_implementations)]
pub struct TranslationArgs(pub HashMap<String, String>);
impl TranslationArgs {
/// Alias for `.insert()` (needed for Fluent compat).
pub fn set(&mut self, k: &str, v: &str) -> Option<String> {
self.0.insert(k.to_string(), v.to_string())
}
/// Alias for `.get()` (needed for Fluent compat).
pub fn get(&self, k: &str) -> Option<&String> {
self.0.get(k)
}
/// Alias for `.new()` (needed for Fluent compat).
pub fn new() -> Self {
Self(HashMap::new())
}
}

/// The internal lightweight backend for the `t!` macro.
#[doc(hidden)]
pub fn t_macro_backend(id: &str, cx: Scope) -> String {
let translator = use_context::<Signal<super::Translator>>(cx).get_untracked();
translator.translate(id, None)
}
/// The internal lightweight backend for the `t!` macro, when it's used with
/// arguments.
#[doc(hidden)]
pub fn t_macro_backend_with_args(id: &str, args: TranslationArgs, cx: Scope) -> String {
let translator = use_context::<Signal<super::Translator>>(cx).get_untracked();
translator.translate(id, Some(args))
}
/// The internal lightweight backend for the `link!` macro.
#[doc(hidden)]
pub fn link_macro_backend(url: &str, cx: Scope) -> String {
let translator = use_context::<Signal<super::Translator>>(cx).get_untracked();
translator.url(url)
}
62 changes: 54 additions & 8 deletions packages/perseus/src/translator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@ mod fluent;
#[cfg(feature = "translator-fluent")]
pub use fluent::{FluentTranslator, FLUENT_TRANSLATOR_FILE_EXT};

#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(feature = "translator-lightweight")]
mod lightweight;
#[cfg(feature = "translator-lightweight")]
pub use lightweight::{LightweightTranslator, LIGHTWEIGHT_TRANSLATOR_FILE_EXT};

#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
mod dummy;
#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
pub use dummy::{DummyTranslator, DUMMY_TRANSLATOR_FILE_EXT};

// And then we export defaults using feature gates
Expand All @@ -19,6 +30,11 @@ pub use FluentTranslator as Translator;
#[cfg(feature = "translator-fluent")]
pub use FLUENT_TRANSLATOR_FILE_EXT as TRANSLATOR_FILE_EXT;

#[cfg(feature = "translator-lightweight")]
pub use LightweightTranslator as Translator;
#[cfg(feature = "translator-lightweight")]
pub use LIGHTWEIGHT_TRANSLATOR_FILE_EXT as TRANSLATOR_FILE_EXT;

// And then we export the appropriate macro backends, hidden from the docs
#[cfg(feature = "translator-fluent")]
#[doc(hidden)]
Expand All @@ -32,22 +48,52 @@ pub use fluent::t_macro_backend_with_args;
#[cfg(feature = "translator-fluent")]
pub use fluent::TranslationArgs;

#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(feature = "translator-lightweight")]
#[doc(hidden)]
pub use lightweight::link_macro_backend;
#[cfg(feature = "translator-lightweight")]
#[doc(hidden)]
pub use lightweight::t_macro_backend;
#[cfg(feature = "translator-lightweight")]
#[doc(hidden)]
pub use lightweight::t_macro_backend_with_args;
#[cfg(feature = "translator-lightweight")]
pub use lightweight::TranslationArgs;

#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
#[doc(hidden)]
pub use dummy::link_macro_backend;
#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
#[doc(hidden)]
pub use dummy::t_macro_backend;
#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
#[doc(hidden)]
pub use dummy::t_macro_backend_with_args;
#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
pub use dummy::TranslationArgs;

// If no translators have been specified, we'll use a dummy one
#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
pub use DummyTranslator as Translator;
#[cfg(all(not(feature = "translator-fluent")))]
#[cfg(all(
not(feature = "translator-fluent"),
not(feature = "translator-lightweight")
))]
pub use DUMMY_TRANSLATOR_FILE_EXT as TRANSLATOR_FILE_EXT;

/// Translates the given ID conveniently, taking arguments for interpolation as
Expand Down

0 comments on commit b5bb075

Please sign in to comment.