Skip to content

Commit 1e3206c

Browse files
committed
feat(i18n): added support for auto-setting the lang attribute
This is fully tested now. Closes #261.
1 parent 53e224b commit 1e3206c

File tree

8 files changed

+83
-14
lines changed

8 files changed

+83
-14
lines changed

examples/core/i18n/src/templates/about.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use sycamore::prelude::*;
44
fn about_page<G: Html>(cx: Scope) -> View<G> {
55
view! { cx,
66
p { (t!(cx, "about")) }
7-
button(on:click = move |_| {
7+
button(id = "switch-button", on:click = move |_| {
88
#[cfg(client)]
99
Reactor::<G>::from_cx(cx).switch_locale("fr-FR");
1010
}) { "Switch to French" }

examples/core/i18n/tests/main.rs

+16-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
1010
// languages is very hard, we do unit testing on the locale detection system
1111
// instead
1212
assert!(url.as_ref().starts_with("http://localhost:8080/en-US"));
13+
// Make sure the HTML `lang` attribute has been correctly set
14+
let lang = c.find(Locator::Css("html")).await?.attr("lang").await?;
15+
assert_eq!(lang, Some("en-US".to_string()));
1316
// This tests translations, variable interpolation, and multiple aspects of
1417
// Sycamore all at once
1518
let text = c.find(Locator::Css("p")).await?.text().await?;
@@ -18,10 +21,22 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> {
1821
c.find(Locator::Css("a")).await?.click().await?;
1922
// This tests i18n linking (locale should be auto-detected)
2023
let url = c.current_url().await?;
21-
wait_for_checkpoint!("page_interactive", 0, c);
24+
wait_for_checkpoint!("page_interactive", 1, c);
2225
assert!(url
2326
.as_ref()
2427
.starts_with("http://localhost:8080/en-US/about"));
2528

29+
// Switch the locale
30+
c.find(Locator::Id("switch-button")).await?.click().await?;
31+
let url = c.current_url().await?;
32+
wait_for_checkpoint!("page_interactive", 2, c);
33+
assert!(url
34+
.as_ref()
35+
.starts_with("http://localhost:8080/fr-FR/about"));
36+
37+
// Make sure the HTML `lang` attribute has been correctly set
38+
let lang = c.find(Locator::Css("html")).await?.attr("lang").await?;
39+
assert_eq!(lang, Some("fr-FR".to_string()));
40+
2641
Ok(())
2742
}

packages/perseus/src/i18n/client_translations_manager.rs

+23-1
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ impl ClientTranslationsManager {
106106
}
107107
};
108108
self.cache_translator(translator);
109+
// Only set `lang` if everything else worked (otherwise we'd end up with state
110+
// inconsistencies)
111+
Self::set_html_lang(locale);
109112
}
110113

111114
Ok(())
@@ -115,6 +118,9 @@ impl ClientTranslationsManager {
115118
/// the translations from the server. This manages mutability for caching
116119
/// internally.
117120
///
121+
/// This will imperatively update the `lang` attribute on the root `<html>`
122+
/// element.
123+
///
118124
/// # Panics
119125
///
120126
/// This will panic if the given locale is not supported.
@@ -132,7 +138,10 @@ impl ClientTranslationsManager {
132138
match translations_str {
133139
Some(translations_str) => {
134140
// All good, turn the translations into a translator
135-
self.set_translator_for_translations_str(locale, &translations_str)?
141+
self.set_translator_for_translations_str(locale, &translations_str)?;
142+
// Only set `lang` if everything else worked (otherwise we'd end up with state
143+
// inconsistencies)
144+
Self::set_html_lang(locale);
136145
}
137146
// If we get a 404 for a supported locale, that's an exception
138147
None => {
@@ -146,4 +155,17 @@ impl ClientTranslationsManager {
146155

147156
Ok(())
148157
}
158+
159+
/// Sets the `lang` attribute on the root `<html>` tag to stay up-to-date
160+
/// with the internal state of this system.
161+
fn set_html_lang(locale: &str) {
162+
let document = web_sys::window().unwrap().document().unwrap();
163+
// If the `<html>` tag does not exist, and Perseus is running, this would
164+
// be...interesting...
165+
let html = document.document_element().unwrap();
166+
167+
// This is a non-critical operation, so errors are not propagated here
168+
// TODO In future, this should integrate with the `PlatformError` system
169+
let _ = html.set_attribute("lang", locale);
170+
}
149171
}

packages/perseus/src/reactor/initial_load.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ impl<G: Html> Reactor<G> {
6262
let slim_verdict = verdict.clone();
6363
match &verdict.into_full(&self.entities) {
6464
// WARNING: This will be triggered on *all* incremental paths, even if
65-
// the serber returns a 404!
65+
// the server returns a 404!
6666
FullRouteVerdict::Found(FullRouteInfo {
6767
path,
6868
entity,

packages/perseus/src/server/html_shell.rs

+34-2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ pub(crate) struct HtmlShell {
4242
/// Code to be inserted into the shell after the Perseus contents of the
4343
/// page. This is designed to be modified by plugins.
4444
pub after_content: Vec<String>,
45+
/// The locale to be used for this page, which will be set in the `lang`
46+
/// attribute on `<html>`.
47+
///
48+
/// **IMPORTANT:** This *does* use `xx-XX` for denoting the locale of an
49+
/// app not using i18n, but, if this value is set, no interpolation will
50+
/// be performed. For apps that do use i18n, manually setting the `lang`
51+
/// attribute in teh index view may lead to unpredictable errors (as
52+
/// interpolation will nonetheless be performed, leading to conflicts).
53+
///
54+
/// Note that `xx-XX` will be automatically set for locale redirection
55+
/// pages, preventing interpolation.
56+
pub locale: String,
4557
/// The ID of the element into which we'll interpolate content.
4658
root_id: String,
4759
/// The path prefix to use.
@@ -178,6 +190,8 @@ impl HtmlShell {
178190
content: "".into(),
179191
root_id: root_id.into(),
180192
path_prefix: path_prefix.into(),
193+
// Assume until we know otherwise
194+
locale: "xx-XX".to_string(),
181195
}
182196
}
183197

@@ -187,12 +201,17 @@ impl HtmlShell {
187201
/// translator can be derived on the client-side. These are provided in
188202
/// a window variable to avoid page interactivity requiring a network
189203
/// request to get them.
204+
///
205+
/// This needs to know what the locale is so it can set the HTML `lang`
206+
/// attribute for improved SEO.
190207
pub(crate) fn page_data(
191208
mut self,
192209
page_data: &PageData,
193210
global_state: &TemplateState,
211+
locale: &str,
194212
translations: &str,
195213
) -> Self {
214+
self.locale = locale.to_string();
196215
// Interpolate a global variable of the state so the app shell doesn't have to
197216
// make any more trips The app shell will unset this after usage so it
198217
// doesn't contaminate later non-initial loads Error pages (above) will
@@ -249,6 +268,7 @@ impl HtmlShell {
249268
/// Further, this will preload the Wasm binary, making redirection snappier
250269
/// (but initial load slower), a tradeoff that generally improves UX.
251270
pub(crate) fn locale_redirection_fallback(mut self, redirect_url: &str) -> Self {
271+
self.locale = "xx-XX".to_string();
252272
// This will be used if JavaScript is completely disabled (it's then the site's
253273
// responsibility to show a further message)
254274
let dumb_redirect = format!(
@@ -316,8 +336,13 @@ impl HtmlShell {
316336
error_page_data: &ServerErrorData,
317337
error_html: &str,
318338
error_head: &str,
339+
locale: Option<String>, // For internal convenience
319340
translations_str: Option<&str>,
320341
) -> Self {
342+
if let Some(locale) = locale {
343+
self.locale = locale.to_string();
344+
}
345+
321346
let error = serde_json::to_string(error_page_data).unwrap();
322347
let state_var = format!(
323348
"window.__PERSEUS_INITIAL_STATE = `error-{}`;",
@@ -394,12 +419,19 @@ impl fmt::Display for HtmlShell {
394419
.replace(&html_to_replace_double, &html_replacement)
395420
.replace(&html_to_replace_single, &html_replacement);
396421

422+
// Finally, set the `lang` tag if we should
423+
let final_shell = if self.locale != "xx-XX" {
424+
new_shell.replace("<html", &format!(r#"<html lang="{}""#, self.locale))
425+
} else {
426+
new_shell
427+
};
428+
397429
// And minify everything
398430
// Because this is run on live requests, we have to be fault-tolerant (if we
399431
// can't minify, we'll fall back to unminified)
400-
let minified = match minify(&new_shell, true) {
432+
let minified = match minify(&final_shell, true) {
401433
Ok(minified) => minified,
402-
Err(_) => new_shell,
434+
Err(_) => final_shell,
403435
};
404436

405437
f.write_str(&minified)

packages/perseus/src/turbine/build_error_page.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> {
3030
// Translator and translations string
3131
i18n_data: Option<(&Translator, &str)>,
3232
) -> String {
33-
let (translator, translations_str) = if let Some((t, s)) = i18n_data {
34-
(Some(t), Some(s))
33+
let (translator, translations_str, locale) = if let Some((t, s)) = i18n_data {
34+
(Some(t), Some(s), Some(t.get_locale()))
3535
} else {
36-
(None, None)
36+
(None, None, None)
3737
};
3838

3939
let (head, body) = self.error_views.render_to_string(data.clone(), translator);
@@ -43,7 +43,7 @@ impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> {
4343
.unwrap()
4444
.clone()
4545
// This will inject the translations string if it's available
46-
.error_page(&data, &body, &head, translations_str)
46+
.error_page(&data, &body, &head, locale, translations_str)
4747
.to_string()
4848
}
4949
}

packages/perseus/src/turbine/export.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> {
180180
// not using i18n
181181
let full_html = html_shell
182182
.clone()
183-
.page_data(&page_data, global_state, &translations)
183+
.page_data(&page_data, global_state, locale, &translations)
184184
.to_string();
185185
self.immutable_store
186186
.write(
@@ -224,7 +224,7 @@ impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> {
224224
// not using i18n
225225
let full_html = html_shell
226226
.clone()
227-
.page_data(&page_data, global_state, "")
227+
.page_data(&page_data, global_state, "xx-XX", "")
228228
.to_string();
229229
// We don't add an extension because this will be queried directly by the
230230
// browser

packages/perseus/src/turbine/server.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> {
223223
// We can use those to get a translator efficiently
224224
let translator = match self
225225
.translations_manager
226-
.get_translator_for_translations_str(locale, translations_str.clone())
226+
.get_translator_for_translations_str(locale.clone(), translations_str.clone())
227227
.await
228228
{
229229
Ok(translator) => translator,
@@ -257,7 +257,7 @@ impl<M: MutableStore, T: TranslationsManager> Turbine<M, T> {
257257
.as_ref()
258258
.unwrap()
259259
.clone()
260-
.page_data(&page_data, &global_state, &translations_str)
260+
.page_data(&page_data, &global_state, &locale, &translations_str)
261261
.to_string();
262262
// NOTE: Yes, the user can fully override the content type...I have yet to find
263263
// a good use for this given the need to generate a `View`

0 commit comments

Comments
 (0)