🐛 (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:
parent
6bea4a5793
commit
3198b74399
13 changed files with 96 additions and 26 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
|
|||
@if *field.required() {
|
||||
span
|
||||
class="form-required"
|
||||
title=(L10n::l("field_required").using(cx))
|
||||
title=[L10n::l("field_required").lookup(cx)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
|
|||
@if *field.required() {
|
||||
span
|
||||
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) => {
|
||||
optgroup
|
||||
label=(group.label().using(cx))
|
||||
label=[group.label().lookup(cx)]
|
||||
disabled[*group.disabled()]
|
||||
{
|
||||
@for opt in group.items() {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ pub(crate) fn render(field: &Textarea, cx: &mut Context) -> Result<Markup, Compo
|
|||
@if *field.required() {
|
||||
span
|
||||
class="form-required"
|
||||
title=(L10n::l("field_required").using(cx))
|
||||
title=[L10n::l("field_required").lookup(cx)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ use crate::prelude::*;
|
|||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// let badge = Badge::new().with_label(L10n::n("Admin"));
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let badge = Badge::labeled(L10n::n("Admin"));
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct Badge {
|
||||
|
|
@ -41,6 +40,14 @@ impl Component for Badge {
|
|||
}
|
||||
|
||||
impl Badge {
|
||||
/// Crea un badge a partir de la etiqueta indicada.
|
||||
pub fn labeled(label: L10n) -> 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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
"*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
///
|
||||
/// 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<CowStr>) -> 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<CowStr>, 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. `<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
|
||||
///
|
||||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("<script>alert(1)</script>");
|
||||
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. `<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!");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue