Skip to content

Commit 8e598f2

Browse files
jasonyessstadust
andauthored
Localization Support (#232)
Add support for localizing pointercrate. Pointercrate now loads all strings displayed to the user on the website from .ftl static files, which are registered with the runtime before rocket launches. Supported languages are dynamically determined by searching the /static/ftl directories registered for sub-directories named after the ISO codes of languages in the format `language-region`, e.g. `en-US` for American English, `de-de` for German as spoken in Germany, etc. If at least 2 languages are found, the nav bar gains a new dropdown to select the language. The currently selected language is stored in a task local variable, so that we don't have to pass this information down into every page-rendering function. The localization resources are stored in a global OnceLock that is initialized by `LocalesLoader::commit()`. Localization works by looking up text_ids. If a specific language doesn't define a text id, then it falls back onto a default language. If the default language also does not specify a given text_id, then just the text_id itself is returned, and an internal server error is logged. The frontend can also localize (localization.js defines javascript versions of the tr and trp functions that the backend uses), although for now it does not support the fallback onto the default language. Instead it immediately falls back to just printing the text_id. Implementation by github.com/jasonyess, code only reorganized slightly by github.com/stadust Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> Co-authored-by: stadust <43299462+stadust@users.noreply.github.com>
1 parent 0631370 commit 8e598f2

File tree

110 files changed

+3999
-889
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+3999
-889
lines changed

Cargo.lock

Lines changed: 270 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ members = [
33
"pointercrate-core",
44
"pointercrate-core-api",
55
"pointercrate-core-pages",
6+
"pointercrate-core-macros",
67
"pointercrate-demonlist",
78
"pointercrate-demonlist-api",
89
"pointercrate-demonlist-pages",

pointercrate-core-api/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio-
1616
log = "0.4.27"
1717
serde_urlencoded = "0.7.0"
1818
maud = "0.27.0"
19+
unic-langid = "0.9.5"
20+
tokio = "1.46.1"

pointercrate-core-api/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
pub mod error;
22
pub mod etag;
3+
pub mod localization;
34
pub mod maintenance;
45
pub mod pagination;
6+
pub mod preferences;
57
pub mod query;
68
pub mod response;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
use crate::preferences::{ClientPreferences, PreferenceManager};
2+
use crate::{tryo_result, tryo_state};
3+
use pointercrate_core::error::CoreError;
4+
use pointercrate_core::localization::LocaleConfiguration;
5+
use rocket::{
6+
request::{FromRequest, Outcome},
7+
Request,
8+
};
9+
use unic_langid::subtags::Language;
10+
11+
pub const LOCALE_COOKIE_NAME: &str = "locale";
12+
13+
pub struct ClientLocale(pub Language);
14+
15+
#[rocket::async_trait]
16+
impl<'r> FromRequest<'r> for ClientLocale {
17+
type Error = CoreError;
18+
19+
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
20+
let preference_manager = tryo_state!(request, PreferenceManager);
21+
let preferences = ClientPreferences::from_cookies(request.cookies(), preference_manager);
22+
let language = tryo_result!(preferences
23+
.get(LOCALE_COOKIE_NAME)
24+
.ok_or_else(|| CoreError::internal_server_error("locale set not registered with preference manager")));
25+
let lang_id = LocaleConfiguration::get().by_code(language);
26+
27+
Outcome::Success(ClientLocale(lang_id.language))
28+
}
29+
}

pointercrate-core-api/src/maintenance.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Module providing a "maintenance mode" fairing (middleware)
22
3-
use crate::error::Result;
4-
use pointercrate_core::error::CoreError;
3+
use crate::{error::Result, localization::ClientLocale};
4+
use pointercrate_core::{error::CoreError, localization::LANGUAGE};
55
use rocket::{
66
fairing::{Fairing, Info, Kind},
77
http::Method,
@@ -49,7 +49,9 @@ impl Fairing for MaintenanceFairing {
4949
}
5050
}
5151

52+
// we can't use the #[localized] proc-macro here due to issues related to imports
53+
// (https://github.com/stadust/pointercrate/pull/232#discussion_r2118293806)
5254
#[rocket::get("/maintenance")]
53-
async fn maintenance() -> Result<()> {
54-
Err(CoreError::ReadOnlyMaintenance.into())
55+
async fn maintenance(locale: ClientLocale) -> Result<()> {
56+
LANGUAGE.scope(locale.0, async { Err(CoreError::ReadOnlyMaintenance.into()) }).await
5557
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
use std::collections::HashMap;
2+
3+
use crate::localization::LOCALE_COOKIE_NAME;
4+
use pointercrate_core::error::CoreError;
5+
use pointercrate_core::localization::LocaleConfiguration;
6+
use rocket::{
7+
http::CookieJar,
8+
request::{FromRequest, Outcome},
9+
Request,
10+
};
11+
12+
/// A request guard which stores the preferences sent from the client.
13+
pub struct ClientPreferences<'k, 'v>(HashMap<&'k str, &'v str>);
14+
15+
impl<'k: 'v, 'v> ClientPreferences<'k, 'v> {
16+
/// Retrieve a particular preference which was sent to us from the client.
17+
///
18+
/// `T` must implement `From<ClientPreference>`, which [`String`] already
19+
/// implements, in case the untouched cookie value is what needs to be handled.
20+
pub fn get(&self, name: &'k str) -> Option<&'v str> {
21+
self.0.get(name).map(|&s| s)
22+
}
23+
24+
pub fn from_cookies(cookies: &'v CookieJar<'v>, preference_manager: &'k PreferenceManager) -> Self {
25+
ClientPreferences(
26+
preference_manager
27+
.0
28+
.iter()
29+
.map(|(name, default)| {
30+
(
31+
name.as_ref(),
32+
cookies
33+
.get(&format!("preference-{}", name))
34+
.map(|cookie| cookie.value())
35+
.unwrap_or(default)
36+
.as_ref(),
37+
)
38+
})
39+
.collect(),
40+
)
41+
}
42+
}
43+
44+
#[rocket::async_trait]
45+
impl<'r> FromRequest<'r> for ClientPreferences<'r, 'r> {
46+
type Error = CoreError;
47+
48+
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
49+
let preference_manager = match request.rocket().state::<PreferenceManager>() {
50+
Some(preference_manager) => preference_manager,
51+
_ => return Outcome::Success(ClientPreferences(HashMap::new())), // return an empty preferences hashmap if this instance doesnt support preferences
52+
};
53+
54+
let preferences = ClientPreferences::from_cookies(request.cookies(), preference_manager);
55+
56+
Outcome::Success(preferences)
57+
}
58+
}
59+
60+
/// A configuration state to manage all of your pointercrate instance's
61+
/// client preferences.
62+
#[derive(Default)]
63+
pub struct PreferenceManager(HashMap<String, String>);
64+
65+
impl PreferenceManager {
66+
/// Append a new preference to this [`PreferenceManager`]. `name` represents
67+
/// the name of the cookie which stores the value of this preference.
68+
///
69+
/// Note that the cookie name is prefixed with `"preference-"`, so creating a
70+
/// preference with the `name` value as `"theme"` would result in the cookie
71+
/// sent from the client being named `"preference-theme"`.
72+
///
73+
/// If the cookie was not received, its value will default to `default`.
74+
pub fn preference(mut self, name: impl Into<String>, default: impl Into<String>) -> Self {
75+
self.0.insert(name.into(), default.into());
76+
77+
self
78+
}
79+
80+
/// Automatically register the preferences needed to store active locales.
81+
///
82+
/// Requires the global localization context to have been set up via [`LocalesLoader::commit`],
83+
/// otherwise will panic.
84+
pub fn with_localization(self) -> Self {
85+
self.preference(LOCALE_COOKIE_NAME, LocaleConfiguration::get().fallback.as_str())
86+
}
87+
}

pointercrate-core-api/src/response.rs

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
use crate::etag::Tagged;
2-
use maud::{html, DOCTYPE};
3-
use pointercrate_core::etag::Taggable;
1+
use crate::localization::LOCALE_COOKIE_NAME;
2+
use crate::{
3+
etag::Tagged,
4+
preferences::{ClientPreferences, PreferenceManager},
5+
};
6+
use maud::{html, Render, DOCTYPE};
7+
use pointercrate_core::localization::LocaleConfiguration;
8+
use pointercrate_core::{etag::Taggable, localization::LANGUAGE};
49
use pointercrate_core_pages::{
510
head::{Head, HeadLike},
611
PageConfiguration, PageFragment,
712
};
13+
use rocket::tokio::runtime::Handle;
14+
use rocket::tokio::task::block_in_place;
815
use rocket::{
916
http::{ContentType, Header, Status},
1017
response::Responder,
@@ -30,24 +37,46 @@ impl HeadLike for Page {
3037

3138
impl<'r, 'o: 'r> Responder<'r, 'o> for Page {
3239
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'o> {
33-
let page_config = request.rocket().state::<PageConfiguration>().ok_or(Status::InternalServerError)?;
40+
let preference_manager = request.rocket().state::<PreferenceManager>().ok_or(Status::InternalServerError)?;
41+
let preferences = ClientPreferences::from_cookies(request.cookies(), preference_manager);
42+
43+
let language = preferences.get(LOCALE_COOKIE_NAME).ok_or(Status::InternalServerError)?;
44+
let lang_id = LocaleConfiguration::get().by_code(language);
45+
46+
let (page_config, nav_bar, footer) = block_in_place(move || {
47+
Handle::current().block_on(async {
48+
LANGUAGE
49+
.scope(lang_id.language, async {
50+
let page_config = request
51+
.rocket()
52+
.state::<fn() -> PageConfiguration>()
53+
.ok_or(Status::InternalServerError)?();
54+
55+
let nav_bar = page_config.nav_bar.render();
56+
let footer = page_config.footer.render();
57+
58+
Ok((page_config, nav_bar, footer))
59+
})
60+
.await
61+
})
62+
})?;
3463

3564
let fragment = self.0;
3665

3766
let rendered_fragment = html! {
3867
(DOCTYPE)
39-
html lang="en" prefix="og: http://opg.me/ns#" {
68+
html lang=(lang_id) prefix="og: http://opg.me/ns#" {
4069
head {
4170
(page_config.head)
4271
(fragment.head)
4372
}
4473
body {
4574
div.content {
46-
(page_config.nav_bar)
75+
(nav_bar)
4776
(fragment.body)
4877
div #bg {}
4978
}
50-
(page_config.footer)
79+
(footer)
5180
}
5281
}
5382
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "pointercrate-core-macros"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
description.workspace = true
6+
homepage.workspace = true
7+
edition.workspace = true
8+
repository.workspace = true
9+
10+
[lib]
11+
proc-macro = true
12+
13+
[dependencies]
14+
syn = { version = "2.0.101", features = ["full"] }
15+
quote = "1.0.40"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use proc_macro::TokenStream;
2+
use quote::quote;
3+
use syn::{parse2, parse_macro_input, parse_quote, ItemFn};
4+
5+
/// A procedural macro for automatically wrapping a request handler inside a tokio::task_local!
6+
/// [`LocalKey`] scope for `LANGUAGE`, with the value of the [`ClientLocale`] request guard.
7+
///
8+
/// Use of this macro eliminates the need for writing and maintaining boilerplate code caused
9+
/// by manually wrapping the request handler body inside a `LANGUAGE` scope, while also
10+
/// having to take in a [`ClientLocale`] guard and handling that properly.
11+
///
12+
/// This macro should be used for any endpoint whose request handler calls a translation
13+
/// function at some point, so basically any page or API endpoint (API endpoints need
14+
/// to be localized because errors are also translated)
15+
#[proc_macro_attribute]
16+
pub fn localized(_: TokenStream, input: TokenStream) -> TokenStream {
17+
let mut f = parse_macro_input!(input as ItemFn);
18+
19+
// modify the request handler to automatically take in our [`ClientLocale`] request
20+
// guard (defined in pointercrate-core-api/src/localization.rs)
21+
f.sig
22+
.inputs
23+
.push(parse_quote! { __locale: pointercrate_core_api::localization::ClientLocale });
24+
25+
let block = &f.block;
26+
let block = quote! {
27+
{
28+
pointercrate_core::localization::LANGUAGE.scope(__locale.0, async {
29+
#block
30+
}).await
31+
}
32+
};
33+
34+
f.block = parse2(block).unwrap();
35+
36+
TokenStream::from(quote!(#f))
37+
}
38+
39+
/// Identical behaviour to `#[localized]`, but modified to support error catchers.
40+
#[proc_macro_attribute]
41+
pub fn localized_catcher(_: TokenStream, input: TokenStream) -> TokenStream {
42+
let mut f = parse_macro_input!(input as ItemFn);
43+
44+
f.sig.inputs.push(parse_quote! { __request: &rocket::Request<'_> });
45+
46+
let block = &f.block;
47+
let block = quote! {
48+
{
49+
use rocket::request::FromRequest;
50+
51+
let __locale = match pointercrate_core_api::localization::ClientLocale::from_request(__request).await {
52+
rocket::request::Outcome::Success(locale) => locale,
53+
_ => return pointercrate_core_api::error::ErrorResponder::from(pointercrate_core::error::CoreError::internal_server_error("An error occurred while trying to extract requested locale. Check your locale fallbacks!")),
54+
};
55+
56+
pointercrate_core::localization::LANGUAGE.scope(__locale.0, async {
57+
#block
58+
}).await
59+
}
60+
};
61+
62+
f.block = parse2(block).unwrap();
63+
64+
TokenStream::from(quote!(#f))
65+
}

0 commit comments

Comments
 (0)