feat(i18n): add i18n on server side

- remove the hardcoded list of locales in favor of a discovry on start time
    - server will stop on badly formatted locale .json
    - add server.* entries for serer side translation

    server side translation will be used for templating and email
    note: no json in some embded html (like in /magic), amount of work was similar
This commit is contained in:
Edouard Vanbelle
2026-06-03 13:18:05 +02:00
parent e0f59aa942
commit 044bd76738
32 changed files with 1520 additions and 149 deletions
+40 -41
View File
@@ -1,5 +1,20 @@
//! Domain port for translation lookup.
//!
//! The concrete locale type lives in [`crate::common::locale::Locale`] and
//! is a string-backed newtype validated at construction against a
//! [`LocaleRegistry`] populated at startup from `static/locales/*.json`.
//!
//! This module is a thin facade: the trait + error types stay where the
//! application + infrastructure layers expect them; the type itself is
//! re-exported from `common` so the same `Locale` value flows through
//! handlers, middleware, services, and DTOs without re-wrapping.
//!
//! [`LocaleRegistry`]: crate::common::locale::LocaleRegistry
use thiserror::Error;
pub use crate::common::locale::Locale;
/// Error types for i18n service operations
#[derive(Debug, Error)]
pub enum I18nError {
@@ -16,53 +31,37 @@ pub enum I18nError {
/// Result type for i18n service operations
pub type I18nResult<T> = Result<T, I18nError>;
/// Supported locales
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Locale {
#[default]
English,
Spanish,
French,
German,
Portuguese,
}
impl Locale {
/// Convert locale to code string
pub fn as_str(&self) -> &'static str {
match self {
Locale::English => "en",
Locale::Spanish => "es",
Locale::French => "fr",
Locale::German => "de",
Locale::Portuguese => "pt",
}
}
/// Create from locale code string
pub fn from_code(code: &str) -> Option<Self> {
match code.to_lowercase().as_str() {
"en" => Some(Locale::English),
"es" => Some(Locale::Spanish),
"fr" => Some(Locale::French),
"de" => Some(Locale::German),
"pt" => Some(Locale::Portuguese),
_ => None,
}
}
}
/// Interface for i18n service (primary port)
/// Interface for i18n service (primary port).
///
/// Implementations should fall back to English when the requested
/// locale has no entry for `key`. Unknown locales (codes not in the
/// configured [`crate::common::locale::LocaleRegistry`]) are an
/// `InvalidLocale` error — callers normally avoid this by going
/// through the registry's `parse_or_default` before calling
/// `translate`.
pub trait I18nService: Send + Sync + 'static {
/// Get a translation for a key and locale
/// Get a translation for a key and locale.
async fn translate(&self, key: &str, locale: Locale) -> I18nResult<String>;
/// Load translations for a locale
/// Get a translation with `{{name}}`-mustache substitution applied
/// to the resolved string. Mirrors the frontend convention in
/// `static/js/core/i18n.js:117` so JSON values are interchangeable
/// between front- and back-end.
async fn translate_args(
&self,
key: &str,
locale: Locale,
args: &[(&str, &str)],
) -> I18nResult<String>;
/// Load translations for a locale into the in-memory cache.
async fn load_translations(&self, locale: Locale) -> I18nResult<()>;
/// Get available locales
/// Available locales — typically the contents of the underlying
/// registry. Returned in arbitrary order; callers that need a
/// stable order should sort.
async fn available_locales(&self) -> Vec<Locale>;
/// Check if a locale is supported
/// True iff the given locale is in the registry.
async fn is_supported(&self, locale: Locale) -> bool;
}