🐛 (locale): Corrige escapado de L10n::using()

`L10n::using()` insertaba el texto de `L10n::n()` sin escapar,
tratándolo igual que una traducción de confianza (`l()`/`t()`); ahora se
escapa como cualquier otro valor interpolado con `html!`.

Además, `using()` no debe usarse en valores de atributo. Su marcado de
confianza para `l()`/`t()` podría romper el delimitador si la traducción
contuviera una comilla. Se sustituye por `lookup()`
This commit is contained in:
Manuel Cillero 2026-08-12 14:28:47 +02:00
parent 6bea4a5793
commit 3198b74399
13 changed files with 96 additions and 26 deletions

View file

@ -15,9 +15,7 @@ const EXTRA_TEXT_BG: &str = "bootsier.badge.text_bg";
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*; /// use pagetop_bootsier::theme::*;
/// ///
/// let admin = bs::Badge::new() /// let admin = bs::Badge::labeled(L10n::n("Admin")).with_text_bg(ThemeColor::Danger);
/// .with_label(L10n::n("Admin"))
/// .with_text_bg(ThemeColor::Danger);
/// ``` /// ```
pub trait BadgeBootsier { pub trait BadgeBootsier {
#[builder_fn] #[builder_fn]

View file

@ -83,7 +83,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
@if *field.required() { @if *field.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -70,7 +70,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
@if *field.required() { @if *field.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }
@ -106,7 +106,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
} }
form::select::Entry::Group(group) => { form::select::Entry::Group(group) => {
optgroup optgroup
label=(group.label().using(cx)) label=[group.label().lookup(cx)]
disabled[*group.disabled()] disabled[*group.disabled()]
{ {
@for opt in group.items() { @for opt in group.items() {

View file

@ -76,7 +76,7 @@ pub(crate) fn render(field: &Textarea, cx: &mut Context) -> Result<Markup, Compo
@if *field.required() { @if *field.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -5,9 +5,8 @@ use crate::prelude::*;
/// # Ejemplo /// # Ejemplo
/// ///
/// ```rust,no_run /// ```rust,no_run
/// use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// /// let badge = Badge::labeled(L10n::n("Admin"));
/// let badge = Badge::new().with_label(L10n::n("Admin"));
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Badge { pub struct Badge {
@ -41,6 +40,14 @@ impl Component for Badge {
} }
impl Badge { impl Badge {
/// Crea un badge a partir de la etiqueta indicada.
pub fn labeled(label: L10n) -> Self {
Self {
label,
..Default::default()
}
}
// **< Badge BUILDER >************************************************************************** // **< Badge BUILDER >**************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
@ -50,7 +57,7 @@ impl Badge {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -123,7 +123,7 @@ impl Component for Checkbox {
@if *self.required() { @if *self.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -238,7 +238,7 @@ impl Component for Field {
@if *self.required() { @if *self.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -89,7 +89,7 @@ impl Component for Number {
@if *self.required() { @if *self.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -150,7 +150,7 @@ impl Component for Field {
@if *self.required() { @if *self.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -246,7 +246,7 @@ impl Component for Field {
@if *self.required() { @if *self.required() {
span span
class="form-required" 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) => { Entry::Group(group) => {
optgroup optgroup
label=(group.label().using(cx)) label=[group.label().lookup(cx)]
disabled[*group.disabled()] disabled[*group.disabled()]
{ {
@for opt in group.items() { @for opt in group.items() {

View file

@ -99,7 +99,7 @@ impl Component for Textarea {
@if *self.required() { @if *self.required() {
span span
class="form-required" class="form-required"
title=(L10n::l("field_required").using(cx)) title=[L10n::l("field_required").lookup(cx)]
{ {
"*" "*"
} }

View file

@ -1,4 +1,4 @@
use crate::html::{Markup, PreEscaped}; use crate::html::{Markup, PreEscaped, Render};
use crate::{AutoDefault, CowStr, include_locales}; use crate::{AutoDefault, CowStr, include_locales};
use super::{LangId, Locale}; use super::{LangId, Locale};
@ -30,9 +30,30 @@ enum L10nOp {
/// Cada instancia puede representar: /// Cada instancia puede representar:
/// ///
/// - Un texto puro (`n()`) que no requiere traducción. /// - 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()`). /// - 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<String>`: 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 /// # Ejemplo
/// ///
/// Los argumentos dinámicos se añaden con `with_arg()` o `with_args()`. /// 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 /// **l** = *“library”*. Crea una instancia con una clave del conjunto de traducciones
/// traducciones predefinidas. /// predefinidas propias de PageTop.
pub fn l(key: impl Into<CowStr>) -> Self { pub fn l(key: impl Into<CowStr>) -> Self {
Self { Self {
op: L10nOp::Translate(key.into()), 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 /// **t** = *“translate”*. Crea una instancia con una clave de un conjunto de traducciones
/// traducciones específico. /// específico.
pub fn t(key: impl Into<CowStr>, locales: &'static Locales) -> Self { pub fn t(key: impl Into<CowStr>, locales: &'static Locales) -> Self {
Self { Self {
op: L10nOp::Translate(key.into()), op: L10nOp::Translate(key.into()),
@ -140,6 +161,11 @@ impl L10n {
/// ///
/// Devuelve `None` si no aplica o no encuentra una traducción válida. /// 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 /// # Ejemplo
/// ///
/// ```rust,no_run /// ```rust,no_run
@ -176,7 +202,18 @@ impl L10n {
/// Traduce el texto y lo devuelve como [`Markup`] usando la fuente de idioma proporcionada. /// 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. `<strong>`, 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 /// # Ejemplo
/// ///
@ -185,6 +222,9 @@ impl L10n {
/// let html = L10n::l("welcome.message").using(&Locale::resolve("es")); /// let html = L10n::l("welcome.message").using(&Locale::resolve("es"));
/// ``` /// ```
pub fn using(&self, language: &impl LangId) -> Markup { 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()),
}
} }
} }

View file

@ -60,3 +60,28 @@ async fn check_unknown_key() {
let translation = l10n.lookup(&Locale::resolve("en-US")); let translation = l10n.lookup(&Locale::resolve("en-US"));
assert_eq!(translation, None); 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("<script>alert(1)</script>");
let markup = l10n.using(&Locale::default());
assert_eq!(
markup.into_string(),
"&lt;script&gt;alert(1)&lt;/script&gt;"
);
}
// Translation keys (`L10n::l()`/`L10n::t()`) are developer-authored `.ftl` content that may embed
// HTML on purpose (e.g. `<strong>`), 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!");
}