Skip to content

Commit

Permalink
feat(i18n): ✨ added i18n to error pages and integrated fluent
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `Translator` no longer `Serialize`/`Deserialize`
  • Loading branch information
arctic-hen7 committed Sep 9, 2021
1 parent 6786ff4 commit 89fa00e
Show file tree
Hide file tree
Showing 23 changed files with 247 additions and 107 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Perseus is a blazingly fast frontend web development framework built in Rust wit
- ✨ Supports incremental regeneration (build on demand)
- ✨ Open build matrix (use any rendering strategy with anything else, mostly)
- ✨ CLI harness that lets you build apps with ease and confidence
- ✨ Full i18n support out-of-the-box with [Fluent](https://projectfluent.org)

## How to use

Expand Down
3 changes: 2 additions & 1 deletion examples/cli/.perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ pub fn run() -> Result<(), JsValue> {
BrowserRouter(move |route: AppRoute| {
match route {
// We handle the 404 for the user for convenience
AppRoute::NotFound => get_error_pages().get_template_for_page("", &404, "not found"),
// To get a translator here, we'd have to go async and dangerously check the URL
AppRoute::NotFound => get_error_pages().get_template_for_page("", &404, "not found", None),
// All other routes are based on the user's given statements
_ => {
let (name, render_fn, locale) = match_route(route);
Expand Down
6 changes: 3 additions & 3 deletions examples/cli/src/error_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ use perseus::ErrorPages;
use sycamore::template;

pub fn get_error_pages() -> ErrorPages {
let mut error_pages = ErrorPages::new(Box::new(|_, _, _| {
let mut error_pages = ErrorPages::new(Box::new(|_, _, _, _| {
template! {
p { "Another error occurred." }
}
}));
error_pages.add_page(
404,
Box::new(|_, _, _| {
Box::new(|_, _, _, _| {
template! {
p { "Page not found." }
}
}),
);
error_pages.add_page(
400,
Box::new(|_, _, _| {
Box::new(|_, _, _, _| {
template! {
p { "Client error occurred..." }
}
Expand Down
1 change: 1 addition & 0 deletions examples/i18n/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ sycamore-router = "0.5.1"
# Serde, which lets you work with representations of data, like JSON
serde = { version = "1", features = ["derive"] }
serde_json = "1"
fluent-bundle = "0.15"
6 changes: 3 additions & 3 deletions examples/i18n/src/error_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ use perseus::ErrorPages;
use sycamore::template;

pub fn get_error_pages() -> ErrorPages {
let mut error_pages = ErrorPages::new(Box::new(|_, _, _| {
let mut error_pages = ErrorPages::new(Box::new(|_, _, _, _| {
template! {
p { "Another error occurred." }
}
}));
error_pages.add_page(
404,
Box::new(|_, _, _| {
Box::new(|_, _, _, _| {
template! {
p { "Page not found." }
}
}),
);
error_pages.add_page(
400,
Box::new(|_, _, _| {
Box::new(|_, _, _, _| {
template! {
p { "Client error occurred..." }
}
Expand Down
2 changes: 1 addition & 1 deletion examples/i18n/src/templates/about.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTe
pub fn about_page(translator: Rc<Translator>) -> SycamoreTemplate<G> {
template! {
// TODO switch to `t!` macro
p { (translator.translate("about")) }
p { (translator.translate("about", None)) }
}
}

Expand Down
6 changes: 5 additions & 1 deletion examples/i18n/src/templates/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ pub fn index_page(translator: Rc<Translator>) -> SycamoreTemplate<G> {
let translator_2 = Rc::clone(&translator);
template! {
// TODO switch to `t!` macro
p { (translator_1.translate("hello")) }
p { (translator_1.translate("hello", {
let mut args = fluent_bundle::FluentArgs::new();
args.set("user", "User");
Some(args)
})) }
a(href = translator_2.url("/about")) { "About" }
}
}
Expand Down
2 changes: 2 additions & 0 deletions examples/i18n/translations/en-US.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hello = Hello, { $user }!
about = Welcome to the about page (English)!
4 changes: 0 additions & 4 deletions examples/i18n/translations/en-US.json

This file was deleted.

2 changes: 2 additions & 0 deletions examples/i18n/translations/es-ES.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hello = Hola!
about = Welcome to the about page (Spanish)!
4 changes: 0 additions & 4 deletions examples/i18n/translations/es-ES.json

This file was deleted.

2 changes: 2 additions & 0 deletions examples/i18n/translations/fr-FR.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
hello = Bonjour!
about = Welcome to the about page (French)!
4 changes: 0 additions & 4 deletions examples/i18n/translations/fr-FR.json

This file was deleted.

4 changes: 3 additions & 1 deletion packages/perseus-actix-web/src/translations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ pub async fn translations<T: TranslationsManager>(
// Check if the locale is supported
if opts.locales.is_supported(locale) {
// We know that the locale is supported, so any failure to get translations is a 500
let translations = translations_manager.get_translations_str_for_locale(locale.to_string()).await;
let translations = translations_manager
.get_translations_str_for_locale(locale.to_string())
.await;
let translations = match translations {
Ok(translations) => translations,
Err(err) => return HttpResponse::InternalServerError().body(err.to_string()),
Expand Down
2 changes: 2 additions & 0 deletions packages/perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ urlencoding = "2.1"
chrono = "0.4"
http = "0.2"
async-trait = "0.1"
fluent-bundle = "0.15"
unic-langid = "0.9"
2 changes: 1 addition & 1 deletion packages/perseus/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pub async fn build_template(
false => urlencoding::encode(&template_path).to_string(),
};
// Add the current locale to the front of that
let full_path = format!("{}-{}", translator.locale, full_path);
let full_path = format!("{}-{}", translator.get_locale(), full_path);

// Handle static initial state generation
// We'll only write a static state if one is explicitly generated
Expand Down
20 changes: 9 additions & 11 deletions packages/perseus/src/client_translations_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ use crate::shell::fetch;
use crate::Locales;
use crate::Translator;
use std::rc::Rc;
use std::collections::HashMap;

/// Manages translations in the app shell. This handles fetching translations from the server as well as caching for performance.
/// This is distinct from `TranslationsManager` in that it operates on the client-side rather than on the server. This optimizes for
Expand All @@ -27,7 +26,7 @@ impl ClientTranslationsManager {
pub async fn get_translator_for_locale(&mut self, locale: &str) -> Result<Rc<Translator>> {
// Check if we've already cached
if self.cached_translator.is_some()
&& self.cached_translator.as_ref().unwrap().locale == locale
&& self.cached_translator.as_ref().unwrap().get_locale() == locale
{
Ok(Rc::clone(self.cached_translator.as_ref().unwrap()))
} else {
Expand All @@ -36,15 +35,14 @@ impl ClientTranslationsManager {
// Get the translations data
let asset_url = format!("/.perseus/translations/{}", locale);
// If this doesn't exist, then it's a 404 (we went here by explicit navigation after checking the locale, so that's a bug)
let translator_str = fetch(&asset_url).await;
let translator = match translator_str {
Ok(translator_str) => match translator_str {
Some(translator_str) => {
// All good, deserialize the translations
let translations = serde_json::from_str::<HashMap<String, String>>(&translator_str);
match translations {
// And then turn them into a translator
Ok(translations) => Translator::new(locale.to_string(), translations),
let translations_str = fetch(&asset_url).await;
let translator = match translations_str {
Ok(translations_str) => match translations_str {
Some(translations_str) => {
// All good, turn the translations into a translator
let translator = Translator::new(locale.to_string(), translations_str);
match translator {
Ok(translator) => translator,
Err(err) => {
bail!(ErrorKind::AssetSerFailed(asset_url, err.to_string()))
}
Expand Down
7 changes: 1 addition & 6 deletions packages/perseus/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,11 @@ error_chain! {
description("error while calling render function")
display("an error caused by '{:?}' occurred while calling render function '{}' on template '{}': '{}'", cause, fn_name, template, err_str)
}
/// For when a translation ID doesn't exist. This indicates that `translate_checked()` was deliberately used, because `translate()`
/// will panic in this scenario.
TranslationIdNotFound(id: String, locale: String) {
description("translation id not found for current locale")
display("translation id '{}' not found for locale '{}'", id, locale)
}
}
links {
ConfigManager(crate::config_manager::Error, crate::config_manager::ErrorKind);
TranslationsManager(crate::translations_manager::Error, crate::translations_manager::ErrorKind);
Translator(crate::translator::Error, crate::translator::ErrorKind);
}
// We work with many external libraries, all of which have their own errors
foreign_links {
Expand Down
4 changes: 3 additions & 1 deletion packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
*/

#![deny(missing_docs)]
#![recursion_limit = "256"]

/// Utilities for building your app.
pub mod build;
Expand All @@ -43,7 +44,8 @@ pub mod shell;
pub mod template;
/// Utilities for creating custom translations managers, as well as the default `FsTranslationsManager`.
pub mod translations_manager;
mod translator;
/// Utilities regarding translators, including the default `FluentTranslator`.
pub mod translator;

pub use http;
pub use http::Request as HttpRequest;
Expand Down
7 changes: 6 additions & 1 deletion packages/perseus/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ macro_rules! define_get_translations_manager {
.cloned()
.cloned()
.collect();
$crate::FsTranslationsManager::new("../translations".to_string(), all_locales).await
$crate::FsTranslationsManager::new(
"../translations".to_string(),
all_locales,
"ftl".to_string(),
)
.await
}
};
($locales:expr, $translations_manager:expr) => {
Expand Down
36 changes: 25 additions & 11 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::errors::*;
use crate::serve::PageData;
use crate::template::TemplateFn;
use crate::ClientTranslationsManager;
use crate::Translator;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
Expand Down Expand Up @@ -52,9 +53,11 @@ pub(crate) async fn fetch(url: &str) -> Result<Option<String>> {
}
}

/// The callback to a template the user must provide for error pages. This is passed the status code, the error message, and the URL of
/// the problematic asset.
pub type ErrorPageTemplate<G> = Box<dyn Fn(&str, &u16, &str) -> SycamoreTemplate<G>>;
/// The callback to a template the user must provide for error pages. This is passed the status code, the error message, the URL of the
/// problematic asset, and a translator if one is available . Many error pages are generated when a translator is not available or
/// couldn't be instantiated, so you'll need to rely on symbols or the like in these cases.
pub type ErrorPageTemplate<G> =
Box<dyn Fn(&str, &u16, &str, Option<Rc<Translator>>) -> SycamoreTemplate<G>>;

/// A type alias for the `HashMap` the user should provide for error pages.
pub struct ErrorPages {
Expand All @@ -75,7 +78,14 @@ impl ErrorPages {
self.status_pages.insert(status, page);
}
/// Renders the appropriate error page to the given DOM container.
pub fn render_page(&self, url: &str, status: &u16, err: &str, container: &NodeRef<DomNode>) {
pub fn render_page(
&self,
url: &str,
status: &u16,
err: &str,
translator: Option<Rc<Translator>>,
container: &NodeRef<DomNode>,
) {
// Check if we have an explicitly defined page for this status code
// If not, we'll render the fallback page
let template_fn = match self.status_pages.contains_key(status) {
Expand All @@ -84,7 +94,7 @@ impl ErrorPages {
};
// Render that to the given container
sycamore::render_to(
|| template_fn(url, status, err),
|| template_fn(url, status, err, translator),
&container.get::<DomNode>().inner_element(),
);
}
Expand All @@ -94,6 +104,7 @@ impl ErrorPages {
url: &str,
status: &u16,
err: &str,
translator: Option<Rc<Translator>>,
) -> SycamoreTemplate<DomNode> {
// Check if we have an explicitly defined page for this status code
// If not, we'll render the fallback page
Expand All @@ -102,7 +113,7 @@ impl ErrorPages {
false => &self.fallback,
};

template_fn(url, status, err)
template_fn(url, status, err, translator)
}
}

Expand Down Expand Up @@ -144,10 +155,11 @@ pub fn app_shell(
let translator = match translator {
Ok(translator) => translator,
Err(err) => match err.kind() {
// These errors happen because we couldn't get a translator, so they certainly don't get one
// TODO assign status codes to client-side errors and do all this automatically
ErrorKind::AssetNotOk(url, status, err) => return error_pages.render_page(url, status, err, &container),
ErrorKind::AssetSerFailed(url, err) => return error_pages.render_page(url, &500, err, &container),
ErrorKind::LocaleNotSupported(locale) => return error_pages.render_page(&format!("/{}/...", locale), &404, &format!("locale '{}' not supported", locale), &container),
ErrorKind::AssetNotOk(url, status, _) => return error_pages.render_page(url, status, &err.to_string(), None, &container),
ErrorKind::AssetSerFailed(url, _) => return error_pages.render_page(url, &500, &err.to_string(), None, &container),
ErrorKind::LocaleNotSupported(locale) => return error_pages.render_page(&format!("/{}/...", locale), &404, &err.to_string(),None, &container),
// No other errors should be returned
_ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error")
}
Expand All @@ -164,10 +176,12 @@ pub fn app_shell(
Err(err) => panic!("page data couldn't be serialized: '{}'", err)
};
},
None => error_pages.render_page(&asset_url, &404, "page not found", &container),
// No translators ready yet
None => error_pages.render_page(&asset_url, &404, "page not found", None, &container),
},
Err(err) => match err.kind() {
ErrorKind::AssetNotOk(url, status, err) => error_pages.render_page(url, status, err, &container),
// No translators ready yet
ErrorKind::AssetNotOk(url, status, _) => error_pages.render_page(url, status, &err.to_string(), None, &container),
// No other errors should be returned
_ => panic!("expected 'AssetNotOk' error, found other unacceptable error")
}
Expand Down
Loading

0 comments on commit 89fa00e

Please sign in to comment.