Skip to content

Commit b5bb075

Browse files
committed
feat(i18n): added lightweight translator
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.
1 parent f5b5c28 commit b5bb075

File tree

8 files changed

+230
-9
lines changed

8 files changed

+230
-9
lines changed

examples/core/i18n/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ edition = "2021"
66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
77

88
[dependencies]
9+
# Note: this example can be used with `translator-fluent` or `translator-lightweight`
910
perseus = { path = "../../../packages/perseus", features = [ "translator-fluent", "hydrate" ] }
1011
sycamore = "=0.8.0-beta.7"
1112
serde = { version = "1", features = [ "derive" ] }

examples/core/i18n/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
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.
44

5-
Note that this i18n in this example uses the [Fluent](https://projectfluent.org) integration.
5+
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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"hello": "Hello, { $user }!",
3+
"about": "Welcome to the about page (English)!"
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"hello": "Hola!",
3+
"about": "Welcome to the about page (Spanish)!"
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"hello": "Bonjour!",
3+
"about": "Welcome to the about page (French)!"
4+
}

packages/perseus/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ wasm-bindgen-futures = "0.4"
5050
# BUG This adds 1.9kB to the production bundle (that's without size optimizations though)
5151
default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine" ]
5252
translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"]
53+
translator-lightweight = []
5354
# 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)
5455
macros = [ "perseus-macro" ]
5556
# This feature enable support for functions that make using the default engine configuration much easier.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use crate::translator::errors::*;
2+
use std::collections::HashMap;
3+
use sycamore::prelude::{use_context, Scope, Signal};
4+
5+
/// The file extension used by the lightweight translator, which expects JSON
6+
/// files.
7+
pub const LIGHTWEIGHT_TRANSLATOR_FILE_EXT: &str = "json";
8+
9+
/// Manages translations for a single locale using a custom lightweight
10+
/// translations management system optimized for systems that don't need
11+
/// [Fluent]()'s complexity. If you need control over things like
12+
/// pluralization, gender, etc., you should use the `translator-fluent`
13+
/// feature instead.
14+
///
15+
/// The reason this exists is to enable systems that don't need those features
16+
/// to access i18n with smaller Wasm bundle sizes, since Fluent tends to create
17+
/// substantial bloat.
18+
///
19+
/// Translations for this system should be specified in JSON form, with simple
20+
/// key-value pairs from translation ID to actual translation, with `{ $variable
21+
/// }` syntax used for variables (spacing matters!). If you need to do something
22+
/// like pluralization with this system, you should use multiple separate
23+
/// translation IDs.
24+
///
25+
/// This system supports variants only in the msot basic way: you could create
26+
/// multiple 'sub-ids' on ID `x` by having one ID called `x.y` and another
27+
/// called `x.z`, etc., but the system doesn't particularly care, unlike Fluent,
28+
/// which explicitly handles these cases.
29+
#[derive(Clone)]
30+
pub struct LightweightTranslator {
31+
/// The locale for which translations are being managed by this instance.
32+
locale: String,
33+
/// An internal store of the key-value pairs of translation IDs to
34+
/// translations.
35+
translations: HashMap<String, String>,
36+
}
37+
impl std::fmt::Debug for LightweightTranslator {
38+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39+
f.debug_struct("LightweightTranslator")
40+
.field("locale", &self.locale)
41+
.finish()
42+
}
43+
}
44+
impl LightweightTranslator {
45+
/// Creates a new translator for a given locale, passing in translations in
46+
/// JSON form.
47+
pub fn new(locale: String, json_string: String) -> Result<Self, TranslatorError> {
48+
// Deserialize the JSON
49+
let translations =
50+
serde_json::from_str::<HashMap<String, String>>(&json_string).map_err(|err| {
51+
TranslatorError::TranslationsStrSerFailed {
52+
locale: locale.to_string(),
53+
source: err.into(),
54+
}
55+
})?;
56+
57+
Ok(Self {
58+
translations,
59+
locale,
60+
})
61+
}
62+
/// Gets the path to the given URL in whatever locale the instance is
63+
/// configured for. This also applies the path prefix.
64+
pub fn url(&self, url: &str) -> String {
65+
format!("{}{}", self.locale, url)
66+
}
67+
/// Gets the locale for which this instancce is configured.
68+
pub fn get_locale(&self) -> String {
69+
self.locale.clone()
70+
}
71+
/// Translates the given ID. This additionally takes any arguments that
72+
/// should be interpolated. If your i18n system also has variants,
73+
/// they should be specified somehow in the ID.
74+
///
75+
/// # Panics
76+
/// This will `panic!` if any errors occur while trying to prepare the given
77+
/// ID. Therefore, this method should only be used for hardcoded IDs
78+
/// that can be confirmed as valid. If you need to parse arbitrary IDs, use
79+
/// `.translate_checked()` instead.
80+
pub fn translate(&self, id: &str, args: Option<TranslationArgs>) -> String {
81+
let translation_res = self.translate_checked(id, args);
82+
match translation_res {
83+
Ok(translation) => translation,
84+
Err(_) => panic!("translation id '{}' not found for locale '{}' (if you're not hardcoding the id, use `.translate_checked()` instead)", id, self.locale)
85+
}
86+
}
87+
/// Translates the given ID, returning graceful errors. This additionally
88+
/// takes any arguments that should be interpolated. If your i18n system
89+
/// also has variants, they should be specified somehow in the ID.
90+
pub fn translate_checked(
91+
&self,
92+
id: &str,
93+
args: Option<TranslationArgs>,
94+
) -> Result<String, TranslatorError> {
95+
match self.translations.get(id) {
96+
Some(translation) => {
97+
let mut translation = translation.to_string();
98+
// Loop through each of the arguments and interpolate them
99+
if let Some(args) = args {
100+
for (k, v) in args.0.iter() {
101+
// Replace `${<k>}`, with `v`
102+
translation = translation.replace(&format!("{{ ${} }}", k), v);
103+
}
104+
}
105+
Ok(translation)
106+
}
107+
None => Err(TranslatorError::TranslationIdNotFound {
108+
locale: self.locale.to_string(),
109+
id: id.to_string(),
110+
}),
111+
}
112+
}
113+
/// Gets the underlying translations for more advanced translation
114+
/// requirements.
115+
///
116+
/// Most of the time, if you need to call this, you should seriously
117+
/// consider using `translator-fluent` instead.
118+
pub fn get_bundle(&self) -> &HashMap<String, String> {
119+
&self.translations
120+
}
121+
}
122+
123+
/// A *very* simple argument interpolation system based on a `HashMap`. Any more
124+
/// complex functionality shoudl use `translator-fluent` instead.
125+
#[doc(hidden)]
126+
#[allow(missing_debug_implementations)]
127+
pub struct TranslationArgs(pub HashMap<String, String>);
128+
impl TranslationArgs {
129+
/// Alias for `.insert()` (needed for Fluent compat).
130+
pub fn set(&mut self, k: &str, v: &str) -> Option<String> {
131+
self.0.insert(k.to_string(), v.to_string())
132+
}
133+
/// Alias for `.get()` (needed for Fluent compat).
134+
pub fn get(&self, k: &str) -> Option<&String> {
135+
self.0.get(k)
136+
}
137+
/// Alias for `.new()` (needed for Fluent compat).
138+
pub fn new() -> Self {
139+
Self(HashMap::new())
140+
}
141+
}
142+
143+
/// The internal lightweight backend for the `t!` macro.
144+
#[doc(hidden)]
145+
pub fn t_macro_backend(id: &str, cx: Scope) -> String {
146+
let translator = use_context::<Signal<super::Translator>>(cx).get_untracked();
147+
translator.translate(id, None)
148+
}
149+
/// The internal lightweight backend for the `t!` macro, when it's used with
150+
/// arguments.
151+
#[doc(hidden)]
152+
pub fn t_macro_backend_with_args(id: &str, args: TranslationArgs, cx: Scope) -> String {
153+
let translator = use_context::<Signal<super::Translator>>(cx).get_untracked();
154+
translator.translate(id, Some(args))
155+
}
156+
/// The internal lightweight backend for the `link!` macro.
157+
#[doc(hidden)]
158+
pub fn link_macro_backend(url: &str, cx: Scope) -> String {
159+
let translator = use_context::<Signal<super::Translator>>(cx).get_untracked();
160+
translator.url(url)
161+
}

packages/perseus/src/translator/mod.rs

+54-8
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,20 @@ mod fluent;
88
#[cfg(feature = "translator-fluent")]
99
pub use fluent::{FluentTranslator, FLUENT_TRANSLATOR_FILE_EXT};
1010

11-
#[cfg(all(not(feature = "translator-fluent")))]
11+
#[cfg(feature = "translator-lightweight")]
12+
mod lightweight;
13+
#[cfg(feature = "translator-lightweight")]
14+
pub use lightweight::{LightweightTranslator, LIGHTWEIGHT_TRANSLATOR_FILE_EXT};
15+
16+
#[cfg(all(
17+
not(feature = "translator-fluent"),
18+
not(feature = "translator-lightweight")
19+
))]
1220
mod dummy;
13-
#[cfg(all(not(feature = "translator-fluent")))]
21+
#[cfg(all(
22+
not(feature = "translator-fluent"),
23+
not(feature = "translator-lightweight")
24+
))]
1425
pub use dummy::{DummyTranslator, DUMMY_TRANSLATOR_FILE_EXT};
1526

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

33+
#[cfg(feature = "translator-lightweight")]
34+
pub use LightweightTranslator as Translator;
35+
#[cfg(feature = "translator-lightweight")]
36+
pub use LIGHTWEIGHT_TRANSLATOR_FILE_EXT as TRANSLATOR_FILE_EXT;
37+
2238
// And then we export the appropriate macro backends, hidden from the docs
2339
#[cfg(feature = "translator-fluent")]
2440
#[doc(hidden)]
@@ -32,22 +48,52 @@ pub use fluent::t_macro_backend_with_args;
3248
#[cfg(feature = "translator-fluent")]
3349
pub use fluent::TranslationArgs;
3450

35-
#[cfg(all(not(feature = "translator-fluent")))]
51+
#[cfg(feature = "translator-lightweight")]
52+
#[doc(hidden)]
53+
pub use lightweight::link_macro_backend;
54+
#[cfg(feature = "translator-lightweight")]
55+
#[doc(hidden)]
56+
pub use lightweight::t_macro_backend;
57+
#[cfg(feature = "translator-lightweight")]
58+
#[doc(hidden)]
59+
pub use lightweight::t_macro_backend_with_args;
60+
#[cfg(feature = "translator-lightweight")]
61+
pub use lightweight::TranslationArgs;
62+
63+
#[cfg(all(
64+
not(feature = "translator-fluent"),
65+
not(feature = "translator-lightweight")
66+
))]
3667
#[doc(hidden)]
3768
pub use dummy::link_macro_backend;
38-
#[cfg(all(not(feature = "translator-fluent")))]
69+
#[cfg(all(
70+
not(feature = "translator-fluent"),
71+
not(feature = "translator-lightweight")
72+
))]
3973
#[doc(hidden)]
4074
pub use dummy::t_macro_backend;
41-
#[cfg(all(not(feature = "translator-fluent")))]
75+
#[cfg(all(
76+
not(feature = "translator-fluent"),
77+
not(feature = "translator-lightweight")
78+
))]
4279
#[doc(hidden)]
4380
pub use dummy::t_macro_backend_with_args;
44-
#[cfg(all(not(feature = "translator-fluent")))]
81+
#[cfg(all(
82+
not(feature = "translator-fluent"),
83+
not(feature = "translator-lightweight")
84+
))]
4585
pub use dummy::TranslationArgs;
4686

4787
// If no translators have been specified, we'll use a dummy one
48-
#[cfg(all(not(feature = "translator-fluent")))]
88+
#[cfg(all(
89+
not(feature = "translator-fluent"),
90+
not(feature = "translator-lightweight")
91+
))]
4992
pub use DummyTranslator as Translator;
50-
#[cfg(all(not(feature = "translator-fluent")))]
93+
#[cfg(all(
94+
not(feature = "translator-fluent"),
95+
not(feature = "translator-lightweight")
96+
))]
5197
pub use DUMMY_TRANSLATOR_FILE_EXT as TRANSLATOR_FILE_EXT;
5298

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

0 commit comments

Comments
 (0)