Skip to content

Commit 3260835

Browse files
committed
Localization Support
Add support for localizing pointercrate Signed-off-by: stadust <[email protected]>
1 parent 154b2aa commit 3260835

File tree

110 files changed

+3995
-890
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

+3995
-890
lines changed

Cargo.lock

Lines changed: 269 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ 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"

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: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +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
};
813
use rocket::{
14+
futures,
915
http::{ContentType, Header, Status},
1016
response::Responder,
1117
serde::json::Json,
@@ -30,24 +36,44 @@ impl HeadLike for Page {
3036

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

3561
let fragment = self.0;
3662

3763
let rendered_fragment = html! {
3864
(DOCTYPE)
39-
html lang="en" prefix="og: http://opg.me/ns#" {
65+
html lang=(lang_id) prefix="og: http://opg.me/ns#" {
4066
head {
4167
(page_config.head)
4268
(fragment.head)
4369
}
4470
body {
4571
div.content {
46-
(page_config.nav_bar)
72+
(nav_bar)
4773
(fragment.body)
4874
div #bg {}
4975
}
50-
(page_config.footer)
76+
(footer)
5177
}
5278
}
5379
}
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)