diff --git a/Cargo.toml b/Cargo.toml index b10add2b28..b60a8635ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,12 +3,14 @@ members = [ "packages/perseus", "packages/perseus-actix-web", "packages/perseus-cli", - "examples/showcase/app", - "examples/showcase/server-actix-web", + # TODO remake showcase app for CLI + # "examples/showcase/app", + # "examples/showcase/server-actix-web", "examples/cli", # We have the CLI subcrates as workspace members so we can actively develop on them # They also can't be a workspace until nested workspaces are supported "examples/cli/.perseus", "examples/cli/.perseus/server", - "examples/basic" + "examples/basic", + "examples/i18n" ] diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 9241461b2d..a4d2f1fedd 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -9,7 +9,7 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -perseus = "0.1.1" +perseus = { path = "../../packages/perseus" } sycamore = { version = "0.5.1", features = ["ssr"] } sycamore-router = "0.5.1" serde = { version = "1", features = ["derive"] } diff --git a/examples/cli/.perseus/server/src/main.rs b/examples/cli/.perseus/server/src/main.rs index 13a57e2aaf..3d6e39f906 100644 --- a/examples/cli/.perseus/server/src/main.rs +++ b/examples/cli/.perseus/server/src/main.rs @@ -1,5 +1,5 @@ use actix_web::{App, HttpServer}; -use app::{get_config_manager, get_templates_map}; +use app::{get_config_manager, get_locales, get_templates_map, get_translations_manager}; use futures::executor::block_on; use perseus_actix_web::{configurer, Options}; use std::env; @@ -23,8 +23,10 @@ async fn main() -> std::io::Result<()> { // Our crate has the same name, so this will be predictable wasm_bundle: "dist/pkg/perseus_cli_builder_bg.wasm".to_string(), templates_map: get_templates_map(), + locales: get_locales(), }, get_config_manager(), + get_translations_manager(), ))) }) .bind((host, port))? diff --git a/examples/cli/.perseus/src/bin/build.rs b/examples/cli/.perseus/src/bin/build.rs index 8cd49090be..5c15cc65ee 100644 --- a/examples/cli/.perseus/src/bin/build.rs +++ b/examples/cli/.perseus/src/bin/build.rs @@ -1,15 +1,30 @@ -use app::{get_config_manager, get_templates_vec}; +use app::{get_config_manager, get_locales, get_templates_vec, get_translations_manager}; use futures::executor::block_on; -use perseus::{build_templates, SsrNode}; +use perseus::{build_app, SsrNode}; fn main() { + let exit_code = real_main(); + std::process::exit(exit_code) +} + +fn real_main() -> i32 { let config_manager = get_config_manager(); + let translations_manager = get_translations_manager(); + let locales = get_locales(); - let fut = build_templates(get_templates_vec::(), &config_manager); + // Build the site for all the common locales (done in parallel) + let fut = build_app( + get_templates_vec::(), + &locales, + &config_manager, + &translations_manager, + ); let res = block_on(fut); if let Err(err) = res { eprintln!("Static generation failed: '{}'", err); + 1 } else { println!("Static generation successfully completed!"); + 0 } } diff --git a/examples/cli/.perseus/src/build.rs b/examples/cli/.perseus/src/build.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/cli/.perseus/src/lib.rs b/examples/cli/.perseus/src/lib.rs index 14e2339d9d..fd5a8c58df 100644 --- a/examples/cli/.perseus/src/lib.rs +++ b/examples/cli/.perseus/src/lib.rs @@ -1,5 +1,7 @@ -use app::{get_error_pages, match_route, AppRoute, APP_ROUTE}; -use perseus::app_shell; +use app::{get_error_pages, get_locales, match_route, AppRoute, APP_ROUTE}; +use perseus::{app_shell, ClientTranslationsManager}; +use std::cell::RefCell; +use std::rc::Rc; use sycamore::prelude::template; use sycamore_router::BrowserRouter; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; @@ -20,20 +22,28 @@ pub fn run() -> Result<(), JsValue> { .unwrap() .unwrap(); + // Create a mutable translations manager to control caching + let translations_manager = + Rc::new(RefCell::new(ClientTranslationsManager::new(&get_locales()))); + sycamore::render_to( || { template! { - BrowserRouter(|route: AppRoute| { - // TODO improve performance rather than naively copying error pages for every template + BrowserRouter(move |route: AppRoute| { + // TODO improve performance rather than naively copying error pages for every template (use `Rc`) match route { // We handle the 404 for the user for convenience AppRoute::NotFound => get_error_pages().get_template_for_page("", &404, "not found"), // All other routes are based on the user's given statements _ => { - let (name, render_fn) = match_route(route); + let (name, render_fn, locale) = match_route(route); + app_shell( name, render_fn, + locale, + // We give the app shell a translations manager and let it get the `Rc` (because it can do async safely) + Rc::clone(&translations_manager), get_error_pages() ) } diff --git a/examples/cli/.perseus/src/serve.rs b/examples/cli/.perseus/src/serve.rs deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/cli/src/lib.rs b/examples/cli/src/lib.rs index 07de06812c..b998a16c3d 100644 --- a/examples/cli/src/lib.rs +++ b/examples/cli/src/lib.rs @@ -18,17 +18,24 @@ define_app! { router: { Route::Index => [ "index".to_string(), - pages::index::template_fn() + pages::index::template_fn(), + "en-US".to_string() ], Route::About => [ "about".to_string(), - pages::about::template_fn() + pages::about::template_fn(), + "en-US".to_string() ] }, error_pages: crate::error_pages::get_error_pages(), templates: [ crate::pages::index::get_page::(), crate::pages::about::get_page::() - ] + ], + locales: { + default: "en-US", + common: [], + other: [] + } // config_manager: perseus::FsConfigManager::new() } diff --git a/examples/cli/src/pages/about.rs b/examples/cli/src/pages/about.rs index 73e3715271..d288ea00e9 100644 --- a/examples/cli/src/pages/about.rs +++ b/examples/cli/src/pages/about.rs @@ -14,7 +14,7 @@ pub fn get_page() -> Template { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|_| { + Arc::new(|_, _| { template! { AboutPage() } diff --git a/examples/cli/src/pages/index.rs b/examples/cli/src/pages/index.rs index 66d90c279a..a7be2e7e0b 100644 --- a/examples/cli/src/pages/index.rs +++ b/examples/cli/src/pages/index.rs @@ -30,7 +30,7 @@ pub async fn get_static_props(_path: String) -> StringResultWithCause { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props: Option| { + Arc::new(|props: Option, _| { template! { IndexPage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/examples/i18n/.gitignore b/examples/i18n/.gitignore new file mode 100644 index 0000000000..3b1525da45 --- /dev/null +++ b/examples/i18n/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock + +.perseus/ \ No newline at end of file diff --git a/examples/i18n/Cargo.toml b/examples/i18n/Cargo.toml new file mode 100644 index 0000000000..fff21147cf --- /dev/null +++ b/examples/i18n/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "perseus-example-i18n" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# Perseus itself, which we (amazingly) need for a Perseus app +perseus = { path = "../../packages/perseus" } +# Sycamore, the library Perseus depends on for lower-leve reactivity primitivity +sycamore = { version = "0.5.1", features = ["ssr"] } +sycamore-router = "0.5.1" +# Serde, which lets you work with representations of data, like JSON +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/examples/i18n/index.html b/examples/i18n/index.html new file mode 100644 index 0000000000..dc31105f88 --- /dev/null +++ b/examples/i18n/index.html @@ -0,0 +1,14 @@ + + + + + + + Perseus Starter App + + + + +
+ + diff --git a/examples/i18n/src/error_pages.rs b/examples/i18n/src/error_pages.rs new file mode 100644 index 0000000000..7e14b3a70c --- /dev/null +++ b/examples/i18n/src/error_pages.rs @@ -0,0 +1,28 @@ +use perseus::ErrorPages; +use sycamore::template; + +pub fn get_error_pages() -> ErrorPages { + let mut error_pages = ErrorPages::new(Box::new(|_, _, _| { + template! { + p { "Another error occurred." } + } + })); + error_pages.add_page( + 404, + Box::new(|_, _, _| { + template! { + p { "Page not found." } + } + }), + ); + error_pages.add_page( + 400, + Box::new(|_, _, _| { + template! { + p { "Client error occurred..." } + } + }), + ); + + error_pages +} diff --git a/examples/i18n/src/lib.rs b/examples/i18n/src/lib.rs new file mode 100644 index 0000000000..c11dafd73c --- /dev/null +++ b/examples/i18n/src/lib.rs @@ -0,0 +1,41 @@ +mod error_pages; +mod templates; + +use perseus::define_app; + +#[derive(perseus::Route)] +pub enum Route { + #[to("/")] + Index { locale: String }, + #[to("//about")] + About { locale: String }, + #[not_found] + NotFound, +} + +define_app! { + root: "#root", + route: Route, + router: { + Route::Index { locale } => [ + "index".to_string(), + templates::index::template_fn(), + locale + ], + Route::About { locale } => [ + "about".to_string(), + templates::about::template_fn(), + locale + ] + }, + error_pages: crate::error_pages::get_error_pages(), + templates: [ + crate::templates::index::get_template::(), + crate::templates::about::get_template::() + ], + locales: { + default: "en-US", + common: ["en-US", "fr-FR"], + other: ["es-ES"] + } +} diff --git a/examples/i18n/src/templates/about.rs b/examples/i18n/src/templates/about.rs new file mode 100644 index 0000000000..dfd3d8b8ba --- /dev/null +++ b/examples/i18n/src/templates/about.rs @@ -0,0 +1,24 @@ +use perseus::{t, Template, Translator}; +use std::rc::Rc; +use std::sync::Arc; +use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; + +#[component(AboutPage)] +pub fn about_page(translator: Rc) -> SycamoreTemplate { + template! { + // TODO switch to `t!` macro + p { (translator.translate("about")) } + } +} + +pub fn template_fn() -> perseus::template::TemplateFn { + Arc::new(|_, translator: Rc| { + template! { + AboutPage(translator) + } + }) +} + +pub fn get_template() -> Template { + Template::new("about").template(template_fn()) +} diff --git a/examples/i18n/src/templates/index.rs b/examples/i18n/src/templates/index.rs new file mode 100644 index 0000000000..8bd2190113 --- /dev/null +++ b/examples/i18n/src/templates/index.rs @@ -0,0 +1,25 @@ +use perseus::{t, Template, Translator}; +use std::rc::Rc; +use std::sync::Arc; +use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; + +#[component(IndexPage)] +pub fn index_page(translator: Rc) -> SycamoreTemplate { + template! { + // TODO switch to `t!` macro + p { (translator.translate("hello")) } + a(href = "/en-US/about") { "About" } + } +} + +pub fn template_fn() -> perseus::template::TemplateFn { + Arc::new(|_, translator: Rc| { + template! { + IndexPage(translator) + } + }) +} + +pub fn get_template() -> Template { + Template::new("index").template(template_fn()) +} diff --git a/examples/i18n/src/templates/mod.rs b/examples/i18n/src/templates/mod.rs new file mode 100644 index 0000000000..9b9cf18fc5 --- /dev/null +++ b/examples/i18n/src/templates/mod.rs @@ -0,0 +1,2 @@ +pub mod about; +pub mod index; diff --git a/examples/i18n/translations/en-US.json b/examples/i18n/translations/en-US.json new file mode 100644 index 0000000000..e4acbf1ba5 --- /dev/null +++ b/examples/i18n/translations/en-US.json @@ -0,0 +1,4 @@ +{ + "hello": "Welcome to the app!", + "about": "Welcome to the about page (English)!" +} diff --git a/examples/i18n/translations/es-ES.json b/examples/i18n/translations/es-ES.json new file mode 100644 index 0000000000..b862220f71 --- /dev/null +++ b/examples/i18n/translations/es-ES.json @@ -0,0 +1,4 @@ +{ + "hello": "Hola!", + "about": "Welcome to the about page (Spanish)!" +} diff --git a/examples/i18n/translations/fr-FR.json b/examples/i18n/translations/fr-FR.json new file mode 100644 index 0000000000..f45d2ed342 --- /dev/null +++ b/examples/i18n/translations/fr-FR.json @@ -0,0 +1,4 @@ +{ + "hello": "Bonjour!", + "about": "Welcome to the about page (French)!" +} diff --git a/examples/showcase/app/src/bin/build.rs b/examples/showcase/app/src/bin/build.rs index 52ff345f4b..78c10f85ee 100644 --- a/examples/showcase/app/src/bin/build.rs +++ b/examples/showcase/app/src/bin/build.rs @@ -1,11 +1,11 @@ use futures::executor::block_on; -use perseus::{build_templates, FsConfigManager, SsrNode}; +use perseus::{build_templates_for_locale, FsConfigManager, SsrNode, Translator}; use perseus_showcase_app::pages; fn main() { let config_manager = FsConfigManager::new("./dist".to_string()); - let fut = build_templates( + let fut = build_templates_for_locale( vec![ pages::index::get_page::(), pages::about::get_page::(), @@ -16,6 +16,7 @@ fn main() { pages::time_root::get_page::(), pages::amalgamation::get_page::(), ], + Translator::empty(), &config_manager, ); block_on(fut).expect("Static generation failed!"); diff --git a/examples/showcase/app/src/pages/about.rs b/examples/showcase/app/src/pages/about.rs index 73e3715271..d288ea00e9 100644 --- a/examples/showcase/app/src/pages/about.rs +++ b/examples/showcase/app/src/pages/about.rs @@ -14,7 +14,7 @@ pub fn get_page() -> Template { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|_| { + Arc::new(|_, _| { template! { AboutPage() } diff --git a/examples/showcase/app/src/pages/amalgamation.rs b/examples/showcase/app/src/pages/amalgamation.rs index cc8ba8a5fa..2346307c2e 100644 --- a/examples/showcase/app/src/pages/amalgamation.rs +++ b/examples/showcase/app/src/pages/amalgamation.rs @@ -57,7 +57,7 @@ pub async fn get_request_state(_path: String, _req: Request) -> StringResultWith } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props| { + Arc::new(|props, _| { template! { AboutPage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/examples/showcase/app/src/pages/index.rs b/examples/showcase/app/src/pages/index.rs index 66d90c279a..cc350af35f 100644 --- a/examples/showcase/app/src/pages/index.rs +++ b/examples/showcase/app/src/pages/index.rs @@ -30,7 +30,7 @@ pub async fn get_static_props(_path: String) -> StringResultWithCause { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props: Option| { + Arc::new(|props, _| { template! { IndexPage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/examples/showcase/app/src/pages/ip.rs b/examples/showcase/app/src/pages/ip.rs index d9e62ff9cd..64e85ce04d 100644 --- a/examples/showcase/app/src/pages/ip.rs +++ b/examples/showcase/app/src/pages/ip.rs @@ -42,7 +42,7 @@ pub async fn get_request_state(_path: String, req: Request) -> StringResultWithC } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props: Option| { + Arc::new(|props, _| { template! { IpPage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/examples/showcase/app/src/pages/new_post.rs b/examples/showcase/app/src/pages/new_post.rs index c3bf932c5b..98719e6f78 100644 --- a/examples/showcase/app/src/pages/new_post.rs +++ b/examples/showcase/app/src/pages/new_post.rs @@ -14,7 +14,7 @@ pub fn get_page() -> Template { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|_| { + Arc::new(|_, _| { template! { NewPostPage() } diff --git a/examples/showcase/app/src/pages/post.rs b/examples/showcase/app/src/pages/post.rs index 3d447adb51..1b731f04e6 100644 --- a/examples/showcase/app/src/pages/post.rs +++ b/examples/showcase/app/src/pages/post.rs @@ -55,7 +55,7 @@ pub async fn get_static_paths() -> Result, String> { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props: Option| { + Arc::new(|props, _| { template! { PostPage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/examples/showcase/app/src/pages/time.rs b/examples/showcase/app/src/pages/time.rs index 3b3173bb95..283effec00 100644 --- a/examples/showcase/app/src/pages/time.rs +++ b/examples/showcase/app/src/pages/time.rs @@ -37,7 +37,7 @@ pub async fn get_build_paths() -> Result, String> { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props: Option| { + Arc::new(|props, _| { template! { TimePage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/examples/showcase/app/src/pages/time_root.rs b/examples/showcase/app/src/pages/time_root.rs index 5074c7c180..53216b9e20 100644 --- a/examples/showcase/app/src/pages/time_root.rs +++ b/examples/showcase/app/src/pages/time_root.rs @@ -33,7 +33,7 @@ pub async fn get_build_state(_path: String) -> StringResultWithCause { } pub fn template_fn() -> perseus::template::TemplateFn { - Arc::new(|props: Option| { + Arc::new(|props, _| { template! { TimePage( serde_json::from_str::(&props.unwrap()).unwrap() diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index 0295e24489..d40690800d 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -1,7 +1,8 @@ use crate::page_data::page_data; +use crate::translations::translations; use actix_files::NamedFile; use actix_web::web; -use perseus::{get_render_cfg, ConfigManager, SsrNode, TemplateMap}; +use perseus::{get_render_cfg, ConfigManager, Locales, SsrNode, TemplateMap, TranslationsManager}; /// The options for setting up the Actix Web integration. This should be literally constructed, as nothing is optional. #[derive(Clone)] @@ -14,6 +15,8 @@ pub struct Options { pub index: String, /// A `HashMap` of your app's templates by their paths. pub templates_map: TemplateMap, + /// The locales information for the app. + pub locales: Locales, } async fn js_bundle(opts: web::Data) -> std::io::Result { @@ -27,18 +30,20 @@ async fn index(opts: web::Data) -> std::io::Result { } /// Configures an existing Actix Web app for Perseus. This returns a function that does the configuring so it can take arguments. -pub async fn configurer( +pub async fn configurer( opts: Options, config_manager: C, + translations_manager: T, ) -> impl Fn(&mut web::ServiceConfig) { let render_cfg = get_render_cfg(&config_manager) .await .expect("Couldn't get render configuration!"); move |cfg: &mut web::ServiceConfig| { cfg - // We implant the render config in the app data for bertter performance, it's needed on every request + // We implant the render config in the app data for better performance, it's needed on every request .data(render_cfg.clone()) .data(config_manager.clone()) + .data(translations_manager.clone()) .data(opts.clone()) // TODO chunk JS and WASM bundles // These allow getting the basic app code (not including the static data) @@ -48,11 +53,15 @@ pub async fn configurer( // This allows getting the static HTML/JSON of a page // We stream both together in a single JSON object so SSR works (otherwise we'd have request IDs and weird caching...) .route( - "/.perseus/page/{filename:.*}", - web::get().to(page_data::), + "/.perseus/page/{locale}/{filename:.*}", + web::get().to(page_data::), + ) + // This allows the app shell to fetch translations for a given page + .route( + "/.perseus/translations/{locale}", + web::get().to(translations::), ) // For everything else, we'll serve the app shell directly - // FIXME .route("*", web::get().to(index)); } } diff --git a/packages/perseus-actix-web/src/lib.rs b/packages/perseus-actix-web/src/lib.rs index 2014605c83..8f0224e620 100644 --- a/packages/perseus-actix-web/src/lib.rs +++ b/packages/perseus-actix-web/src/lib.rs @@ -32,5 +32,6 @@ mod configurer; mod conv_req; pub mod errors; mod page_data; +mod translations; pub use crate::configurer::{configurer, Options}; diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index 7f425fd120..850ed9cf69 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -1,40 +1,52 @@ use crate::conv_req::convert_req; use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; -use perseus::{err_to_status_code, get_page, ConfigManager}; +use perseus::{err_to_status_code, get_page, ConfigManager, TranslationsManager}; use std::collections::HashMap; /// The handler for calls to `.perseus/page/*`. This will manage returning errors and the like. -pub async fn page_data( +pub async fn page_data( req: HttpRequest, opts: web::Data, render_cfg: web::Data>, config_manager: web::Data, + translations_manager: web::Data, ) -> HttpResponse { let templates = &opts.templates_map; - let path = req.match_info().query("filename"); - // We need to turn the Actix Web request into one acceptable for Perseus (uses `http` internally) - let http_req = convert_req(&req); - let http_req = match http_req { - Ok(http_req) => http_req, - // If this fails, the client request is malformed, so it's a 400 - Err(err) => { - return HttpResponse::build(StatusCode::from_u16(400).unwrap()).body(err.to_string()) - } - }; - let page_data = get_page( - path, - http_req, - &render_cfg, - templates, - config_manager.get_ref(), - ) - .await; + let locale = req.match_info().query("locale"); + // Check if the locale is supported + if opts.locales.is_supported(locale) { + let path = req.match_info().query("filename"); + // We need to turn the Actix Web request into one acceptable for Perseus (uses `http` internally) + let http_req = convert_req(&req); + let http_req = match http_req { + Ok(http_req) => http_req, + // If this fails, the client request is malformed, so it's a 400 + Err(err) => { + return HttpResponse::build(StatusCode::from_u16(400).unwrap()) + .body(err.to_string()) + } + }; + let page_data = get_page( + path, + locale, + http_req, + &render_cfg, + templates, + config_manager.get_ref(), + translations_manager.get_ref(), + ) + .await; - match page_data { - Ok(page_data) => HttpResponse::Ok().body(serde_json::to_string(&page_data).unwrap()), - // We parse the error to return an appropriate status code - Err(err) => HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) - .body(err.to_string()), + match page_data { + Ok(page_data) => HttpResponse::Ok().body(serde_json::to_string(&page_data).unwrap()), + // We parse the error to return an appropriate status code + Err(err) => { + HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) + .body(err.to_string()) + } + } + } else { + HttpResponse::NotFound().body("locale not supported".to_string()) } } diff --git a/packages/perseus-actix-web/src/translations.rs b/packages/perseus-actix-web/src/translations.rs new file mode 100644 index 0000000000..79393550c2 --- /dev/null +++ b/packages/perseus-actix-web/src/translations.rs @@ -0,0 +1,34 @@ +use crate::Options; +use actix_web::{web, HttpRequest, HttpResponse}; +use perseus::TranslationsManager; + +/// The handler for calls to `.perseus/page/*`. This will manage returning errors and the like. +pub async fn translations( + req: HttpRequest, + opts: web::Data, + translations_manager: web::Data, +) -> HttpResponse { + let locale = req.match_info().query("locale"); + // Check if the locale is supported + if opts.locales.is_supported(locale) { + // Create a translator for that locale (and hope the implementation is caching for sanity) + // We know that it''s supported, which means a failure is a 500 + let translator = translations_manager + .get_translator_for_locale(locale.to_string()) + .await; + let translator = match translator { + Ok(translator) => translator, + Err(err) => return HttpResponse::InternalServerError().body(err.to_string()), + }; + // Serialize that into a JSON response + let json = serde_json::to_string(&translator); + let json = match json { + Ok(json) => json, + Err(err) => return HttpResponse::InternalServerError().body(err.to_string()), + }; + + HttpResponse::Ok().body(json) + } else { + HttpResponse::NotFound().body("locale not supported".to_string()) + } +} diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index b5e15e8be4..6522715aca 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -1,5 +1,7 @@ use perseus_cli::errors::*; -use perseus_cli::{build, check_env, delete_artifacts, delete_bad_dir, help, prepare, serve, PERSEUS_VERSION}; +use perseus_cli::{ + build, check_env, delete_artifacts, delete_bad_dir, help, prepare, serve, PERSEUS_VERSION, +}; use std::env; use std::io::Write; use std::path::PathBuf; @@ -8,7 +10,8 @@ use std::path::PathBuf; fn main() { // In development, we'll test in the `basic` example if cfg!(debug_assertions) { - let example_to_test = env::var("TEST_EXAMPLE").unwrap_or_else(|_| "../../examples/basic".to_string()); + let example_to_test = + env::var("TEST_EXAMPLE").unwrap_or_else(|_| "../../examples/basic".to_string()); env::set_current_dir(example_to_test).unwrap(); } let exit_code = real_main(); diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index f0c6c98f13..ebf875bfc9 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -1,9 +1,13 @@ // This binary builds all the templates with SSG use crate::errors::*; +use crate::Locales; +use crate::TranslationsManager; +use crate::Translator; use crate::{config_manager::ConfigManager, decode_time_str::decode_time_str, template::Template}; use futures::future::try_join_all; use std::collections::HashMap; +use std::rc::Rc; use sycamore::prelude::SsrNode; /// Builds a template, writing static data as appropriate. This should be used as part of a larger build process. This returns both a list @@ -11,7 +15,8 @@ use sycamore::prelude::SsrNode; /// as to whether or not it only generated a single page to occupy the template's root path (`true` unless using using build-time path /// generation). pub async fn build_template( - template: Template, + template: &Template, + translator: Rc, config_manager: &impl ConfigManager, ) -> Result<(Vec, bool)> { let mut single_page = false; @@ -36,6 +41,8 @@ pub async fn build_template( // We don't want to concatenate the name twice if we don't have to false => urlencoding::encode(&template_path).to_string(), }; + // Add the current locale to the front of that + let full_path = format!("{}-{}", translator.locale, full_path); // Handle static initial state generation // We'll only write a static state if one is explicitly generated @@ -47,8 +54,9 @@ pub async fn build_template( .write(&format!("static/{}.json", full_path), &initial_state) .await?; // Prerender the template using that state - let prerendered = - sycamore::render_to_string(|| template.render_for_template(Some(initial_state))); + let prerendered = sycamore::render_to_string(|| { + template.render_for_template(Some(initial_state), Rc::clone(&translator)) + }); // Write that prerendered HTML to a static file config_manager .write(&format!("static/{}.html", full_path), &prerendered) @@ -62,6 +70,7 @@ pub async fn build_template( decode_time_str(&template.get_revalidate_interval().unwrap())?; // Write that to a static file, we'll update it every time we revalidate // Note that this runs for every path generated, so it's fully usable with ISR + // Yes, there's a different revalidation schedule for each locale, but that means we don't have to rebuild every locale simultaneously config_manager .write( &format!("static/{}.revld.txt", full_path), @@ -76,7 +85,9 @@ pub async fn build_template( // If the template is very basic, prerender without any state // It's safe to add a property to the render options here because `.is_basic()` will only return true if path generation is not being used (or anything else) if template.is_basic() { - let prerendered = sycamore::render_to_string(|| template.render_for_template(None)); + let prerendered = sycamore::render_to_string(|| { + template.render_for_template(None, Rc::clone(&translator)) + }); // Write that prerendered HTML to a static file config_manager .write(&format!("static/{}.html", full_path), &prerendered) @@ -88,14 +99,15 @@ pub async fn build_template( } async fn build_template_and_get_cfg( - template: Template, + template: &Template, + translator: Rc, config_manager: &impl ConfigManager, ) -> Result> { let mut render_cfg = HashMap::new(); let template_root_path = template.get_path(); let is_incremental = template.uses_incremental(); - let (pages, single_page) = build_template(template, config_manager).await?; + let (pages, single_page) = build_template(template, translator, config_manager).await?; // If the template represents a single page itself, we don't need any concatenation if single_page { render_cfg.insert(template_root_path.clone(), template_root_path.clone()); @@ -120,17 +132,24 @@ async fn build_template_and_get_cfg( Ok(render_cfg) } -/// Runs the build process of building many different templates. -pub async fn build_templates( - templates: Vec>, +/// Runs the build process of building many different templates for a single locale. If you're not using i18n, provide a `Translator::empty()` +/// for this. You should only build the most commonly used locales here (the rest should be built on demand). +pub async fn build_templates_for_locale( + templates: &[Template], + translator_raw: Translator, config_manager: &impl ConfigManager, ) -> Result<()> { + let translator = Rc::new(translator_raw); // The render configuration stores a list of pages to the root paths of their templates let mut render_cfg: HashMap = HashMap::new(); // Create each of the templates let mut futs = Vec::new(); for template in templates { - futs.push(build_template_and_get_cfg(template, config_manager)); + futs.push(build_template_and_get_cfg( + template, + Rc::clone(&translator), + config_manager, + )); } let template_cfgs = try_join_all(futs).await?; for template_cfg in template_cfgs { @@ -143,3 +162,43 @@ pub async fn build_templates( Ok(()) } + +/// Gets a translator and builds templates for a single locale. +async fn build_templates_and_translator_for_locale( + templates: &[Template], + locale: String, + config_manager: &impl ConfigManager, + translations_manager: &impl TranslationsManager, +) -> Result<()> { + let translator = translations_manager + .get_translator_for_locale(locale) + .await?; + build_templates_for_locale(templates, translator, config_manager).await?; + + Ok(()) +} + +/// Runs the build process of building many templates for the given locales data, building directly for the default and common locales. +/// Any other locales should be built on demand. +pub async fn build_app( + templates: Vec>, + locales: &Locales, + config_manager: &impl ConfigManager, + translations_manager: &impl TranslationsManager, +) -> Result<()> { + let locales_to_build = locales.get_default_and_common(); + let mut futs = Vec::new(); + + for locale in locales_to_build { + futs.push(build_templates_and_translator_for_locale( + &templates, + locale.to_string(), + config_manager, + translations_manager, + )); + } + // Build all locales in parallel + try_join_all(futs).await?; + + Ok(()) +} diff --git a/packages/perseus/src/client_translations_manager.rs b/packages/perseus/src/client_translations_manager.rs new file mode 100644 index 0000000000..4c92044676 --- /dev/null +++ b/packages/perseus/src/client_translations_manager.rs @@ -0,0 +1,76 @@ +use crate::errors::*; +use crate::shell::fetch; +use crate::Locales; +use crate::Translator; +use std::rc::Rc; + +/// 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 +/// users viewing many pages in the same locale, which is by far the most common use of most websites in terms of i18n. +pub struct ClientTranslationsManager { + /// The cached translator. If the same locale is requested again, this will simply be returned. + cached_translator: Option>, + locales: Locales, +} +impl ClientTranslationsManager { + /// Creates a new client-side translations manager that hasn't cached anything yet. This needs to know about an app's supported locales + /// so it can avoid network requests to unsupported locales. + pub fn new(locales: &Locales) -> Self { + Self { + cached_translator: None, + locales: locales.clone(), + } + } + /// Gets an `Rc` for the given locale. This will use the internally cached `Translator` if possible, and will otherwise + /// fetch the translations from the server. This needs mutability because it will modify its internal cache if necessary. + pub async fn get_translator_for_locale(&mut self, locale: &str) -> Result> { + // Check if we've already cached + if self.cached_translator.is_some() + && self.cached_translator.as_ref().unwrap().locale == locale + { + Ok(Rc::clone(self.cached_translator.as_ref().unwrap())) + } else { + // Check if the locale is supported + if self.locales.is_supported(locale) { + // 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 translator + let translator = serde_json::from_str::(&translator_str); + match translator { + Ok(translator) => translator, + Err(err) => { + bail!(ErrorKind::AssetSerFailed(asset_url, err.to_string())) + } + } + } + // If we get a 404 for a supported locale, that's an exception + None => panic!( + "server returned 404 for translations for known supported locale '{}'", + locale + ), + }, + Err(err) => match err.kind() { + ErrorKind::AssetNotOk(url, status, err) => bail!(ErrorKind::AssetNotOk( + url.to_string(), + *status, + err.to_string() + )), + // No other errors should be returned + _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), + }, + }; + // Cache that translator + self.cached_translator = Some(Rc::new(translator)); + // Now return that + Ok(Rc::clone(self.cached_translator.as_ref().unwrap())) + } else { + bail!(ErrorKind::LocaleNotSupported(locale.to_string())) + } + } + } +} diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index bf21da962a..d98f383bee 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -30,6 +30,17 @@ error_chain! { description("the asset couldn't be fecthed with a 200 OK") display("the asset at '{}' returned status code '{}' with payload '{}'", url, status, err) } + /// For when the server returned an asset that was 200 but couldn't be serialized properly. This is the server's fault, and + /// should generate a 500 status code at presentation. + AssetSerFailed(url: String, err: String) { + description("the asset couldn't be properly serialized") + display("the asset at '{}' was successfully fetched, but couldn't be serialized with error '{}'", url, err) + } + /// For when the user requested an unsupported locale. This should generate a 404 at presentation. + LocaleNotSupported(locale: String) { + description("the given locale is not supported") + display("the locale '{}' is not supported", locale) + } /// For when a necessary template feautre was expected but not present. This just pertains to rendering strategies, and shouldn't /// ever be sensitive. @@ -61,9 +72,16 @@ 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); } // We work with many external libraries, all of which have their own errors foreign_links { diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index a98a63de19..0152f73833 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -29,6 +29,7 @@ /// Utilities for building your app. pub mod build; +mod client_translations_manager; /// Utilities for creating custom config managers, as well as the default `FsConfigManager`. pub mod config_manager; mod decode_time_str; @@ -40,6 +41,9 @@ pub mod serve; pub mod shell; /// Utilities to do with templating. This is where the bulk of designing apps lies. pub mod template; +/// Utilities for creating custom translations managers, as well as the default `FsTranslationsManager`. +pub mod translations_manager; +mod translator; pub use http; pub use http::Request as HttpRequest; @@ -48,9 +52,12 @@ pub type Request = HttpRequest<()>; pub use sycamore::{generic_node::GenericNode, DomNode, SsrNode}; pub use sycamore_router::Route; -pub use crate::build::{build_template, build_templates}; +pub use crate::build::{build_app, build_template, build_templates_for_locale}; +pub use crate::client_translations_manager::ClientTranslationsManager; pub use crate::config_manager::{ConfigManager, FsConfigManager}; pub use crate::errors::{err_to_status_code, ErrorCause}; pub use crate::serve::{get_page, get_render_cfg}; pub use crate::shell::{app_shell, ErrorPages}; pub use crate::template::{States, StringResult, StringResultWithCause, Template, TemplateMap}; +pub use crate::translations_manager::{FsTranslationsManager, TranslationsManager}; +pub use crate::translator::{Locales, Translator}; diff --git a/packages/perseus/src/macros.rs b/packages/perseus/src/macros.rs index 090121d3d4..c49b058b00 100644 --- a/packages/perseus/src/macros.rs +++ b/packages/perseus/src/macros.rs @@ -13,6 +13,22 @@ macro_rules! define_get_config_manager { } }; } +/// An internal macro used for defining a function to get the user's preferred translations manager (which requires multiple branches). +#[macro_export] +macro_rules! define_get_translations_manager { + () => { + pub fn get_translations_manager() -> impl $crate::TranslationsManager { + // This will be executed in the context of the user's directory, but moved into `.perseus` + // Note that `translations/` must be next to `src/`, not within it + $crate::FsTranslationsManager::new("../translations".to_string()) + } + }; + ($translations_manager:expr) => { + pub fn get_translations_manager() -> impl $crate::TranslationsManager { + $translations_manager + } + }; +} /// Defines the components to create an entrypoint for the app. The actual entrypoint is created in the `.perseus/` crate (where we can /// get all the dependencies without driving the user's `Cargo.toml` nuts). This also defines the template map. This is intended to make @@ -28,15 +44,24 @@ macro_rules! define_app { $( $pat:pat => [ $name:expr, - $fn:expr + $fn:expr, + $locale:expr ] ),+ }, error_pages: $error_pages:expr, templates: [ $($template:expr),+ - ] + ], + // This deliberately enforces verbose i18n definition, and forces developers to consider i18n as integral + locales: { + default: $default_locale:literal, + // The user doesn't have to define any common/other locales + common: [$($common_locale:literal),*], + other: [$($other_locale:literal),*] + } $(,config_manager: $config_manager:expr)? + $(,translations_manager: $translations_manager:expr)? } => { /// The CSS selector that will find the app root to render Perseus in. pub const APP_ROUTE: &str = $root_selector; @@ -44,10 +69,28 @@ macro_rules! define_app { // We alias the user's route enum so that don't have to worry about naming pub type AppRoute = $route; - /// Gets the config manager to use. This allows the user to conveniently test production managers in development. If nothing is given - /// the filesystem will be used. + /// Gets the config manager to use. This allows the user to conveniently test production managers in development. If nothing is + /// given, the filesystem will be used. $crate::define_get_config_manager!($($config_manager)?); + /// Gets the translations manager to use. This allows the user to conveniently test production managers in development. If + /// nothing is given, the filesystem will be used. + $crate::define_get_translations_manager!($($translations_manager)?); + + /// Defines the locales the app should build for, specifying defaults and common locales (which will be built at build-time + /// rather than on-demand). + pub fn get_locales() -> $crate::Locales { + $crate::Locales { + default: $default_locale.to_string(), + common: vec![ + $($common_locale.to_string()),* + ], + other: vec![ + $($other_locale.to_string()),* + ] + } + } + /// Gets a map of all the templates in the app by their root paths. pub fn get_templates_map() -> $crate::TemplateMap { $crate::get_templates_map![ @@ -67,14 +110,15 @@ macro_rules! define_app { $error_pages } - /// Matches the given route to a template name and render function. - pub fn match_route(route: $route) -> (String, $crate::template::TemplateFn<$crate::DomNode>) { + /// Matches the given route to a template name, render function, and locale. + pub fn match_route(route: $route) -> (String, $crate::template::TemplateFn<$crate::DomNode>, String) { match route { // We regurgitate all the user's custom matches $( $pat => ( $name, $fn, + $locale ), )+ // We MUST handle the NotFound route before this function diff --git a/packages/perseus/src/serve.rs b/packages/perseus/src/serve.rs index c83414a858..55ad9a9b64 100644 --- a/packages/perseus/src/serve.rs +++ b/packages/perseus/src/serve.rs @@ -5,9 +5,12 @@ use crate::decode_time_str::decode_time_str; use crate::errors::*; use crate::template::{States, Template, TemplateMap}; use crate::Request; +use crate::TranslationsManager; +use crate::Translator; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::rc::Rc; use sycamore::prelude::SsrNode; /// Represents the data necessary to render a page. @@ -53,13 +56,15 @@ async fn render_build_state( /// Renders a template that generated its state at request-time. Note that revalidation and ISR have no impact on SSR-rendered pages. async fn render_request_state( template: &Template, + translator: Rc, path: &str, req: Request, ) -> Result<(String, Option)> { // Generate the initial state (this may generate an error, but there's no file that can't exist) let state = Some(template.get_request_state(path.to_string(), req).await?); // Use that to render the static HTML - let html = sycamore::render_to_string(|| template.render_for_template(state.clone())); + let html = + sycamore::render_to_string(|| template.render_for_template(state.clone(), translator)); Ok((html, state)) } @@ -111,6 +116,7 @@ async fn should_revalidate( /// Revalidates a template async fn revalidate( template: &Template, + translator: Rc, path: &str, path_encoded: &str, config_manager: &impl ConfigManager, @@ -121,7 +127,8 @@ async fn revalidate( .get_build_state(format!("{}/{}", template.get_path(), path)) .await?, ); - let html = sycamore::render_to_string(|| template.render_for_template(state.clone())); + let html = + sycamore::render_to_string(|| template.render_for_template(state.clone(), translator)); // Handle revalidation, we need to parse any given time strings into datetimes // We don't need to worry about revalidation that operates by logic, that's request-time only if template.revalidates_with_time() { @@ -153,13 +160,21 @@ async fn revalidate( // TODO possible further optimizations on this for futures? pub async fn get_page( path: &str, + locale: &str, req: Request, render_cfg: &HashMap, templates: &TemplateMap, config_manager: &impl ConfigManager, + translations_manager: &impl TranslationsManager, ) -> Result { - // Remove `/` from the path by encoding it as a URL (that's what we store) - let path_encoded = urlencoding::encode(path).to_string(); + // Get a translator for this locale (for sanity we hope the manager is caching) + let translator = Rc::new( + translations_manager + .get_translator_for_locale(locale.to_string()) + .await?, + ); + // Remove `/` from the path by encoding it as a URL (that's what we store) and add the locale + let path_encoded = format!("{}-{}", locale, urlencoding::encode(path).to_string()); // Match the path to one of the templates let mut template_name = String::new(); @@ -213,8 +228,14 @@ pub async fn get_page( Some(html_val) => { // Check if we need to revalidate if should_revalidate(template, &path_encoded, config_manager).await? { - let (html_val, state) = - revalidate(template, path, &path_encoded, config_manager).await?; + let (html_val, state) = revalidate( + template, + Rc::clone(&translator), + path, + &path_encoded, + config_manager, + ) + .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val @@ -239,8 +260,9 @@ pub async fn get_page( None => { // We need to generate and cache this page for future usage let state = Some(template.get_build_state(path.to_string()).await?); - let html_val = - sycamore::render_to_string(|| template.render_for_template(state.clone())); + let html_val = sycamore::render_to_string(|| { + template.render_for_template(state.clone(), Rc::clone(&translator)) + }); // Handle revalidation, we need to parse any given time strings into datetimes // We don't need to worry about revalidation that operates by logic, that's request-time only // Obviously we don't need to revalidate now, we just created it @@ -278,8 +300,14 @@ pub async fn get_page( } else { // Handle if we need to revalidate if should_revalidate(template, &path_encoded, config_manager).await? { - let (html_val, state) = - revalidate(template, path, &path_encoded, config_manager).await?; + let (html_val, state) = revalidate( + template, + Rc::clone(&translator), + path, + &path_encoded, + config_manager, + ) + .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val @@ -297,7 +325,8 @@ pub async fn get_page( } // Handle request state if template.uses_request_state() { - let (html_val, state) = render_request_state(template, path, req).await?; + let (html_val, state) = + render_request_state(template, Rc::clone(&translator), path, req).await?; // Request-time HTML always overrides anything generated at build-time or incrementally (this has more information) html = html_val; states.request_state = state; diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 9274635e8e..93712d1beb 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -1,7 +1,10 @@ use crate::errors::*; use crate::serve::PageData; use crate::template::TemplateFn; +use crate::ClientTranslationsManager; +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use sycamore::prelude::Template as SycamoreTemplate; use sycamore::prelude::*; use wasm_bindgen::prelude::*; @@ -109,6 +112,9 @@ impl ErrorPages { pub fn app_shell( path: String, template_fn: TemplateFn, + // translator: Rc, + locale: String, + translations_manager: Rc>, error_pages: ErrorPages, ) -> Template { // Get the container as a DOM element @@ -116,7 +122,7 @@ pub fn app_shell( // Spawn a Rust futures thread in the background to fetch the static HTML/JSON wasm_bindgen_futures::spawn_local(cloned!((container) => async move { // Get the static page data - let asset_url = format!("/.perseus/page/{}", path.to_string()); + let asset_url = format!("/.perseus/page/{}/{}", locale, path.to_string()); // If this doesn't exist, then it's a 404 (we went here by explicit navigation, but it may be an unservable ISR page or the like) let page_data_str = fetch(&asset_url).await; match page_data_str { @@ -131,10 +137,26 @@ pub fn app_shell( let container_elem = container.get::().unchecked_into::(); container_elem.set_inner_html(&page_data.content); + // Now that the user can see something, we can get the translator + let mut translations_manager_mut = translations_manager.borrow_mut(); + // This gets an `Rc` that references the translations manager, meaning no cloning of translations + let translator = translations_manager_mut.get_translator_for_locale(&locale).await; + let translator = match translator { + Ok(translator) => translator, + Err(err) => match err.kind() { + // 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), + // No other errors should be returned + _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") + } + }; + // Hydrate that static code using the acquired state // BUG (Sycamore): this will double-render if the component is just text (no nodes) sycamore::hydrate_to( - || template_fn(page_data.state), + || template_fn(page_data.state, Rc::clone(&translator)), &container.get::().inner_element() ); }, @@ -154,11 +176,6 @@ pub fn app_shell( // This is where the static content will be rendered // BUG: white flash of death until Sycamore can suspend the router until the static content is ready - // PageToRender::Success( - // template! { - // div(ref = container) - // } - // ) template! { div(ref = container) } diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index a004e70974..9449494823 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -2,9 +2,11 @@ use crate::errors::*; use crate::Request; +use crate::Translator; use futures::Future; use std::collections::HashMap; use std::pin::Pin; +use std::rc::Rc; use std::sync::Arc; use sycamore::prelude::{GenericNode, Template as SycamoreTemplate}; @@ -109,8 +111,8 @@ make_async_trait!(ShouldRevalidateFnType, StringResultWithCause); // A series of closure types that should not be typed out more than once /// The type of functions that are given a state and render a page. If you've defined state for your page, it's safe to `.unwrap()` the -/// given `Option`. -pub type TemplateFn = Arc) -> SycamoreTemplate>; +/// given `Option`. If you're using i18n, this will also be given an `Rc` (all templates share ownership of the translations). +pub type TemplateFn = Arc, Rc) -> SycamoreTemplate>; /// The type of functions that get build paths. pub type GetBuildPathsFn = Arc; /// The type of functions that get build state. @@ -168,7 +170,7 @@ impl Template { pub fn new(path: impl Into + std::fmt::Display) -> Self { Self { path: path.to_string(), - template: Arc::new(|_: Option| sycamore::template! {}), + template: Arc::new(|_: Option, _: Rc| sycamore::template! {}), get_build_paths: None, incremental_path_rendering: false, get_build_state: None, @@ -181,8 +183,12 @@ impl Template { // Render executors /// Executes the user-given function that renders the template on the server-side (build or request time). - pub fn render_for_template(&self, props: Option) -> SycamoreTemplate { - (self.template)(props) + pub fn render_for_template( + &self, + props: Option, + translator: Rc, + ) -> SycamoreTemplate { + (self.template)(props, translator) } /// Gets the list of templates that should be prerendered for at build-time. pub async fn get_build_paths(&self) -> Result> { diff --git a/packages/perseus/src/translations_manager.rs b/packages/perseus/src/translations_manager.rs new file mode 100644 index 0000000000..e5ccf7a97d --- /dev/null +++ b/packages/perseus/src/translations_manager.rs @@ -0,0 +1,73 @@ +// This file contains the logic for a universal interface to fecth `Translator` instances for given locales +// At simplest, this is just a filesystem interface, but it might be something like a database in production +// This has its own error management logic because the user may implement it separately + +use crate::Translator; +use error_chain::{bail, error_chain}; +use std::collections::HashMap; +use std::fs; + +// This has no foreign links because everything to do with config management should be isolated and generic +error_chain! { + errors { + /// For when the locale wasn't found. Locales will be checked for existence before fetching is attempted, so this indicates + /// a bug in the storage system. + NotFound(locale: String) { + description("translations not found") + display("translations for locale '{}' not found", locale) + } + /// For when translations couldn't be read for some generic reason. + ReadFailed(locale: String, err: String) { + description("translations couldn't be read") + display("translations for locale '{}' couldn't be read, error was '{}'", locale, err) + } + /// For when serializing into the `Translator` data structure failed. + SerializationFailed(locale: String, err: String) { + description("translations couldn't be serialized into translator") + display("translations for locale '{}' couldn't be serialized into translator, error was '{}'", locale, err) + } + } +} + +/// A trait for systems that manage where to put configuration files. At simplest, we'll just write them to static files, but they're +/// more likely to be stored on a CMS. +#[async_trait::async_trait] +pub trait TranslationsManager: Clone { + /// Gets translations for the given locale. + async fn get_translator_for_locale(&self, locale: String) -> Result; +} + +/// The default translations manager. This will store static files in the specified location on disk. This should be suitable for +/// nearly all development and serverful use-cases. Serverless is another matter though (more development needs to be done). This +/// mandates that translations be stored as JSON files named as the locale they describe (e.g. 'en-US.json'). +#[derive(Clone)] +pub struct FsTranslationsManager { + root_path: String, +} +impl FsTranslationsManager { + /// Creates a new filesystem translations manager. You should provide a path like `/translations` here. + pub fn new(root_path: String) -> Self { + Self { root_path } + } +} +#[async_trait::async_trait] +impl TranslationsManager for FsTranslationsManager { + async fn get_translator_for_locale(&self, locale: String) -> Result { + // The file must be named as the locale it describes + let asset_path = format!("{}/{}.json", self.root_path, locale); + let translations_str = match fs::metadata(&asset_path) { + Ok(_) => fs::read_to_string(&asset_path) + .map_err(|err| ErrorKind::ReadFailed(asset_path, err.to_string()))?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!(ErrorKind::NotFound(asset_path)) + } + Err(err) => bail!(ErrorKind::ReadFailed(locale.to_string(), err.to_string())), + }; + // We expect the translations defined there, but not the locale itself + let translations = serde_json::from_str::>(&translations_str) + .map_err(|err| ErrorKind::SerializationFailed(locale.to_string(), err.to_string()))?; + let translator = Translator::new(locale, translations); + + Ok(translator) + } +} diff --git a/packages/perseus/src/translator.rs b/packages/perseus/src/translator.rs new file mode 100644 index 0000000000..af6fee4c44 --- /dev/null +++ b/packages/perseus/src/translator.rs @@ -0,0 +1,97 @@ +use crate::errors::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Defines app information about i18n, specifically about which locales are supported. +#[derive(Clone)] +pub struct Locales { + /// The default locale, which will be used as a fallback if the user's locale can't be extracted. This will be built for at build-time. + pub default: String, + /// Common locales that should be built for at build-time. + pub common: Vec, + /// All other supported locales, which won't be built unless requested at request-time. + pub other: Vec, +} +impl Locales { + /// Gets all the supported locales by combining the default, common, and other. + pub fn get_all(&self) -> Vec<&String> { + let mut vec: Vec<&String> = vec![&self.default]; + vec.extend(&self.common); + vec.extend(&self.other); + + vec + } + /// Gets the locales that should be built at build time, the default and common. + pub fn get_default_and_common(&self) -> Vec<&String> { + let mut vec: Vec<&String> = vec![&self.default]; + vec.extend(&self.common); + + vec + } + /// Checks if the given locale is supported. + pub fn is_supported(&self, locale: &str) -> bool { + let locales = self.get_all(); + locales.iter().any(|l| *l == locale) + } +} + +/// Manages translations on the client-side for a single locale. This should generally be placed into an `Rc` and referred to by +/// every template in an app. You do NOT want to be cloning potentially thousands of translations! +#[derive(Serialize, Deserialize)] +pub struct Translator { + /// Stores a map of translation IDs to actual translations for the current locale. + translations: HashMap, + /// The locale for which translations are being managed by this instance. + pub locale: String, +} +// TODO support variables, plurals, etc. +impl Translator { + /// Creates a new instance of the translator for the given locale. + pub fn new(locale: String, translations: HashMap) -> Self { + Self { + translations, + locale, + } + } + /// Creates an empty translator that doesn't do anything. This is instantiated on the server in particular. This should NEVER be + /// used on the client-side! This won't allocate translations or a locale. + pub fn empty() -> Self { + Self { + translations: HashMap::new(), + locale: String::new(), + } + } + /// Gets the translation of the given ID for the current locale. + /// # Panics + /// This will panic if the translation ID is not found. If you need to translate an arbitrary ID, you should use `.translate_checked()` + /// instead. + pub fn translate + std::fmt::Display>(&self, id: I) -> String { + let translation_res = self.translate_checked(&id.to_string()); + 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) + } + } + /// Gets the translation of the given ID for the current locale. This will return an error gracefully if the ID doesn't exist. If + /// you're hardcoding a translation ID in though, you should use `.translate()` instead, which will panic if the ID doesn't exist. + pub fn translate_checked + std::fmt::Display>(&self, id: I) -> Result { + let id_str = id.to_string(); + let translation = self.translations.get(&id_str); + match translation { + Some(translation) => Ok(translation.to_string()), + None => bail!(ErrorKind::TranslationIdNotFound( + id_str, + self.locale.clone() + )), + } + } +} + +/// A super-shortcut for translating stuff. Your translator must be named `translator` for this to work. +// FIXME +#[macro_export] +macro_rules! t { + ($id:literal, $translator:expr) => { + $translator.translate($id) + }; +}