diff --git a/src/app.rs b/src/app.rs
index 14e6b46..1ef756a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,5 +1,5 @@
use leptos::prelude::*;
-use leptos_meta::{provide_meta_context, HashedStylesheet, MetaTags, Title};
+use leptos_meta::{provide_meta_context, HashedStylesheet, Html, MetaTags, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
+use crate::i18n::t;
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's
@@ -68,8 +69,18 @@ pub fn App() -> impl IntoView {
// context instead of fetching their own copy.
let site = Resource::new(|| (), |_| get_site());
provide_context(site);
+ // The chrome's language, from site.yaml (default "en"); content
+ // speaks for itself. Components read this via context for t().
+ let lang = Memo::new(move |_| {
+ site.get()
+ .and_then(|r| r.ok())
+ .and_then(|s| s.lang)
+ .unwrap_or_else(|| "en".to_string())
+ });
+ provide_context(Lang(lang));
view! {
+
{move || {
site.get()
@@ -88,6 +99,15 @@ pub fn App() -> impl IntoView {
}
}
+/// The site's chrome language as a context signal - see App.
+#[derive(Clone, Copy)]
+pub struct Lang(pub Memo);
+
+/// The current chrome language, for `t()` calls inside views.
+fn lang() -> Memo {
+ expect_context::().0
+}
+
/// Every server-fn resource the pages read, created exactly once for
/// the app's lifetime and handed down via context. Wrapper structs
/// because a bare `Resource` context is claimed by whoever provides
@@ -241,7 +261,7 @@ fn QuestionView(
/>
- "This page follows from an answer you don't seem to carry yet."
+ {move || t(&lang().get(), "follows_answer")}
{label}
@@ -269,7 +289,7 @@ fn QuestionView(
{if signed_in {
- view! { "This part of the site is for organizational owners — sign in with that account to take a look."
}
+ view! { {move || t(&lang().get(), "owners_only")}
}
.into_any()
} else {
view! {
@@ -278,7 +298,7 @@ fn QuestionView(
href=format!("/auth/login?redirect={question_id}")
rel="external"
>
- "Sign in"
+ {move || t(&lang().get(), "sign_in")}
}
.into_any()
@@ -371,7 +391,7 @@ fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
.collect();
(!others.is_empty()).then(|| view! {
- "Also worth asking"
+ {move || t(&lang().get(), "also_worth_asking")}
impl IntoView {
let current_id = current_id.clone();
announcements.get().and_then(|res| res.ok()).map(|items| {
(!items.is_empty()).then(|| view! {
-
+
impl IntoView {
view! {
- "Asked by " {responsible.name.clone()} " — "
+ {move || t(&lang().get(), "asked_by")} {responsible.name.clone()} " — "
- "contact them"
+ {move || t(&lang().get(), "contact_them")}
- " if you get stuck."
+ {move || t(&lang().get(), "if_stuck")}
}
}
@@ -741,7 +761,7 @@ fn AlternativeCard(
}
.into_any(),
- Some(Ok(())) => view! { "Done."
}.into_any(),
+ Some(Ok(())) => view! { {move || t(&lang().get(), "done")}
}.into_any(),
Some(Err(e)) => view! { {e.to_string()}
}.into_any(),
}
}}
@@ -936,7 +956,7 @@ fn AlternativeCard(
.consequence
.first()
.cloned()
- .unwrap_or_else(|| "Send".to_string());
+ .unwrap_or_else(|| t(&lang().get(), "send").to_string());
view! {
@@ -1426,7 +1446,7 @@ fn ResourceFeature(
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
- let empty = empty.clone().unwrap_or_else(|| "Nothing here yet.".to_string());
+ let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get()
.map(|res| match res {
Ok(value) => {
@@ -1895,8 +1915,8 @@ fn format_ms(ms: i64) -> String {
fn NotFound() -> impl IntoView {
view! {
- "Nothing here"
- "back to the start"
+ {move || t(&lang().get(), "nothing_here")}
+ {move || t(&lang().get(), "back_to_start")}
}
}
diff --git a/src/content.rs b/src/content.rs
index 68740ef..bb7a70b 100644
--- a/src/content.rs
+++ b/src/content.rs
@@ -183,6 +183,7 @@ pub fn render_inline_markdown(text: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(text, Options::empty());
let mut in_link = 0usize;
+ let mut in_dropped_image = 0usize;
let filtered = parser.filter_map(|event| match event {
Event::Html(_) | Event::InlineHtml(_) => None,
Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None,
@@ -200,17 +201,20 @@ pub fn render_inline_markdown(text: &str) -> String {
None
}
// Images take the same gate as links: https or same-origin
- // only - no data:, no plain http.
+ // only - no data:, no plain http. Unlike a dropped link (whose
+ // label is real text worth keeping), a dropped image's alt
+ // text is a caption for something that isn't there - swallow it.
Event::Start(Tag::Image { dest_url, .. })
if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) =>
{
- in_link += 1;
+ in_dropped_image += 1;
None
}
- Event::End(TagEnd::Image) if in_link > 0 => {
- in_link -= 1;
+ Event::End(TagEnd::Image) if in_dropped_image > 0 => {
+ in_dropped_image -= 1;
None
}
+ Event::Text(_) if in_dropped_image > 0 => None,
other => Some(other),
});
let mut out = String::new();
@@ -518,6 +522,11 @@ pub struct SiteConfig {
/// intrinsic pixel height.
#[serde(default)]
pub wordmark_height: Option,
+ /// BCP 47-ish language code for the portal's own chrome strings
+ /// ("Asked by", the nav lead...) and the html lang attribute.
+ /// `None` means "en"; unknown codes fall back to English per key.
+ #[serde(default)]
+ pub lang: Option,
#[serde(default)]
pub hero: HeroConfig,
}
diff --git a/src/i18n.rs b/src/i18n.rs
new file mode 100644
index 0000000..fb01986
--- /dev/null
+++ b/src/i18n.rs
@@ -0,0 +1,97 @@
+//! Chrome-string translations, selected by `site.yaml`'s `lang`.
+//!
+//! Content carries its own language; this covers only the strings the
+//! portal itself speaks around it ("Asked by", the nav lead, the
+//! not-found page...). Unknown languages and unknown keys fall back
+//! to English, so a typo degrades to the default instead of a blank.
+
+/// Translate `key` for `lang`. `en` is the reference table; every
+/// other language falls through to it for keys it doesn't carry.
+pub fn t(lang: &str, key: &str) -> &'static str {
+ if let Some(s) = lookup(lang, key) {
+ return s;
+ }
+ lookup("en", key).unwrap_or(key_missing(key))
+}
+
+fn lookup(lang: &str, key: &str) -> Option<&'static str> {
+ Some(match (lang, key) {
+ ("en", "also_worth_asking") => "Also worth asking",
+ ("en", "asked_by") => "Asked by ",
+ ("en", "contact_them") => "contact them",
+ ("en", "if_stuck") => " if you get stuck.",
+ ("en", "send") => "Send",
+ ("en", "sign_in") => "Sign in",
+ ("en", "nothing_here_yet") => "Nothing here yet.",
+ ("en", "nothing_here") => "Nothing here",
+ ("en", "back_to_start") => "back to the start",
+ ("en", "done") => "Done.",
+ ("en", "follows_answer") => {
+ "This page follows from an answer you don't seem to carry yet."
+ }
+ ("en", "owners_only") => {
+ "This part of the site is for organizational owners — sign in with that account to take a look."
+ }
+ ("en", "announcements") => "Announcements",
+
+ ("no", "also_worth_asking") => "Også verdt å spørre",
+ ("no", "asked_by") => "Stilt av ",
+ ("no", "contact_them") => "ta kontakt",
+ ("no", "if_stuck") => " om du står fast.",
+ ("no", "send") => "Send",
+ ("no", "sign_in") => "Logg inn",
+ ("no", "nothing_here_yet") => "Ingenting her ennå.",
+ ("no", "nothing_here") => "Ingenting her",
+ ("no", "back_to_start") => "tilbake til start",
+ ("no", "done") => "Ferdig.",
+ ("no", "follows_answer") => {
+ "Denne siden følger av et svar du ikke ser ut til å bære ennå."
+ }
+ ("no", "owners_only") => {
+ "Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
+ }
+ ("no", "announcements") => "Kunngjøringer",
+
+ _ => return None,
+ })
+}
+
+/// A missing key is a programmer error; render the key itself so it
+/// is findable, never a panic in a view.
+fn key_missing(key: &str) -> &'static str {
+ // Leak is bounded: keys are a small fixed set of literals.
+ Box::leak(key.to_string().into_boxed_str())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn norwegian_covers_the_english_table() {
+ for key in [
+ "also_worth_asking",
+ "asked_by",
+ "contact_them",
+ "if_stuck",
+ "send",
+ "sign_in",
+ "nothing_here_yet",
+ "nothing_here",
+ "back_to_start",
+ "done",
+ "follows_answer",
+ "owners_only",
+ "announcements",
+ ] {
+ assert!(lookup("en", key).is_some(), "en missing {key}");
+ assert!(lookup("no", key).is_some(), "no missing {key}");
+ }
+ }
+
+ #[test]
+ fn unknown_language_falls_back_to_english() {
+ assert_eq!(t("de", "sign_in"), "Sign in");
+ assert_eq!(t("en", "sign_in"), "Sign in");
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index d54989e..0d91949 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod auth;
pub mod chain;
pub mod content;
pub mod events;
+pub mod i18n;
pub mod resource;
#[cfg(feature = "ssr")]