From d0bb1ce8f0ee7885cb4c2f1109d4484db112e3ca Mon Sep 17 00:00:00 2001 From: angela-helios Date: Mon, 6 Jul 2026 00:41:30 -0400 Subject: [PATCH] feat(ui): wire the i18n foundation into the web UI (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plug the locale structure from feat/i18n-foundation into the helios-ui crate from #186 — the runtime wiring #187 deliberately deferred: - crates/ui/src/i18n.rs: negotiate_locale middleware producing one RequestLocale per request (?lang= override -> hfs_lang cookie -> Accept-Language via RFC 4647 Lookup -> en), an explicit ?lang= choice persisted in the cookie, Vary: Accept-Language, Cookie on responses, and the I18n Fluent lookup helper templates resolve keys through. - Catalogs embedded at compile time via fluent-templates static_loader (no runtime file/CDN dependency, same stance as the vendored assets), falling back negotiated locale -> en, key echo for unknown keys. - Templates now hold catalog keys, not prose; reflects the negotiated locale; no-JS language switcher with aria-current. - New keys (home-lede, status-version, status-last-checked, action-refresh-status) seeded across en/es/de; en stays the source of truth and a test parses all three .ftl files to enforce key-set parity. - tests/i18n_http.rs drives the mounted router end to end: header negotiation, override cookie, persistence, localized htmx fragments. - docs/multi-language.md: crate name updated to helios-ui post-#188 review; point at the now-existing wiring. --- Cargo.lock | 387 ++++++++++++++++++++++- crates/ui/Cargo.toml | 14 + crates/ui/src/i18n.rs | 360 +++++++++++++++++++++ crates/ui/src/lib.rs | 52 ++- crates/ui/templates/layouts/base.html | 14 +- crates/ui/templates/pages/index.html | 6 +- crates/ui/templates/partials/status.html | 4 +- crates/ui/tests/i18n_http.rs | 123 +++++++ docs/multi-language.md | 6 +- locales/de/main.ftl | 10 + locales/en/main.ftl | 10 + locales/es/main.ftl | 10 + 12 files changed, 979 insertions(+), 17 deletions(-) create mode 100644 crates/ui/src/i18n.rs create mode 100644 crates/ui/tests/i18n_http.rs diff --git a/Cargo.lock b/Cargo.lock index 499fac454..a057e8e7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,20 +416,33 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" dependencies = [ - "askama_derive", + "askama_derive 0.12.5", "askama_escape", "humansize", "num-traits", "percent-encoding", ] +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + [[package]] name = "askama_derive" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" dependencies = [ - "askama_parser", + "askama_parser 0.2.1", "basic-toml", "mime", "mime_guess", @@ -439,12 +452,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser 0.16.0", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.117", +] + [[package]] name = "askama_escape" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive 0.16.0", +] + [[package]] name = "askama_parser" version = "0.2.1" @@ -454,6 +494,19 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow 1.0.2", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -1068,7 +1121,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ - "axum-core", + "axum-core 0.5.6", "base64 0.22.1", "bytes", "form_urlencoded", @@ -1099,6 +1152,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -1118,6 +1191,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-embed" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "077959a7f8cf438676af90b483304528eb7e16eadadb7f44e9ada4f9dceb9e62" +dependencies = [ + "axum-core 0.4.5", + "chrono", + "http 1.4.0", + "mime_guess", + "rust-embed", + "tower-service", +] + +[[package]] +name = "axum-htmx" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d4a162b7621482903309c0e8a990a866728b6312350147181230f840252314" +dependencies = [ + "axum-core 0.5.6", + "futures", + "http 1.4.0", + "tokio", + "tower", +] + [[package]] name = "axum-test" version = "18.1.0" @@ -1418,6 +1518,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -2715,6 +2825,91 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28a80e3145d8ad11ba0995949bbcf48b9df2be62772b3d351ef017dff6ecb853" +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax 0.12.0", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "fluent-template-macros" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748050b3fb6fd97b566aedff8e9e021389c963e73d5afbeb92752c2b8d686c6c" +dependencies = [ + "flume", + "ignore", + "proc-macro2", + "quote", + "syn 2.0.117", + "unic-langid", +] + +[[package]] +name = "fluent-templates" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56264446a01f404469aef9cc5fd4a4d736f68a0f52482bf6d1a54d6e9bbd9476" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax 0.12.0", + "fluent-template-macros", + "flume", + "ignore", + "intl-memoizer", + "log", + "thiserror 2.0.18", + "unic-langid", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2930,6 +3125,25 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.14", + "regex-syntax 0.8.10", +] + [[package]] name = "group" version = "0.12.1" @@ -3207,6 +3421,7 @@ dependencies = [ "helios-rest", "helios-sof", "helios-subscriptions", + "helios-ui", "openssl", "parking_lot", "reqwest", @@ -3449,11 +3664,28 @@ dependencies = [ "wiremock", ] +[[package]] +name = "helios-ui" +version = "0.2.1" +dependencies = [ + "askama 0.16.0", + "axum", + "axum-embed", + "axum-htmx", + "fluent-syntax 0.11.1", + "fluent-templates", + "http-body-util", + "rust-embed", + "tokio", + "tower", + "unic-langid", +] + [[package]] name = "helios-web" version = "0.2.1" dependencies = [ - "askama", + "askama 0.12.1", "axum", "serde", "tokio", @@ -3956,6 +4188,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.14", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4009,6 +4257,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c00403deb17c3221a1fe4fb571b9ed0370b3dcd116553c77fa294a3d918699" +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.24" @@ -5441,6 +5708,12 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -6121,6 +6394,40 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + [[package]] name = "rust-multipart-rfc7578_2" version = "0.8.0" @@ -6432,6 +6739,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -6736,6 +7049,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.6.0" @@ -7087,6 +7409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -7480,6 +7803,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typed-builder" version = "0.22.0" @@ -7542,6 +7874,49 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.117", + "unic-langid-impl", +] + [[package]] name = "unicase" version = "2.9.0" @@ -8208,6 +8583,9 @@ name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] [[package]] name = "wiremock" @@ -8473,6 +8851,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 71165b4c0..2ef07d928 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -27,3 +27,17 @@ axum-htmx = { version = "0.8", features = ["auto-vary"] } # `axum-embed` serves them with precompressed (br/gzip/deflate) negotiation. rust-embed = "8" axum-embed = "0.1" + +# Fluent message catalogs (locales//main.ftl at the workspace root), +# embedded at compile time — same no-runtime-dependency stance as the assets. +# See docs/multi-language.md for the i18n rules of the road. +fluent-templates = "0.13" +unic-langid = { version = "0.9", features = ["macros"] } + +[dev-dependencies] +# Parses the .ftl sources so tests can enforce key-set parity across locales. +fluent-syntax = "0.11" +# Drive the mounted router end-to-end (locale middleware included) in tests. +tokio = { version = "1", features = ["macros", "rt"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/crates/ui/src/i18n.rs b/crates/ui/src/i18n.rs new file mode 100644 index 000000000..018d0c1e7 --- /dev/null +++ b/crates/ui/src/i18n.rs @@ -0,0 +1,360 @@ +//! Locale negotiation and Fluent catalog lookup for the web UI. +//! +//! One locale is negotiated per request and carried in request extensions as +//! [`RequestLocale`]; templates resolve catalog keys against it through the +//! [`I18n`] helper. The policy is the one ratified in `docs/multi-language.md`: +//! explicit `?lang=` override (persisted in the `hfs_lang` cookie by the +//! language switcher) → cookie → `Accept-Language` (RFC 4647 Lookup) → `en`. +//! +//! Catalogs live in `locales//main.ftl` at the workspace root and are +//! embedded at compile time — no runtime file or CDN dependency, matching the +//! asset stance of this crate. `en` is the source locale and the final +//! fallback: a key missing from a translation renders its English string, +//! never a blank or a crash. + +use axum::{ + extract::{FromRequestParts, Request}, + http::{HeaderMap, Uri, header, request::Parts}, + middleware::Next, + response::Response, +}; +use fluent_templates::{Loader, fluent_bundle::FluentValue}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::convert::Infallible; +use unic_langid::{LanguageIdentifier, langid}; + +fluent_templates::static_loader! { + static LOCALES = { + locales: "../../locales", + fallback_language: "en", + // The UI renders whole localized sentences into an LTR document; the + // Unicode bidi isolation marks Fluent adds around placeables by + // default would only show up as garbage in tests and diffs. + customise: |bundle| bundle.set_use_isolating(false), + }; +} + +static EN: LanguageIdentifier = langid!("en"); +static ES: LanguageIdentifier = langid!("es"); +static DE: LanguageIdentifier = langid!("de"); + +/// Locales the UI can render, in switcher order. `en` first: it is the +/// source locale and the default. +static SUPPORTED: [&LanguageIdentifier; 3] = [&EN, &ES, &DE]; + +/// Name of the cookie the language switcher sets via `?lang=`. +const LANG_COOKIE: &str = "hfs_lang"; + +/// The locale negotiated for the current request. +/// +/// Inserted into request extensions by [`negotiate_locale`]; handlers take it +/// as an (infallible) extractor. Defaults to `en` when the middleware is not +/// installed, so templates always have a locale. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RequestLocale(&'static LanguageIdentifier); + +impl Default for RequestLocale { + fn default() -> Self { + RequestLocale(&EN) + } +} + +impl FromRequestParts for RequestLocale { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(parts + .extensions + .get::() + .copied() + .unwrap_or_default()) + } +} + +/// Middleware: negotiate the request locale once and expose it in request +/// extensions. When the choice came from an explicit `?lang=` (the language +/// switcher), persist it in the `hfs_lang` cookie so it survives navigation. +pub async fn negotiate_locale(mut request: Request, next: Next) -> Response { + let (locale, explicit) = negotiate(request.uri(), request.headers()); + request.extensions_mut().insert(RequestLocale(locale)); + + let mut response = next.run(request).await; + // The body varies with the negotiated inputs; without this a shared cache + // could hand one user's language to another. + response.headers_mut().append( + header::VARY, + header::HeaderValue::from_static("Accept-Language, Cookie"), + ); + if explicit { + let cookie = format!("{LANG_COOKIE}={locale}; Path=/; Max-Age=31536000; SameSite=Lax"); + if let Ok(value) = cookie.parse() { + response.headers_mut().append(header::SET_COOKIE, value); + } + } + response +} + +/// Resolve the locale for a request. Returns the locale and whether it came +/// from an explicit `?lang=` override (which the caller persists in a cookie). +fn negotiate(uri: &Uri, headers: &HeaderMap) -> (&'static LanguageIdentifier, bool) { + if let Some(locale) = query_lang(uri).as_deref().and_then(match_supported) { + return (locale, true); + } + let locale = cookie_lang(headers) + .and_then(|tag| match_supported(&tag)) + .or_else(|| accept_language(headers)) + .unwrap_or(&EN); + (locale, false) +} + +/// `lang` value from the query string, if any. +fn query_lang(uri: &Uri) -> Option { + uri.query()? + .split('&') + .find_map(|pair| pair.strip_prefix("lang=").map(str::to_owned)) +} + +/// `hfs_lang` cookie value, if any. +fn cookie_lang(headers: &HeaderMap) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .find_map(|pair| { + pair.trim() + .strip_prefix(LANG_COOKIE)? + .strip_prefix('=') + .map(str::to_owned) + }) +} + +/// Best supported locale for the `Accept-Language` header, if any: ranges in +/// descending quality order, each matched per RFC 4647 §3.4 Lookup. +fn accept_language(headers: &HeaderMap) -> Option<&'static LanguageIdentifier> { + let header = headers.get(header::ACCEPT_LANGUAGE)?.to_str().ok()?; + let mut ranges: Vec<(&str, f32)> = header + .split(',') + .filter_map(|part| { + let mut pieces = part.trim().split(';'); + let tag = pieces.next()?.trim(); + if tag.is_empty() || tag == "*" { + return None; + } + let q = pieces + .find_map(|p| p.trim().strip_prefix("q=")) + .and_then(|q| q.parse().ok()) + .unwrap_or(1.0); + Some((tag, q)) + }) + .collect(); + // Stable sort: equal-quality ranges keep the client's order. + ranges.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + ranges.iter().find_map(|(tag, _)| match_supported(tag)) +} + +/// RFC 4647 §3.4 Lookup of one language range against the supported set: +/// try the full tag, then progressively truncate subtags (`de-CH-1996` → +/// `de-CH` → `de`). +fn match_supported(tag: &str) -> Option<&'static LanguageIdentifier> { + let tag = tag.trim().to_ascii_lowercase(); + let mut range = tag.as_str(); + loop { + if let Some(locale) = SUPPORTED + .iter() + .find(|supported| supported.to_string().eq_ignore_ascii_case(range)) + { + return Some(locale); + } + match range.rfind('-') { + Some(idx) => range = &range[..idx], + None => return None, + } + } +} + +/// Template-side catalog access, bound to the request's negotiated locale. +/// +/// Templates hold keys, never English text: `{{ i18n.t("nav-dashboard") }}`. +/// Values are interpolated as Fluent placeables (escaped by Askama on +/// output), never spliced into markup. +#[derive(Clone, Copy)] +pub struct I18n { + locale: &'static LanguageIdentifier, +} + +impl I18n { + pub fn new(locale: RequestLocale) -> Self { + I18n { locale: locale.0 } + } + + /// Test-only constructor from a raw tag, for rendering templates in a + /// specific locale without building a request. + #[cfg(test)] + pub(crate) fn from_tag(tag: &str) -> Option { + match_supported(tag).map(|locale| I18n { locale }) + } + + /// BCP 47 tag of the active locale — for `` and the switcher. + pub fn lang(&self) -> String { + self.locale.to_string() + } + + /// Look up a message. Missing keys fall back to `en` (the loader's + /// fallback language); an unknown key renders the key itself rather + /// than crashing or blanking the page. + pub fn t(&self, key: &str) -> String { + LOCALES + .try_lookup(self.locale, key) + .unwrap_or_else(|| key.to_owned()) + } + + /// Look up a message with one named placeable, e.g. + /// `t_arg("status-version", "version", status.version)`. + pub fn t_arg(&self, key: &str, name: &str, value: impl Into>) -> String { + let args: HashMap, FluentValue<'static>> = + HashMap::from([(Cow::Owned(name.to_owned()), value.into())]); + LOCALES + .try_lookup_with_args(self.locale, key, &args) + .unwrap_or_else(|| key.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers(pairs: &[(header::HeaderName, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.append(name.clone(), HeaderValue::from_str(value).unwrap()); + } + map + } + + #[test] + fn default_locale_is_english() { + let (locale, explicit) = negotiate(&Uri::from_static("/ui"), &HeaderMap::new()); + assert_eq!(locale, &EN); + assert!(!explicit); + } + + #[test] + fn accept_language_is_matched_by_rfc4647_lookup() { + let map = headers(&[(header::ACCEPT_LANGUAGE, "de-DE, de;q=0.9, en;q=0.7")]); + let (locale, _) = negotiate(&Uri::from_static("/ui"), &map); + assert_eq!(locale, &DE, "de-DE must truncate to supported de"); + } + + #[test] + fn accept_language_honors_quality_order() { + let map = headers(&[(header::ACCEPT_LANGUAGE, "fr;q=0.9, es;q=0.8, en;q=0.1")]); + let (locale, _) = negotiate(&Uri::from_static("/ui"), &map); + assert_eq!(locale, &ES, "unsupported fr must fall through to es"); + } + + #[test] + fn cookie_beats_accept_language() { + let map = headers(&[ + (header::COOKIE, "session=abc; hfs_lang=es"), + (header::ACCEPT_LANGUAGE, "de"), + ]); + let (locale, explicit) = negotiate(&Uri::from_static("/ui"), &map); + assert_eq!(locale, &ES); + assert!(!explicit, "a cookie is a saved choice, not a new override"); + } + + #[test] + fn query_override_beats_everything_and_is_explicit() { + let map = headers(&[ + (header::COOKIE, "hfs_lang=es"), + (header::ACCEPT_LANGUAGE, "de"), + ]); + let (locale, explicit) = negotiate(&Uri::from_static("/ui/status?lang=de"), &map); + assert_eq!(locale, &DE); + assert!(explicit); + } + + #[test] + fn unsupported_values_fall_through() { + let map = headers(&[ + (header::COOKIE, "hfs_lang=klingon"), + (header::ACCEPT_LANGUAGE, "pt-BR, xx"), + ]); + let (locale, explicit) = negotiate(&Uri::from_static("/ui?lang=fr"), &map); + assert_eq!(locale, &EN, "nothing supported anywhere → en default"); + assert!(!explicit, "an unsupported ?lang= must not set a cookie"); + } + + #[test] + fn catalog_lookup_translates_and_falls_back() { + let es = I18n { locale: &ES }; + assert_eq!(es.t("nav-resources"), "Recursos"); + let de = I18n { locale: &DE }; + assert_eq!(de.t("nav-signout"), "Abmelden"); + // Unknown keys render the key, never a blank. + assert_eq!(de.t("no-such-key"), "no-such-key"); + } + + #[test] + fn placeables_interpolate_per_locale() { + let en = I18n { locale: &EN }; + assert_eq!(en.t_arg("health-uptime", "duration", "3d"), "Uptime: 3d"); + let de = I18n { locale: &DE }; + assert_eq!( + de.t_arg("health-uptime", "duration", "3d"), + "Betriebszeit: 3d" + ); + } + + #[test] + fn plural_categories_select_per_cldr() { + let en = I18n { locale: &EN }; + assert_eq!(en.t_arg("resource-count", "count", 1), "1 resource"); + assert_eq!(en.t_arg("resource-count", "count", 2), "2 resources"); + let de = I18n { locale: &DE }; + assert_eq!(de.t_arg("resource-count", "count", 1), "1 Ressource"); + assert_eq!(de.t_arg("resource-count", "count", 5), "5 Ressourcen"); + } + + /// `en` is the source of truth: every locale must define exactly the + /// same message and term set (locales/README.md). + #[test] + fn catalogs_share_the_same_key_set() { + use fluent_syntax::ast::Entry; + use std::collections::BTreeSet; + use std::path::Path; + + let keys = |locale: &str| -> BTreeSet { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../locales") + .join(locale) + .join("main.ftl"); + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let resource = fluent_syntax::parser::parse(source) + .unwrap_or_else(|(_, errors)| panic!("{locale}/main.ftl: {errors:?}")); + resource + .body + .iter() + .filter_map(|entry| match entry { + Entry::Message(m) => Some(m.id.name.clone()), + Entry::Term(t) => Some(format!("-{}", t.id.name)), + _ => None, + }) + .collect() + }; + + let en = keys("en"); + assert!(!en.is_empty()); + for locale in ["es", "de"] { + assert_eq!( + en, + keys(locale), + "{locale}/main.ftl must define the same key set as en/main.ftl" + ); + } + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index a0c798c00..73c689973 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -14,17 +14,25 @@ //! navigation (or vice versa). //! //! The router is mounted under `/ui` by the `hfs` binary via [`mount`]. +//! +//! All user-visible text is resolved from the Fluent catalogs in `locales/` +//! against the locale negotiated per request by [`i18n::negotiate_locale`] +//! (see `docs/multi-language.md`); templates hold catalog keys, not prose. + +mod i18n; use askama::Template; use axum::{ Router, extract::State, http::StatusCode, + middleware, response::{Html, IntoResponse, Response}, routing::get, }; use axum_embed::ServeEmbed; use axum_htmx::{AutoVaryLayer, HxRequest}; +use i18n::{I18n, RequestLocale}; use rust_embed::RustEmbed; use std::time::{SystemTime, UNIX_EPOCH}; @@ -53,12 +61,14 @@ struct Status { #[template(path = "pages/index.html")] struct IndexPage { status: Status, + i18n: I18n, } #[derive(Template)] #[template(path = "partials/status.html")] struct StatusPartial { status: Status, + i18n: I18n, } /// Mounts the web UI under `/ui`, falling back to the FHIR REST app for every @@ -72,6 +82,9 @@ pub fn mount(fhir_app: Router, hfs_version: &'static str) -> Router { // Emit `Vary: HX-Request` on handlers that read the header, so caches // don't cross a fragment response with a full-page one. .layer(AutoVaryLayer) + // One negotiated locale per request, in request extensions; every + // handler and template reads this same value. + .layer(middleware::from_fn(i18n::negotiate_locale)) .with_state(WebState { version: hfs_version, }) @@ -79,20 +92,26 @@ pub fn mount(fhir_app: Router, hfs_version: &'static str) -> Router { } /// Full landing page. -async fn index(State(state): State) -> Response { +async fn index(State(state): State, locale: RequestLocale) -> Response { render(IndexPage { status: current_status(state.version), + i18n: I18n::new(locale), }) } /// Status read path. Returns a fragment to htmx (`HX-Request`) and a full page /// on a hard navigation, so the same URL works with and without JavaScript. -async fn status(State(state): State, HxRequest(is_htmx): HxRequest) -> Response { +async fn status( + State(state): State, + locale: RequestLocale, + HxRequest(is_htmx): HxRequest, +) -> Response { let status = current_status(state.version); + let i18n = I18n::new(locale); if is_htmx { - render(StatusPartial { status }) + render(StatusPartial { status, i18n }) } else { - render(IndexPage { status }) + render(IndexPage { status, i18n }) } } @@ -125,6 +144,10 @@ fn unix_timestamp_seconds() -> u64 { mod tests { use super::*; + fn i18n(tag: &str) -> I18n { + I18n::from_tag(tag).expect("supported locale") + } + #[test] fn index_page_renders_version_and_local_assets() { let html = IndexPage { @@ -132,6 +155,7 @@ mod tests { version: "1.2.3", checked_at: 42, }, + i18n: i18n("en"), } .render() .expect("index renders"); @@ -144,6 +168,25 @@ mod tests { assert!(!html.contains("unpkg.com")); } + #[test] + fn index_page_renders_in_the_negotiated_locale() { + let html = IndexPage { + status: Status { + version: "1.2.3", + checked_at: 42, + }, + i18n: i18n("es"), + } + .render() + .expect("index renders"); + + assert!(html.contains(r#""#)); + assert!(html.contains("Actualizar estado")); + assert!(html.contains("Última comprobación: 42")); + // The language switcher marks the active locale. + assert!(html.contains(r#"href="?lang=es" aria-current="true""#)); + } + #[test] fn status_partial_is_fragment_not_full_page() { let html = StatusPartial { @@ -151,6 +194,7 @@ mod tests { version: "1.2.3", checked_at: 42, }, + i18n: i18n("en"), } .render() .expect("status renders"); diff --git a/crates/ui/templates/layouts/base.html b/crates/ui/templates/layouts/base.html index a4f8956f4..1b3cc5efd 100644 --- a/crates/ui/templates/layouts/base.html +++ b/crates/ui/templates/layouts/base.html @@ -1,14 +1,24 @@ - + - {% block title %}Helios FHIR Server{% endblock %} + {% block title %}{{ i18n.t("app-title") }}{% endblock %} + +
{% block content %}{% endblock %}
diff --git a/crates/ui/templates/pages/index.html b/crates/ui/templates/pages/index.html index 9ecbb4a5c..b6118b8e5 100644 --- a/crates/ui/templates/pages/index.html +++ b/crates/ui/templates/pages/index.html @@ -1,8 +1,8 @@ {% extends "layouts/base.html" %} {% block content %} -

Helios FHIR Server

-

Server-rendered, HTMX-first UI. This panel is refreshed as an HTML fragment.

+

{{ i18n.t("app-title") }}

+

{{ i18n.t("home-lede") }}

{% include "partials/status.html" %} @@ -20,5 +20,5 @@

Helios FHIR Server

hx-get="/ui/status" hx-target="#status" hx-swap="innerHTML" ->Refresh status +>{{ i18n.t("action-refresh-status") }} {% endblock %} diff --git a/crates/ui/templates/partials/status.html b/crates/ui/templates/partials/status.html index 02a978d34..0fc85df5b 100644 --- a/crates/ui/templates/partials/status.html +++ b/crates/ui/templates/partials/status.html @@ -1,5 +1,5 @@

- hfs version {{ status.version }} - Last checked: {{ status.checked_at }} + {{ i18n.t_arg("status-version", "version", status.version.to_string()) }} + {{ i18n.t_arg("status-last-checked", "timestamp", status.checked_at) }}

diff --git a/crates/ui/tests/i18n_http.rs b/crates/ui/tests/i18n_http.rs new file mode 100644 index 000000000..46b0f80ce --- /dev/null +++ b/crates/ui/tests/i18n_http.rs @@ -0,0 +1,123 @@ +//! End-to-end locale negotiation through the mounted router: the same +//! requests a browser would make, exercising the middleware, the handlers, +//! and the templates together. + +use axum::{ + Router, + body::Body, + http::{Request, header}, +}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +fn app() -> Router { + helios_ui::mount(Router::new(), "9.9.9") +} + +async fn body_text(response: axum::response::Response) -> String { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +#[tokio::test] +async fn accept_language_selects_the_ui_language() { + let response = app() + .oneshot( + Request::get("/ui") + .header(header::ACCEPT_LANGUAGE, "de-DE, de;q=0.9, en;q=0.7") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!(response.headers().get(header::SET_COOKIE).is_none()); + let vary: Vec<_> = response + .headers() + .get_all(header::VARY) + .iter() + .map(|v| v.to_str().unwrap().to_owned()) + .collect(); + assert!( + vary.iter().any(|v| v.contains("Accept-Language")), + "language-dependent responses must vary on Accept-Language, got {vary:?}" + ); + let html = body_text(response).await; + assert!(html.contains(r#""#)); + assert!(html.contains("Status aktualisieren")); + assert!(html.contains("Zuletzt geprüft:")); +} + +#[tokio::test] +async fn lang_override_wins_and_persists_in_a_cookie() { + let response = app() + .oneshot( + Request::get("/ui?lang=es") + .header(header::ACCEPT_LANGUAGE, "de") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("explicit ?lang= must set the hfs_lang cookie") + .to_str() + .unwrap() + .to_owned(); + assert!(cookie.starts_with("hfs_lang=es")); + + let html = body_text(response).await; + assert!(html.contains(r#""#)); + assert!(html.contains("Actualizar estado")); +} + +#[tokio::test] +async fn cookie_keeps_the_choice_on_later_requests() { + let response = app() + .oneshot( + Request::get("/ui") + .header(header::COOKIE, "hfs_lang=es") + .header(header::ACCEPT_LANGUAGE, "de") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let html = body_text(response).await; + assert!(html.contains(r#""#)); +} + +#[tokio::test] +async fn htmx_fragment_is_localized_too() { + let response = app() + .oneshot( + Request::get("/ui/status") + .header("HX-Request", "true") + .header(header::ACCEPT_LANGUAGE, "es") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let html = body_text(response).await; + assert!(html.contains("Última comprobación:")); + assert!(html.contains("9.9.9")); + assert!(!html.contains(""#)); + assert!(html.contains("Refresh status")); +} diff --git a/docs/multi-language.md b/docs/multi-language.md index ed8770980..8d00e9680 100644 --- a/docs/multi-language.md +++ b/docs/multi-language.md @@ -56,7 +56,7 @@ Browser ──Accept-Language: de-DE, de;q=0.9, en;q=0.7──▶ hfs (Axum) │ │ │ ┌───────────────────────────────┼───────────────┐ │ ▼ ▼ ▼ - │ helios-web (UI) helios-rest (API) helios-hts + │ helios-ui (UI) helios-rest (API) helios-hts │ renders Askama template OperationOutcome $expand/$lookup │ + Fluent catalog (Layer 1) text (Layer 3) displayLanguage │ (Layer 5) @@ -282,7 +282,9 @@ rules of the road in §3.3 have been violated. Structure that lands with the UI crate (#186), not here: the negotiation middleware, the Askama/Fluent template helper, and the `Cargo.toml` wiring — this branch establishes the **shape and the rules**; the runtime wiring rides on the -`helios-web` foundation so the two don't conflict. +`helios-ui` foundation so the two don't conflict. That wiring now lives in +`crates/ui/src/i18n.rs` (the `RequestLocale` middleware, the `hfs_lang` +switcher cookie, and the Fluent lookup helper the templates use). --- diff --git a/locales/de/main.ftl b/locales/de/main.ftl index 022bbbc95..26ce0d417 100644 --- a/locales/de/main.ftl +++ b/locales/de/main.ftl @@ -28,6 +28,16 @@ language-en = Englisch language-es = Spanisch language-de = Deutsch +## Startseite + +home-lede = Serverseitig gerenderte, HTMX-basierte Oberfläche. Dieses Panel wird als HTML-Fragment aktualisiert. + +## Statuspanel + +status-version = hfs-Version { $version } +status-last-checked = Zuletzt geprüft: { $timestamp } +action-refresh-status = Status aktualisieren + ## Übersicht / Status dashboard-heading = Server-Übersicht diff --git a/locales/en/main.ftl b/locales/en/main.ftl index 725a548ce..53925bc66 100644 --- a/locales/en/main.ftl +++ b/locales/en/main.ftl @@ -30,6 +30,16 @@ language-en = English language-es = Spanish language-de = German +## Home / landing page + +home-lede = Server-rendered, HTMX-first UI. This panel is refreshed as an HTML fragment. + +## Status panel + +status-version = hfs version { $version } +status-last-checked = Last checked: { $timestamp } +action-refresh-status = Refresh status + ## Dashboard / health dashboard-heading = Server dashboard diff --git a/locales/es/main.ftl b/locales/es/main.ftl index 0e7bb4139..8bbbf485a 100644 --- a/locales/es/main.ftl +++ b/locales/es/main.ftl @@ -28,6 +28,16 @@ language-en = Inglés language-es = Español language-de = Alemán +## Página de inicio + +home-lede = Interfaz renderizada en el servidor y basada en HTMX. Este panel se actualiza como un fragmento HTML. + +## Panel de estado + +status-version = hfs, versión { $version } +status-last-checked = Última comprobación: { $timestamp } +action-refresh-status = Actualizar estado + ## Panel / estado dashboard-heading = Panel del servidor