diff --git a/extensions/pagetop-bootsier/src/theme/bs/badge.rs b/extensions/pagetop-bootsier/src/theme/bs/badge.rs index 95f4ef50..52461833 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/badge.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/badge.rs @@ -15,9 +15,7 @@ const EXTRA_TEXT_BG: &str = "bootsier.badge.text_bg"; /// use pagetop::prelude::*; /// use pagetop_bootsier::theme::*; /// -/// let admin = bs::Badge::new() -/// .with_label(L10n::n("Admin")) -/// .with_text_bg(ThemeColor::Danger); +/// let admin = bs::Badge::labeled(L10n::n("Admin")).with_text_bg(ThemeColor::Danger); /// ``` pub trait BadgeBootsier { #[builder_fn] diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs index 6c7261f6..37d2f137 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs @@ -83,7 +83,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result Result Result { optgroup - label=(group.label().using(cx)) + label=[group.label().lookup(cx)] disabled[*group.disabled()] { @for opt in group.items() { diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs b/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs index 0238d832..c9276b2e 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs @@ -76,7 +76,7 @@ pub(crate) fn render(field: &Textarea, cx: &mut Context) -> Result Self { + Self { + label, + ..Default::default() + } + } + // **< Badge BUILDER >************************************************************************** /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. @@ -50,7 +57,7 @@ impl Badge { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { self.props.alter_prop(op); diff --git a/src/base/component/form/checkbox.rs b/src/base/component/form/checkbox.rs index cb9323af..fa36125a 100644 --- a/src/base/component/form/checkbox.rs +++ b/src/base/component/form/checkbox.rs @@ -123,7 +123,7 @@ impl Component for Checkbox { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=[L10n::l("field_required").lookup(cx)] { "*" } diff --git a/src/base/component/form/input.rs b/src/base/component/form/input.rs index c8946085..bf49c9b3 100644 --- a/src/base/component/form/input.rs +++ b/src/base/component/form/input.rs @@ -238,7 +238,7 @@ impl Component for Field { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=[L10n::l("field_required").lookup(cx)] { "*" } diff --git a/src/base/component/form/number.rs b/src/base/component/form/number.rs index f8ad1c94..dffcafc7 100644 --- a/src/base/component/form/number.rs +++ b/src/base/component/form/number.rs @@ -89,7 +89,7 @@ impl Component for Number { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=[L10n::l("field_required").lookup(cx)] { "*" } diff --git a/src/base/component/form/radio.rs b/src/base/component/form/radio.rs index 330b98f3..ca9a8aa2 100644 --- a/src/base/component/form/radio.rs +++ b/src/base/component/form/radio.rs @@ -150,7 +150,7 @@ impl Component for Field { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=[L10n::l("field_required").lookup(cx)] { "*" } diff --git a/src/base/component/form/select.rs b/src/base/component/form/select.rs index 3796b42b..7436df74 100644 --- a/src/base/component/form/select.rs +++ b/src/base/component/form/select.rs @@ -246,7 +246,7 @@ impl Component for Field { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=[L10n::l("field_required").lookup(cx)] { "*" } @@ -277,7 +277,7 @@ impl Component for Field { } Entry::Group(group) => { optgroup - label=(group.label().using(cx)) + label=[group.label().lookup(cx)] disabled[*group.disabled()] { @for opt in group.items() { diff --git a/src/base/component/form/textarea.rs b/src/base/component/form/textarea.rs index 8cafccd5..3ad89955 100644 --- a/src/base/component/form/textarea.rs +++ b/src/base/component/form/textarea.rs @@ -99,7 +99,7 @@ impl Component for Textarea { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=[L10n::l("field_required").lookup(cx)] { "*" } diff --git a/src/locale/l10n.rs b/src/locale/l10n.rs index 02b255f5..ad5de896 100644 --- a/src/locale/l10n.rs +++ b/src/locale/l10n.rs @@ -1,4 +1,4 @@ -use crate::html::{Markup, PreEscaped}; +use crate::html::{Markup, PreEscaped, Render}; use crate::{AutoDefault, CowStr, include_locales}; use super::{LangId, Locale}; @@ -30,9 +30,30 @@ enum L10nOp { /// 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 un texto del conjunto de traducciones predefinidas de PageTop (`l()`). /// - Una clave para traducir de un conjunto concreto de traducciones (`t()`). /// +/// # ¿Cuál usar, `get()`, `lookup()` o `using()`? +/// +/// Los tres métodos resuelven la traducción; difieren en el tipo que devuelven y en la fuente de +/// idioma que aceptan. +/// +/// - [`get()`](Self::get) y [`lookup()`](Self::lookup) devuelven `Option`: el texto +/// traducido en bruto, con el escapado habitual de cualquier `String` al interpolarlo, y `None` +/// si no aplica o no hay traducción. Son la opción adecuada para cualquier valor de atributo +/// (`attr=[...]` en [`html!`](crate::html::html)), como `title`, `aria-label` o `alt`. +/// +/// Entre ambos, la opción habitual es [`lookup()`](Self::lookup), que acepta una fuente de idioma +/// explícita (`&impl LangId`, normalmente `cx: &Context`). [`get()`](Self::get) queda para los +/// casos marginales en los que no hay `Context` ni otra fuente de idioma a mano, y resuelve con +/// el idioma por defecto o de respaldo de la aplicación. +/// +/// - [`using()`](Self::using) devuelve [`Markup`] para presentar al usuario, listo para insertarse +/// en el contenido de una plantilla (`(x.using(cx))` dentro de [`html!`](crate::html::html)). +/// Nunca devuelve `None`, por lo que una traducción no encontrada se convierte en marcado vacío. +/// Para `n()` se escapa el texto para evitar HTML no controlado. Para valores de atributo, usa +/// siempre [`lookup()`](Self::lookup). +/// /// # Ejemplo /// /// Los argumentos dinámicos se añaden con `with_arg()` o `with_args()`. @@ -82,8 +103,8 @@ impl L10n { } } - /// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de - /// traducciones predefinidas. + /// **l** = *“library”*. Crea una instancia con una clave del conjunto de traducciones + /// predefinidas propias de PageTop. pub fn l(key: impl Into) -> Self { Self { op: L10nOp::Translate(key.into()), @@ -91,8 +112,8 @@ impl L10n { } } - /// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de - /// traducciones específico. + /// **t** = *“translate”*. Crea una instancia con una clave de un conjunto de traducciones + /// específico. pub fn t(key: impl Into, locales: &'static Locales) -> Self { Self { op: L10nOp::Translate(key.into()), @@ -140,6 +161,11 @@ impl L10n { /// /// Devuelve `None` si no aplica o no encuentra una traducción válida. /// + /// Es la opción adecuada para cualquier valor de atributo (`attr=[...]` en + /// [`html!`](crate::html::html)). Para insertar el resultado directamente en el contenido de + /// una plantilla, utiliza [`using()`](Self::using) en su lugar, que ya devuelve [`Markup`] con + /// el escapado adecuado y nunca `None`. + /// /// # Ejemplo /// /// ```rust,no_run @@ -176,7 +202,18 @@ impl L10n { /// 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. + /// Devuelve un marcado vacío si no aplica o no encuentra una traducción válida. + /// + /// Las claves de traducción (`l()`/`t()`) son texto de confianza, con traducciones en ficheros + /// `.ftl` que pueden incluir marcado HTML (p. ej. ``, enlaces, etc.) que se insertan + /// tal cual. El texto literal (`n()`), en cambio, suele venir de datos en tiempo de ejecución + /// que no debe interpretarse como marcado, por lo que se escapa igual que cualquier otro valor + /// interpolado con [`html!`](crate::html::html). + /// + /// No debe usarse para un valor de atributo (`attr=[...]` en [`html!`](crate::html::html)): el + /// marcado de confianza que se aplica en `l()`/`t()` no tiene sentido ahí, y podría romper el + /// delimitador del atributo si la traducción contuviera una comilla. Para atributos, usa + /// siempre [`lookup()`](Self::lookup) en su lugar. /// /// # Ejemplo /// @@ -185,6 +222,9 @@ impl L10n { /// let html = L10n::l("welcome.message").using(&Locale::resolve("es")); /// ``` pub fn using(&self, language: &impl LangId) -> Markup { - PreEscaped(self.lookup(language).unwrap_or_default()) + match &self.op { + L10nOp::Text(text) => text.render(), + _ => PreEscaped(self.lookup(language).unwrap_or_default()), + } } } diff --git a/tests/locale.rs b/tests/locale.rs index 0bf87868..8cafb3b0 100644 --- a/tests/locale.rs +++ b/tests/locale.rs @@ -60,3 +60,28 @@ async fn check_unknown_key() { let translation = l10n.lookup(&Locale::resolve("en-US")); assert_eq!(translation, None); } + +// `using()` renders literal text (`L10n::n()`) as HTML-escaped `Markup`, because it may come from +// runtime data (e.g. a menu title or a role label) rather than developer-authored content. +#[pagetop::test] +async fn literal_text_is_escaped_when_rendered_as_markup() { + setup().await; + + let l10n = L10n::n(""); + let markup = l10n.using(&Locale::default()); + assert_eq!( + markup.into_string(), + "<script>alert(1)</script>" + ); +} + +// Translation keys (`L10n::l()`/`L10n::t()`) are developer-authored `.ftl` content that may embed +// HTML on purpose (e.g. ``), so `using()` must keep rendering them unescaped. +#[pagetop::test] +async fn translated_text_is_not_escaped_when_rendered_as_markup() { + setup().await; + + let l10n = L10n::l("test_hello_world"); + let markup = l10n.using(&Locale::resolve("en-US")); + assert_eq!(markup.into_string(), "Hello world!"); +}