477 lines
16 KiB
Rust
477 lines
16 KiB
Rust
//! Localización (L10n).
|
|
//!
|
|
//! `PageTop` utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la
|
|
//! localización de aplicaciones, y aprovecha [fluent-templates](https://docs.rs/fluent-templates/)
|
|
//! para integrar los recursos de traducción directamente en el binario de la aplicación.
|
|
//!
|
|
//! # Sintaxis Fluent (FTL)
|
|
//!
|
|
//! El formato empleado para describir los recursos de traducción se denomina
|
|
//! [FTL](https://www.projectfluent.org/fluent/guide/). Está diseñado para ser legible y expresivo,
|
|
//! permitiendo representar construcciones complejas del lenguaje natural como el género, el plural
|
|
//! o las conjugaciones verbales.
|
|
//!
|
|
//! # Recursos Fluent
|
|
//!
|
|
//! Por defecto las traducciones están en el directorio `src/locale`, con subdirectorios para cada
|
|
//! [Identificador de Idioma Unicode](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier)
|
|
//! válido. Podríamos tener una estructura como esta:
|
|
//!
|
|
//! ```text
|
|
//! src/locale/
|
|
//! ├── common.ftl
|
|
//! ├── en-US/
|
|
//! │ ├── default.ftl
|
|
//! │ └── main.ftl
|
|
//! ├── es-ES/
|
|
//! │ ├── default.ftl
|
|
//! │ └── main.ftl
|
|
//! ├── es-MX/
|
|
//! │ ├── default.ftl
|
|
//! │ └── main.ftl
|
|
//! └── fr-FR/
|
|
//! ├── default.ftl
|
|
//! └── main.ftl
|
|
//! ```
|
|
//!
|
|
//! Ejemplo de un archivo en `src/locale/en-US/main.ftl`
|
|
//!
|
|
//! ```text
|
|
//! hello-world = Hello world!
|
|
//! hello-user = Hello, {$userName}!
|
|
//! shared-photos =
|
|
//! {$userName} {$photoCount ->
|
|
//! [one] added a new photo
|
|
//! *[other] added {$photoCount} new photos
|
|
//! } of {$userGender ->
|
|
//! [male] him and his family
|
|
//! [female] her and her family
|
|
//! *[other] the family
|
|
//! }.
|
|
//! ```
|
|
//!
|
|
//! Y su archivo equivalente para español en `src/locale/es-ES/main.ftl`:
|
|
//!
|
|
//! ```text
|
|
//! hello-world = Hola mundo!
|
|
//! hello-user = ¡Hola, {$userName}!
|
|
//! shared-photos =
|
|
//! {$userName} {$photoCount ->
|
|
//! [one] ha añadido una nueva foto
|
|
//! *[other] ha añadido {$photoCount} nuevas fotos
|
|
//! } de {$userGender ->
|
|
//! [male] él y su familia
|
|
//! [female] ella y su familia
|
|
//! *[other] la familia
|
|
//! }.
|
|
//! ```
|
|
//!
|
|
//!
|
|
//! # Cómo aplicar la localización en tu código
|
|
//!
|
|
//! Una vez creado el directorio con los recursos FTL, basta con usar la macro
|
|
//! [`include_locales!`](crate::include_locales) para integrarlos en la aplicación.
|
|
//!
|
|
//! Si los recursos se encuentran en el directorio por defecto `src/locale` del *crate*, sólo hay
|
|
//! que declarar:
|
|
//!
|
|
//! ```rust
|
|
//! use pagetop::prelude::*;
|
|
//!
|
|
//! include_locales!(LOCALES_SAMPLE);
|
|
//! ```
|
|
//!
|
|
//! Si están ubicados en otro directorio se puede usar la forma:
|
|
//!
|
|
//! ```rust,ignore
|
|
//! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones");
|
|
//! ```
|
|
//!
|
|
//! Y *voilà*, sólo queda operar con los idiomas soportados por `PageTop` usando [`LangMatch`] y
|
|
//! traducir textos con [`L10n`].
|
|
|
|
use crate::html::{Markup, PreEscaped};
|
|
use crate::{global, hm, AutoDefault};
|
|
|
|
pub use fluent_templates;
|
|
pub use unic_langid::{CharacterDirection, LanguageIdentifier};
|
|
|
|
use unic_langid::langid;
|
|
|
|
use fluent_templates::Loader;
|
|
use fluent_templates::StaticLoader as Locales;
|
|
|
|
use std::borrow::Cow;
|
|
use std::collections::HashMap;
|
|
use std::sync::LazyLock;
|
|
|
|
use std::fmt;
|
|
|
|
// Asocia cada identificador de idioma (como "en-US") con su respectivo [`LanguageIdentifier`] y la
|
|
// clave en *locale/.../languages.ftl* para obtener el nombre del idioma según la localización.
|
|
static LANGUAGES: LazyLock<HashMap<&str, (LanguageIdentifier, &str)>> = LazyLock::new(|| {
|
|
hm![
|
|
"en" => ( langid!("en-US"), "english" ),
|
|
"en-gb" => ( langid!("en-GB"), "english_british" ),
|
|
"en-us" => ( langid!("en-US"), "english_united_states" ),
|
|
"es" => ( langid!("es-ES"), "spanish" ),
|
|
"es-es" => ( langid!("es-ES"), "spanish_spain" ),
|
|
]
|
|
});
|
|
|
|
// Identificador de idioma de **respaldo** (predefinido a `en-US`).
|
|
//
|
|
// Se usa cuando el valor del identificador de idioma en las traducciones no corresponde con ningún
|
|
// idioma soportado por la aplicación.
|
|
pub(crate) static FALLBACK_LANGID: LazyLock<LanguageIdentifier> =
|
|
LazyLock::new(|| langid!("en-US"));
|
|
|
|
// Identificador de idioma **por defecto** para la aplicación.
|
|
//
|
|
// Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador
|
|
// de idioma no es válido o no está disponible entonces resuelve como [`FALLBACK_LANGID`].
|
|
pub(crate) static DEFAULT_LANGID: LazyLock<Option<&LanguageIdentifier>> =
|
|
LazyLock::new(|| LangMatch::resolve(&global::SETTINGS.app.language).as_option());
|
|
|
|
/// Representa la fuente de idioma (`LanguageIdentifier`) asociada a un recurso.
|
|
///
|
|
/// Este *trait* permite que distintas estructuras expongan su fuente de idioma de forma uniforme.
|
|
pub trait LangId {
|
|
/// Devuelve el identificador de idioma asociado al recurso.
|
|
fn langid(&self) -> &'static LanguageIdentifier;
|
|
}
|
|
|
|
/// Operaciones con los idiomas soportados por `PageTop`.
|
|
///
|
|
/// Utiliza [`LangMatch`] para transformar un identificador de idioma en un [`LanguageIdentifier`]
|
|
/// soportado por `PageTop`.
|
|
///
|
|
/// # Ejemplos
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// // Coincidencia exacta.
|
|
/// let lang = LangMatch::resolve("es-ES");
|
|
/// assert_eq!(lang.langid().to_string(), "es-ES");
|
|
///
|
|
/// // Coincidencia parcial (con el idioma base).
|
|
/// let lang = LangMatch::resolve("es-EC");
|
|
/// assert_eq!(lang.langid().to_string(), "es-ES"); // Porque "es-EC" no está soportado.
|
|
///
|
|
/// // Idioma no especificado.
|
|
/// let lang = LangMatch::resolve("");
|
|
/// assert_eq!(lang, LangMatch::Unspecified);
|
|
///
|
|
/// // Idioma no soportado.
|
|
/// let lang = LangMatch::resolve("ja-JP");
|
|
/// assert_eq!(lang, LangMatch::Unsupported(String::from("ja-JP")));
|
|
/// ```
|
|
///
|
|
/// Con la siguiente instrucción siempre se obtiene un [`LanguageIdentifier`] válido, ya sea porque
|
|
/// resuelve un idioma soportado o porque se aplica el idioma por defecto o, en último caso, el de
|
|
/// respaldo ("en-US"):
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// // Idioma por defecto o de respaldo si no resuelve.
|
|
/// let lang = LangMatch::resolve("it-IT");
|
|
/// let langid = lang.langid();
|
|
/// ```
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum LangMatch {
|
|
/// Cuando el identificador de idioma es una cadena vacía.
|
|
Unspecified,
|
|
/// Si encuentra un [`LanguageIdentifier`] en la lista de idiomas soportados por `PageTop` que
|
|
/// coincide exactamente con el identificador de idioma (p.ej. "es-ES"), o con el identificador
|
|
/// del idioma base (p.ej. "es").
|
|
Found(&'static LanguageIdentifier),
|
|
/// Si el identificador de idioma no está entre los soportados por `PageTop`.
|
|
Unsupported(String),
|
|
}
|
|
|
|
impl Default for LangMatch {
|
|
/// Resuelve al idioma por defecto y, si no está disponible, al idioma de respaldo ("en-US").
|
|
fn default() -> Self {
|
|
LangMatch::Found(DEFAULT_LANGID.unwrap_or(&FALLBACK_LANGID))
|
|
}
|
|
}
|
|
|
|
impl LangMatch {
|
|
/// Resuelve `language` y devuelve la variante [`LangMatch`] apropiada.
|
|
pub fn resolve(language: impl AsRef<str>) -> Self {
|
|
let language = language.as_ref().trim();
|
|
|
|
// Rechaza cadenas vacías.
|
|
if language.is_empty() {
|
|
return Self::Unspecified;
|
|
}
|
|
|
|
// Intenta aplicar coincidencia exacta con el código completo (p.ej. "es-MX").
|
|
let lang = language.to_ascii_lowercase();
|
|
if let Some(langid) = LANGUAGES.get(lang.as_str()).map(|(langid, _)| langid) {
|
|
return Self::Found(langid);
|
|
}
|
|
|
|
// Si la variante regional no existe, retrocede al idioma base (p.ej. "es").
|
|
if let Some((base_lang, _)) = lang.split_once('-') {
|
|
if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) {
|
|
return Self::Found(langid);
|
|
}
|
|
}
|
|
|
|
// En otro caso indica que el idioma no está soportado.
|
|
Self::Unsupported(String::from(language))
|
|
}
|
|
|
|
/// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido.
|
|
///
|
|
/// Solo retorna `Some` si la variante es [`LangMatch::Found`]. En cualquier otro caso (por
|
|
/// ejemplo, si el identificador es vacío o no está soportado), devuelve `None`.
|
|
///
|
|
/// Este método es útil cuando se desea acceder directamente al idioma reconocido sin aplicar el
|
|
/// idioma por defecto ni el de respaldo.
|
|
///
|
|
/// # Ejemplo
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// let lang = LangMatch::resolve("es-ES").as_option();
|
|
/// assert_eq!(lang.unwrap().to_string(), "es-ES");
|
|
///
|
|
/// let lang = LangMatch::resolve("jp-JP").as_option();
|
|
/// assert!(lang.is_none());
|
|
/// ```
|
|
#[inline]
|
|
pub fn as_option(&self) -> Option<&'static LanguageIdentifier> {
|
|
match self {
|
|
LangMatch::Found(l) => Some(l),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Permite a [`LangMatch`] actuar como proveedor de idioma.
|
|
///
|
|
/// Devuelve el [`LanguageIdentifier`] si la variante es [`LangMatch::Found`]; en caso contrario,
|
|
/// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de
|
|
/// respaldo ("en-US").
|
|
///
|
|
/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::using()`]
|
|
/// o [`L10n::to_markup()`].
|
|
impl LangId for LangMatch {
|
|
fn langid(&self) -> &'static LanguageIdentifier {
|
|
match self {
|
|
LangMatch::Found(l) => l,
|
|
_ => DEFAULT_LANGID.unwrap_or(&FALLBACK_LANGID),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[macro_export]
|
|
/// Define un conjunto de elementos de localización y textos de traducción local.
|
|
macro_rules! include_locales {
|
|
// Se eliminan las marcas de aislamiento Unicode en los argumentos para mejorar la legibilidad y
|
|
// la compatibilidad en ciertos contextos de renderizado.
|
|
( $LOCALES:ident $(, $core_locales:literal)? ) => {
|
|
$crate::locale::fluent_templates::static_loader! {
|
|
static $LOCALES = {
|
|
locales: "src/locale",
|
|
$( core_locales: $core_locales, )?
|
|
fallback_language: "en-US",
|
|
// Elimina marcas de aislamiento Unicode en los argumentos.
|
|
customise: |bundle| bundle.set_use_isolating(false),
|
|
};
|
|
}
|
|
};
|
|
( $LOCALES:ident from $dir_locales:literal $(, $core_locales:literal)? ) => {
|
|
$crate::locale::fluent_templates::static_loader! {
|
|
static $LOCALES = {
|
|
locales: $dir_locales,
|
|
$( core_locales: $core_locales, )?
|
|
fallback_language: "en-US",
|
|
// Elimina marcas de aislamiento Unicode en los argumentos.
|
|
customise: |bundle| bundle.set_use_isolating(false),
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
include_locales!(LOCALES_PAGETOP);
|
|
|
|
// Operación de localización a realizar.
|
|
//
|
|
// * `None` - No se aplica ninguna localización.
|
|
// * `Text` - Con una cadena literal que se devolverá tal cual.
|
|
// * `Translate` - Con la clave a resolver en el `Locales` indicado.
|
|
#[derive(AutoDefault, Clone, Debug)]
|
|
enum L10nOp {
|
|
#[default]
|
|
None,
|
|
Text(String),
|
|
Translate(String),
|
|
}
|
|
|
|
/// Crea instancias para traducir textos localizados.
|
|
///
|
|
/// Cada instancia puede representar:
|
|
///
|
|
/// - Un texto puro (`n()`) que no requiere traducción.
|
|
/// - Una clave para traducir un texto de las traducciones predefinidas de `PageTop` (`l()`).
|
|
/// - Una clave para traducir de un conjunto concreto de traducciones (`t()`).
|
|
///
|
|
/// # Ejemplo
|
|
///
|
|
/// Los argumentos dinámicos se añaden usando `with_arg()` o `with_args()`.
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// // Texto literal sin traducción.
|
|
/// let raw = L10n::n("© 2025 PageTop").get();
|
|
///
|
|
/// // Traducción simple con clave y argumentos.
|
|
/// let hello = L10n::l("greeting")
|
|
/// .with_arg("name", "Manuel")
|
|
/// .get();
|
|
/// ```
|
|
///
|
|
/// También para traducciones a idiomas concretos.
|
|
///
|
|
/// ```rust,ignore
|
|
/// // Traducción con clave, conjunto de traducciones y fuente de idioma.
|
|
/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).using(&LangMatch::resolve("it"));
|
|
/// ```
|
|
#[derive(AutoDefault, Clone)]
|
|
pub struct L10n {
|
|
op: L10nOp,
|
|
#[default(&LOCALES_PAGETOP)]
|
|
locales: &'static Locales,
|
|
args: HashMap<String, String>,
|
|
}
|
|
|
|
impl L10n {
|
|
/// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción.
|
|
pub fn n(text: impl Into<String>) -> Self {
|
|
L10n {
|
|
op: L10nOp::Text(text.into()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de
|
|
/// traducciones predefinidas.
|
|
pub fn l(key: impl Into<String>) -> Self {
|
|
L10n {
|
|
op: L10nOp::Translate(key.into()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de
|
|
/// traducciones específico.
|
|
pub fn t(key: impl Into<String>, locales: &'static Locales) -> Self {
|
|
L10n {
|
|
op: L10nOp::Translate(key.into()),
|
|
locales,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Añade un argumento `{$arg}` → `value` a la traducción.
|
|
pub fn with_arg(mut self, arg: impl Into<String>, value: impl Into<String>) -> Self {
|
|
self.args.insert(arg.into(), value.into());
|
|
self
|
|
}
|
|
|
|
/// Añade varios argumentos a la traducción de una sola vez (p.ej. usando la macro [`hm!`],
|
|
/// también vec![("k", "v")], incluso un array de duplas u otras colecciones).
|
|
pub fn with_args<I, K, V>(mut self, args: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = (K, V)>,
|
|
K: Into<String>,
|
|
V: Into<String>,
|
|
{
|
|
self.args
|
|
.extend(args.into_iter().map(|(k, v)| (k.into(), v.into())));
|
|
self
|
|
}
|
|
|
|
/// Resuelve la traducción usando el idioma por defecto o de respaldo de la aplicación.
|
|
///
|
|
/// Devuelve `None` si no aplica o no encuentra una traducción válida.
|
|
///
|
|
/// # Ejemplo
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// let text = L10n::l("greeting").with_arg("name", "Manuel").get();
|
|
/// ```
|
|
pub fn get(&self) -> Option<String> {
|
|
self.using(&LangMatch::default())
|
|
}
|
|
|
|
/// Resuelve la traducción usando la fuente de idioma proporcionada.
|
|
///
|
|
/// Devuelve `None` si no aplica o no encuentra una traducción válida.
|
|
///
|
|
/// # Ejemplo
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// struct ResourceLang;
|
|
///
|
|
/// impl LangId for ResourceLang {
|
|
/// fn langid(&self) -> &'static LanguageIdentifier {
|
|
/// LangMatch::resolve("es-MX").langid()
|
|
/// }
|
|
/// }
|
|
///
|
|
/// let r = ResourceLang;
|
|
/// let text = L10n::l("greeting").with_arg("name", "Usuario").using(&r);
|
|
/// ```
|
|
pub fn using(&self, language: &impl LangId) -> Option<String> {
|
|
match &self.op {
|
|
L10nOp::None => None,
|
|
L10nOp::Text(text) => Some(text.to_owned()),
|
|
L10nOp::Translate(key) => self.locales.try_lookup_with_args(
|
|
language.langid(),
|
|
key,
|
|
&self.args.iter().fold(HashMap::new(), |mut arg, (k, v)| {
|
|
arg.insert(Cow::Owned(k.clone()), v.to_owned().into());
|
|
arg
|
|
}),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Traduce el texto y lo devuelve como [`Markup`] usando la fuente de idioma proporcionada.
|
|
///
|
|
/// Si no se encuentra una traducción válida, devuelve una cadena vacía.
|
|
///
|
|
/// # Ejemplo
|
|
///
|
|
/// ```rust
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// let html = L10n::l("welcome.message").to_markup(&LangMatch::resolve("es"));
|
|
/// ```
|
|
pub fn to_markup(&self, language: &impl LangId) -> Markup {
|
|
PreEscaped(self.using(language).unwrap_or_default())
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for L10n {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("L10n")
|
|
.field("op", &self.op)
|
|
.field("args", &self.args)
|
|
// No se puede mostrar `locales`. Se representa con un texto fijo.
|
|
.field("locales", &"<StaticLoader>")
|
|
.finish()
|
|
}
|
|
}
|