Compare commits
No commits in common. "f2a7507317dfd2127e9cbdced7e7ef9564bc6dec" and "5939d33964e666b491bdeb41224a923fee0bbe0d" have entirely different histories.
f2a7507317
...
5939d33964
14 changed files with 68 additions and 830 deletions
|
|
@ -96,7 +96,7 @@ impl Autocomplete {
|
|||
/// El prefijo `section-*` sirve para distinguir entre varios grupos del mismo tipo en una misma
|
||||
/// página (p. ej. una dirección de envío y otra de facturación).
|
||||
pub fn section(name: impl AsRef<str>, field: AutofillField) -> Self {
|
||||
match util::normalize_ascii_non_blank(name.as_ref()) {
|
||||
match util::normalize_ascii(name.as_ref()) {
|
||||
Ok(n) if !n.as_ref().contains(' ') => {
|
||||
Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str()))
|
||||
}
|
||||
|
|
@ -196,7 +196,7 @@ impl Autocomplete {
|
|||
let raw = value.as_ref();
|
||||
|
||||
// Normaliza la entrada.
|
||||
let Some(normalized) = util::normalize_ascii(raw) else {
|
||||
let Some(normalized) = util::normalize_ascii_or_empty(raw, "Autocomplete::custom") else {
|
||||
return Self::On;
|
||||
};
|
||||
let autocomplete = normalized.as_ref();
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ use crate::auth::CurrentUser;
|
|||
use crate::core::TypeInfo;
|
||||
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
||||
use crate::core::theme::all::DEFAULT_THEME;
|
||||
use crate::core::theme::{Breakpoint, ChildrenInRegions, CoreRegions, CoreTemplates};
|
||||
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates};
|
||||
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet};
|
||||
use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet};
|
||||
use crate::html::{Markup, Props, PropsOp, RoutePath, html};
|
||||
use crate::locale::Lc;
|
||||
use crate::locale::{LangId, LanguageIdentifier, RequestLocale};
|
||||
|
|
@ -38,11 +38,6 @@ pub enum AssetsOp {
|
|||
AddJavaScript(JavaScript),
|
||||
/// Elimina un script por su ruta o identificador.
|
||||
RemoveJavaScript(&'static str),
|
||||
|
||||
/// Añade una declaración de estilo responsive (`property: value`) para las clases indicadas,
|
||||
/// dentro del punto de corte dado (`None` para una regla siempre activa). Ver
|
||||
/// [`ResponsiveStyles::add_style()`].
|
||||
AddResponsiveStyle(Option<Breakpoint>, &'static str, &'static str, &'static str),
|
||||
}
|
||||
|
||||
/// Errores de acceso a parámetros dinámicos del contexto.
|
||||
|
|
@ -222,9 +217,6 @@ pub trait Contextual: LangId {
|
|||
/// Devuelve los scripts JavaScript de los recursos del contexto.
|
||||
fn javascripts(&self) -> &Assets<JavaScript>;
|
||||
|
||||
/// Devuelve los estilos *responsive* acumulados en el contexto.
|
||||
fn responsive_styles(&self) -> &ResponsiveStyles;
|
||||
|
||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del elemento `<body>`.
|
||||
fn body_props(&self) -> &Props;
|
||||
|
||||
|
|
@ -321,7 +313,6 @@ pub struct Context {
|
|||
preloads : Assets<Preload>, // Recursos para precarga.
|
||||
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
|
||||
javascripts : Assets<JavaScript>, // Scripts JavaScript.
|
||||
responsives : ResponsiveStyles, // Estilos *responsive*.
|
||||
body_props : Props, // Id, clases CSS y atributos del <body>.
|
||||
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
|
||||
params : HashMap<&'static str, (Box<dyn Any + Send + Sync>, &'static str)>, // Parámetros.
|
||||
|
|
@ -354,7 +345,6 @@ impl Context {
|
|||
preloads : Assets::<Preload>::new(),
|
||||
stylesheets: Assets::<StyleSheet>::new(),
|
||||
javascripts: Assets::<JavaScript>::new(),
|
||||
responsives: ResponsiveStyles::new(),
|
||||
body_props : Props::default(),
|
||||
regions : ChildrenInRegions::default(),
|
||||
params : HashMap::default(),
|
||||
|
|
@ -411,10 +401,6 @@ impl Context {
|
|||
// Primero los recursos para precarga para iniciar las descargas inmediatamente.
|
||||
(preloads.render(self))
|
||||
(stylesheets.render(self))
|
||||
// Después los estilos *responsive*, para poder sobrescribir sus clases.
|
||||
@if !self.responsives.is_empty() {
|
||||
style { (self.responsives.render(self)) }
|
||||
}
|
||||
(javascripts.render(self))
|
||||
};
|
||||
|
||||
|
|
@ -618,11 +604,6 @@ impl Contextual for Context {
|
|||
AssetsOp::RemoveJavaScript(path) => {
|
||||
self.javascripts.remove(path);
|
||||
}
|
||||
// Estilos responsive.
|
||||
AssetsOp::AddResponsiveStyle(breakpoint, classes, property, value) => {
|
||||
self.responsives
|
||||
.add_style(breakpoint, classes, property, value);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
|
@ -683,10 +664,6 @@ impl Contextual for Context {
|
|||
&self.javascripts
|
||||
}
|
||||
|
||||
fn responsive_styles(&self) -> &ResponsiveStyles {
|
||||
&self.responsives
|
||||
}
|
||||
|
||||
fn body_props(&self) -> &Props {
|
||||
&self.body_props
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@
|
|||
//! devuelva `Some(&Self)`. Basta con un `impl Theme for MyTheme {}` vacío, ya que todos los
|
||||
//! métodos de [`Theme`] tienen implementación por defecto.
|
||||
//!
|
||||
//! Un tema puede personalizarse en seis pasos, cada uno necesario sólo si lo que ofrece PageTop
|
||||
//! por defecto no basta o no aplica:
|
||||
//! Un tema puede personalizarse en cinco pasos, cada uno necesario sólo si lo que ofrece PageTop
|
||||
//! por defecto no basta:
|
||||
//!
|
||||
//! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegions`] (`Header`, `Aside`,
|
||||
//! `Content`, `Footer`) como regiones de plantilla siempre disponibles, y [`ReservedRegions`]
|
||||
|
|
@ -77,22 +77,14 @@
|
|||
//! ejemplo, [`CoreTemplates`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente
|
||||
//! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias
|
||||
//! variantes de plantilla.
|
||||
//! 4. **Definir los anchos mínimos *mobile-first* para los puntos de corte** sobrescribiendo
|
||||
//! [`Theme::breakpoint_min_width()`]. Por defecto, [`Breakpoint`] resuelve el ancho mínimo de
|
||||
//! cada variante (`Sm`, `Md`, etc.) como una cadena CSS ya formateada (p. ej. `"768px"`) que
|
||||
//! cada tema puede adaptar. Cuando se genera CSS *responsive* a partir de un [`Breakpoint`], se
|
||||
//! consulta el punto de corte a través de [`Breakpoint::min_width()`], listo para interpolar en
|
||||
//! un `@media (min-width: ...)` sin ningún cálculo adicional. Un tema sin diseño *responsive*
|
||||
//! puede traducir todas las variantes a `""` porque al ser *mobile-first*, un punto de corte sin
|
||||
//! ancho real se aplicará siempre.
|
||||
//! 5. **Traducir [`Intent`] a la paleta de colores propia del tema** sobrescribiendo
|
||||
//! 4. **Traducir [`Intent`] a la paleta de colores propia del tema** sobrescribiendo
|
||||
//! [`Theme::intent_color()`]. Por defecto, este método devuelve el vocabulario semántico de
|
||||
//! [`Intent`] (`"primary"`, `"severe"`, etc.); un tema con su propio catálogo de colores (por
|
||||
//! ejemplo, uno basado en Bootstrap) debe traducir cada variante al nombre que le corresponda en
|
||||
//! su paleta. Los componentes que generan clases CSS a partir de una [`Intent`] (`Button`,
|
||||
//! `Badge`, `Dropdown`, etc.) consultan este método a través de [`Intent::color()`], así que la
|
||||
//! clase resultante ya nace en la paleta del tema activo.
|
||||
//! 6. **Reexportar, extender o añadir componentes**. Un tema puede reexportar tal cual los
|
||||
//! 5. **Reexportar, extender o añadir componentes**. Un tema puede reexportar tal cual los
|
||||
//! componentes propios de PageTop que no requieran adaptación, extenderlos con un trait propio
|
||||
//! para añadir métodos exclusivos (guardando su estado en valores extra con
|
||||
//! [`PropsOp::set_extra()`](crate::html::PropsOp::set_extra) para consumirlos en el
|
||||
|
|
@ -145,9 +137,6 @@
|
|||
mod intent;
|
||||
pub use intent::Intent;
|
||||
|
||||
mod breakpoint;
|
||||
pub use breakpoint::Breakpoint;
|
||||
|
||||
mod layout;
|
||||
pub use layout::{CoreRegions, RegionName, RegionRef};
|
||||
pub use layout::{CoreTemplates, TemplateName, TemplateRef};
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
use crate::AutoDefault;
|
||||
use crate::core::component::{Context, Contextual};
|
||||
|
||||
// **< Breakpoint >*********************************************************************************
|
||||
|
||||
/// Puntos de corte *responsive*, *mobile-first* (aplican "a partir de" el ancho indicado).
|
||||
///
|
||||
/// No define ningún valor en píxeles por sí mismo; cada tema decide a qué ancho corresponde cada
|
||||
/// variante en su sistema de diseño (ver [`Theme::breakpoint_min_width()`]).
|
||||
///
|
||||
/// [`Theme::breakpoint_min_width()`]: crate::core::theme::Theme::breakpoint_min_width
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub enum Breakpoint {
|
||||
/// Base *mobile-first*, equivale a "siempre".
|
||||
#[default]
|
||||
Xs,
|
||||
/// A partir del ancho donde un tema suele pasar de móvil a tableta.
|
||||
Sm,
|
||||
/// A partir del ancho donde un tema suele pasar a un escritorio pequeño.
|
||||
Md,
|
||||
/// A partir del ancho donde un tema suele pasar a un escritorio normal.
|
||||
Lg,
|
||||
/// A partir del ancho donde un tema suele considerar el escritorio ancho.
|
||||
Xl,
|
||||
/// A partir del ancho donde un tema suele considerar el escritorio muy ancho.
|
||||
Xxl,
|
||||
}
|
||||
|
||||
impl Breakpoint {
|
||||
// Todas las variantes, en orden mobile-first (de Xs a Xxl).
|
||||
pub(crate) const ALL: [Breakpoint; 6] = [
|
||||
Breakpoint::Xs,
|
||||
Breakpoint::Sm,
|
||||
Breakpoint::Md,
|
||||
Breakpoint::Lg,
|
||||
Breakpoint::Xl,
|
||||
Breakpoint::Xxl,
|
||||
];
|
||||
|
||||
/// Ancho mínimo resuelto a través del tema activo del contexto actual, como valor CSS ya
|
||||
/// formateado (p. ej. `"768px"`), o `""` si la variante se aplica siempre, sin ancho real.
|
||||
///
|
||||
/// Atajo de [`Theme::breakpoint_min_width()`](crate::core::theme::Theme::breakpoint_min_width)
|
||||
/// a través de [`Context::theme()`].
|
||||
pub fn min_width(&self, cx: &Context) -> &'static str {
|
||||
cx.theme().breakpoint_min_width(*self)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use crate::base::component::{Html, Intro, IntroOpening, layout};
|
|||
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
|
||||
use crate::core::component::{Context, Contextual};
|
||||
use crate::core::extension::Extension;
|
||||
use crate::core::theme::{Breakpoint, CoreRegions, Intent};
|
||||
use crate::core::theme::{CoreRegions, Intent};
|
||||
use crate::global;
|
||||
use crate::html::{Markup, html};
|
||||
use crate::locale::Lc;
|
||||
|
|
@ -65,49 +65,19 @@ pub trait Theme: Extension + Send + Sync {
|
|||
None
|
||||
}
|
||||
|
||||
/// Traduce un [`Breakpoint`] al punto de corte *responsive*, *mobile-first*, propio del tema.
|
||||
///
|
||||
/// `Breakpoint` no define ningún valor propio en píxeles. Será cada tema el que decida a qué
|
||||
/// ancho corresponde cada variante como valor CSS ya formateado (p. ej. `"768px"`), listo para
|
||||
/// aplicar en un `@media (min-width: ...)` sin ningún cálculo adicional. La cadena vacía (`""`)
|
||||
/// indica que la variante no representa ningún ancho mínimo y se aplica siempre; es el caso de
|
||||
/// `Xs`.
|
||||
///
|
||||
/// Normalmente, para resolver un ancho *responsive* no se llamará a este método directamente,
|
||||
/// sino que se usará [`Breakpoint::min_width()`] a través de [`Context::theme()`].
|
||||
///
|
||||
/// [`Breakpoint::min_width()`]: crate::core::theme::Breakpoint::min_width
|
||||
/// [`Context::theme()`]: crate::core::component::Context::theme
|
||||
#[rustfmt::skip]
|
||||
fn breakpoint_min_width(&self, bp: Breakpoint) -> &'static str {
|
||||
if let Some(parent) = self.parent() {
|
||||
return parent.breakpoint_min_width(bp);
|
||||
}
|
||||
match bp {
|
||||
Breakpoint::Xs => "",
|
||||
Breakpoint::Sm => "576px",
|
||||
Breakpoint::Md => "768px",
|
||||
Breakpoint::Lg => "992px",
|
||||
Breakpoint::Xl => "1200px",
|
||||
Breakpoint::Xxl => "1400px",
|
||||
}
|
||||
}
|
||||
|
||||
/// Traduce una [`Intent`] al nombre de color de la paleta propia del tema.
|
||||
///
|
||||
/// `Intent` no define ninguna cadena propia. Cada tema decide qué nombre le corresponde a cada
|
||||
/// variante en su paleta (p. ej. un tema basado en Bootstrap traduce `Severe` a `"danger"`).
|
||||
/// Los componentes que generan clases CSS a partir de una `Intent` (`Button`, `Badge`,
|
||||
/// `Dropdown`, etc.) no llaman a este método directamente en su `setup()`, usan mejor
|
||||
/// [`Intent::color()`] como la forma más sencilla de obtener este mismo valor a través de
|
||||
/// [`Context::theme()`], para que la clase resultante ya nazca en la paleta del tema activo.
|
||||
/// [`Intent::color()`](crate::core::theme::Intent::color) como la forma más sencilla de obtener
|
||||
/// este mismo valor a través de [`Context::theme()`](crate::core::component::Context::theme),
|
||||
/// para que la clase resultante ya nazca en la paleta del tema activo.
|
||||
///
|
||||
/// La implementación por defecto devuelve el vocabulario semántico propio de PageTop
|
||||
/// (`"primary"`, `"severe"`, etc.), que actúa como paleta base cuando ningún tema la
|
||||
/// sobrescribe.
|
||||
///
|
||||
/// [`Intent::color()`]: crate::core::theme::Intent::color
|
||||
/// [`Context::theme()`]: crate::core::component::Context::theme
|
||||
#[rustfmt::skip]
|
||||
fn intent_color(&self, intent: Intent) -> &'static str {
|
||||
if let Some(parent) = self.parent() {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ mod assets;
|
|||
pub use assets::favicon::Favicon;
|
||||
pub use assets::javascript::JavaScript;
|
||||
pub use assets::preload::Preload;
|
||||
pub use assets::responsive::ResponsiveStyles;
|
||||
pub use assets::stylesheet::{StyleSheet, TargetMedia};
|
||||
pub use assets::{Asset, Assets};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
pub mod favicon;
|
||||
pub mod javascript;
|
||||
pub mod preload;
|
||||
pub mod responsive;
|
||||
pub mod stylesheet;
|
||||
|
||||
use crate::core::component::Context;
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
use crate::core::component::Context;
|
||||
use crate::core::theme::Breakpoint;
|
||||
use crate::html::{Markup, PreEscaped, html};
|
||||
use crate::{AutoDefault, CowStr, util};
|
||||
|
||||
// Punto de corte (None si no aplica), clase(s) normalizadas y sus declaraciones `propiedad: valor`.
|
||||
type Entry = (Option<Breakpoint>, CowStr, Vec<(CowStr, CowStr)>);
|
||||
|
||||
/// Declaraciones de estilo en línea agrupadas por punto de corte ([`Breakpoint`]).
|
||||
///
|
||||
/// También por clase (o clases) que van a renderizarse en bloques `@media` en el `<head>` del
|
||||
/// documento.
|
||||
///
|
||||
/// Por tanto, cada entrada se identifica por la pareja (punto de corte, clases) y acumula sus
|
||||
/// propias declaraciones `propiedad: valor`, en el orden en que se añaden. Las clases se guardan en
|
||||
/// bruto, normalizadas igual que hace [`Props`](crate::html::Props) (ASCII, minúsculas y un único
|
||||
/// espacio entre tokens): por ejemplo `"foo bar"`, no `.foo.bar`.
|
||||
///
|
||||
/// El punto de corte es opcional, donde `None` declara una regla siempre activa, sin pasar por el
|
||||
/// tema ni depender de que resuelva algún [`Breakpoint`]. No ordena ni combina las clases de dos
|
||||
/// llamadas que las declaren en distinto orden (por ejemplo, `"foo bar"` y `"bar foo"` generan dos
|
||||
/// entradas distintas). La llamada es a través de [`AssetsOp::AddResponsiveStyle`].
|
||||
///
|
||||
/// [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct ResponsiveStyles(Vec<Entry>);
|
||||
|
||||
impl ResponsiveStyles {
|
||||
/// Crea un conjunto vacío.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Añade una declaración de estilo (`property: value`) para las clases indicadas, dentro del
|
||||
/// punto de corte dado.
|
||||
///
|
||||
/// Si ya existe una declaración para la misma propiedad, en el mismo punto de corte y con las
|
||||
/// mismas clases, la llamada no hace nada: se conserva el valor ya almacenado, no se sustituye.
|
||||
/// Pensado para clases utilitarias generadas automáticamente, donde el mismo nombre de clase
|
||||
/// implica siempre el mismo valor -- declararla de nuevo es entonces una operación de sólo
|
||||
/// lectura, sin normalizar `value`, en vez de una escritura.
|
||||
///
|
||||
/// Si `classes` contiene caracteres no ASCII, o si `classes`, `property` o `value` quedan
|
||||
/// vacíos tras recortar espacios, la operación se ignora.
|
||||
pub fn add_style(
|
||||
&mut self,
|
||||
breakpoint: impl Into<Option<Breakpoint>>,
|
||||
classes: impl AsRef<str>,
|
||||
property: impl AsRef<str>,
|
||||
value: impl AsRef<str>,
|
||||
) {
|
||||
let breakpoint = breakpoint.into();
|
||||
|
||||
let Some(classes) = util::normalize_ascii(classes.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
if classes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let classes: CowStr = classes.into_owned().into();
|
||||
|
||||
let property_norm = property.as_ref().trim().to_ascii_lowercase();
|
||||
if property_norm.is_empty() {
|
||||
return;
|
||||
}
|
||||
let property: CowStr = property_norm.into();
|
||||
|
||||
match self
|
||||
.0
|
||||
.iter_mut()
|
||||
.find(|(bp, cls, _)| *bp == breakpoint && *cls == classes)
|
||||
{
|
||||
Some((_, _, styles)) => {
|
||||
// Ya declarada: se descarta sin normalizar `value`, el camino habitual (y que debe
|
||||
// ser barato) cuando muchos componentes comparten la misma clase utilitaria.
|
||||
if styles.iter().any(|(k, _)| *k == property) {
|
||||
return;
|
||||
}
|
||||
let Some(value) = util::non_blank(value.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
styles.push((property, value.to_string().into()));
|
||||
}
|
||||
None => {
|
||||
let Some(value) = util::non_blank(value.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
self.0.push((
|
||||
breakpoint,
|
||||
classes,
|
||||
vec![(property, value.to_string().into())],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< ResponsiveStyles GETTERS >***************************************************************
|
||||
|
||||
/// Devuelve el valor de la propiedad indicada para el punto de corte y las clases dados, si
|
||||
/// existe.
|
||||
pub fn get_style(
|
||||
&self,
|
||||
breakpoint: impl Into<Option<Breakpoint>>,
|
||||
classes: impl AsRef<str>,
|
||||
property: impl AsRef<str>,
|
||||
) -> Option<String> {
|
||||
let styles = self.entry(breakpoint.into(), classes.as_ref())?;
|
||||
let property = property.as_ref().trim().to_ascii_lowercase();
|
||||
styles
|
||||
.iter()
|
||||
.find(|(k, _)| k.as_ref() == property)
|
||||
.map(|(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
/// Devuelve todas las declaraciones de estilo del punto de corte y las clases dados, como
|
||||
/// cadena de texto (separadas por `"; "`), si existen.
|
||||
pub fn get_styles(
|
||||
&self,
|
||||
breakpoint: impl Into<Option<Breakpoint>>,
|
||||
classes: impl AsRef<str>,
|
||||
) -> Option<String> {
|
||||
let styles = self.entry(breakpoint.into(), classes.as_ref())?;
|
||||
if styles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
styles
|
||||
.iter()
|
||||
.map(|(k, v)| util::join!(k.as_ref(), ": ", v.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
)
|
||||
}
|
||||
|
||||
/// Devuelve `true` si no hay ninguna declaración almacenada.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
// **< ResponsiveStyles RENDER >****************************************************************
|
||||
|
||||
/// Renderiza las declaraciones acumuladas como texto CSS.
|
||||
///
|
||||
/// Emite primero las declaraciones sin punto de corte (`None`), siempre sin envoltorio. Luego
|
||||
/// recorre los puntos de corte reales en orden *mobile-first* (`Xs` a `Xxl`, omitiendo los que
|
||||
/// no tengan ninguna declaración) y, para cada uno, agrupa las reglas de todas sus entradas
|
||||
/// (`.clases { propiedad: valor; ... }`). Si el ancho mínimo resuelto por el tema activo
|
||||
/// ([`Breakpoint::min_width()`]) es una cadena vacía, las reglas se emiten tal cual, sin punto
|
||||
/// de corte real; en cualquier otro caso se envuelven en `@media (min-width: ...)`.
|
||||
///
|
||||
/// El resultado no contiene saltos de línea.
|
||||
pub fn render(&self, cx: &Context) -> Markup {
|
||||
let mut css = String::new();
|
||||
|
||||
css.push_str(&self.render_rules(None));
|
||||
|
||||
for breakpoint in Breakpoint::ALL {
|
||||
let rules = self.render_rules(Some(breakpoint));
|
||||
if rules.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let min_width = breakpoint.min_width(cx);
|
||||
if min_width.is_empty() {
|
||||
css.push_str(&rules);
|
||||
} else {
|
||||
css.push_str(&util::join!(
|
||||
"@media (min-width: ",
|
||||
min_width,
|
||||
") { ",
|
||||
rules,
|
||||
" }"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
html! { (PreEscaped(css)) }
|
||||
}
|
||||
|
||||
// Construye, concatenadas y sin separador, las reglas CSS (`.clases { propiedad: valor; ... }`)
|
||||
// de todas las entradas del punto de corte indicado.
|
||||
fn render_rules(&self, breakpoint: Option<Breakpoint>) -> String {
|
||||
let mut rules = String::new();
|
||||
for (_, classes, styles) in self
|
||||
.0
|
||||
.iter()
|
||||
.filter(|(bp, _, styles)| *bp == breakpoint && !styles.is_empty())
|
||||
{
|
||||
let selector = classes.replace(' ', ".");
|
||||
let declarations = styles
|
||||
.iter()
|
||||
.map(|(property, value)| util::join!(property.as_ref(), ": ", value.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
rules.push_str(&util::join!(".", selector, " { ", declarations, " }"));
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
// Normaliza `classes` igual que `add_style()` y busca las declaraciones de la entrada
|
||||
// correspondiente al punto de corte y las clases dados.
|
||||
fn entry(
|
||||
&self,
|
||||
breakpoint: Option<Breakpoint>,
|
||||
classes: &str,
|
||||
) -> Option<&Vec<(CowStr, CowStr)>> {
|
||||
let classes = util::normalize_ascii(classes)?;
|
||||
if classes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(bp, cls, _)| *bp == breakpoint && cls.as_ref() == classes.as_ref())
|
||||
.map(|(_, _, styles)| styles)
|
||||
}
|
||||
}
|
||||
|
|
@ -539,23 +539,29 @@ impl Props {
|
|||
}
|
||||
}
|
||||
PropsOp::AddClasses(classes) => {
|
||||
let Some(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||
let Some(normalized) =
|
||||
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
let pos = self.classes.len();
|
||||
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos);
|
||||
}
|
||||
PropsOp::PrependClasses(classes) => {
|
||||
let Some(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||
let Some(normalized) =
|
||||
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
|
||||
}
|
||||
PropsOp::ReplaceClasses(old, new) => {
|
||||
let Some(old) = util::normalize_ascii(old.as_ref()) else {
|
||||
let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
let Some(new) = util::normalize_ascii(new.as_ref()) else {
|
||||
let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
let mut pos = self.classes.len();
|
||||
|
|
@ -572,10 +578,12 @@ impl Props {
|
|||
}
|
||||
}
|
||||
PropsOp::ReplaceAllClasses(old, new) => {
|
||||
let Some(old) = util::normalize_ascii(old.as_ref()) else {
|
||||
let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
let Some(new) = util::normalize_ascii(new.as_ref()) else {
|
||||
let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
if !self.has_all_classes(old.as_ref()) {
|
||||
|
|
@ -591,7 +599,9 @@ impl Props {
|
|||
self.insert_classes(new.as_ref().split_ascii_whitespace(), pos);
|
||||
}
|
||||
PropsOp::RemoveClasses(classes) => {
|
||||
let Some(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||
let Some(normalized) =
|
||||
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
|
||||
else {
|
||||
return self;
|
||||
};
|
||||
self.classes.retain(|c| {
|
||||
|
|
@ -611,7 +621,9 @@ impl Props {
|
|||
if name.as_ref() == "id" {
|
||||
self.apply_id(value.as_ref());
|
||||
} else if name.as_ref() == "class" {
|
||||
if let Some(normalized) = util::normalize_ascii(value.as_ref()) {
|
||||
if let Some(normalized) =
|
||||
util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop")
|
||||
{
|
||||
self.classes.clear();
|
||||
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
|
||||
}
|
||||
|
|
@ -753,7 +765,7 @@ impl Props {
|
|||
|
||||
/// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente.
|
||||
pub fn has_classes(&self, classes: impl AsRef<str>) -> bool {
|
||||
let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else {
|
||||
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
normalized
|
||||
|
|
@ -764,7 +776,7 @@ impl Props {
|
|||
|
||||
/// Devuelve `true` si la clase o **todas** las clases indicadas están presentes.
|
||||
pub fn has_all_classes(&self, classes: impl AsRef<str>) -> bool {
|
||||
let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else {
|
||||
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
normalized
|
||||
|
|
@ -910,17 +922,15 @@ impl Props {
|
|||
|
||||
// Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye
|
||||
// su valor conservando la posición; si no, la añade al final. Ignora la declaración si la
|
||||
// propiedad o el valor quedan vacíos tras recortar espacios. No aplica `normalize_ascii`: ver
|
||||
// la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen
|
||||
// a ASCII.
|
||||
// propiedad o el valor quedan vacíos tras recortar espacios. No aplica
|
||||
// normalize_ascii_or_empty: ver la documentación de `PropsOp::AddStyle` sobre por qué los
|
||||
// valores de estilo no se restringen a ASCII.
|
||||
fn set_style(&mut self, property: &str, value: &str) {
|
||||
let Some(property) = util::non_blank(property) else {
|
||||
let property = property.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
if property.is_empty() || value.is_empty() {
|
||||
return;
|
||||
};
|
||||
let property = property.to_ascii_lowercase();
|
||||
let Some(value) = util::non_blank(value) else {
|
||||
return;
|
||||
};
|
||||
}
|
||||
if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) {
|
||||
self.styles[pos].1 = value.to_string().into();
|
||||
} else {
|
||||
|
|
@ -933,9 +943,10 @@ impl Props {
|
|||
// `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":".
|
||||
fn parse_styles(&mut self, styles: &str) {
|
||||
for style in Self::split_style_declarations(styles) {
|
||||
let Some(style) = util::non_blank(style) else {
|
||||
let style = style.trim();
|
||||
if style.is_empty() {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
let Some((property, value)) = style.split_once(':') else {
|
||||
trace::debug!(
|
||||
target = "Props::with_prop",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use crate::global;
|
||||
use crate::util;
|
||||
use crate::web::HttpRequest;
|
||||
|
||||
use super::{LangId, LanguageIdentifier, Locale};
|
||||
|
|
@ -83,7 +82,7 @@ impl RequestLocale {
|
|||
|
||||
// En este primer elemento también puede aparecer `;q=...`, así que se
|
||||
// extrae únicamente la etiqueta de idioma: "es-ES;q=0.9" -> "es-ES".
|
||||
let tag = util::non_blank(first.split(';').next()?)?;
|
||||
let tag = first.split(';').next()?.trim();
|
||||
|
||||
// TODO: Mejorar el soporte de `Accept-Language` en el futuro:
|
||||
//
|
||||
|
|
@ -93,7 +92,9 @@ impl RequestLocale {
|
|||
// - Tener en cuenta rangos de idioma (`es`, `en`, etc.) y variantes
|
||||
// regionales.
|
||||
// - Añadir tests unitarios para distintas combinaciones de cabecera.
|
||||
if let Locale::Resolved(langid) = Locale::resolve(tag) {
|
||||
if tag.is_empty() {
|
||||
None
|
||||
} else if let Locale::Resolved(langid) = Locale::resolve(tag) {
|
||||
Some(langid)
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ use crate::base::component::layout;
|
|||
use crate::core::component::{AssetsOp, ChildOp, ComponentRender};
|
||||
use crate::core::component::{Context, ContextError, Contextual};
|
||||
use crate::core::theme::{CoreRegions, RegionName, RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::html::{Assets, Favicon, JavaScript, ResponsiveStyles, StyleSheet};
|
||||
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
|
||||
use crate::html::{DOCTYPE, Markup, html};
|
||||
use crate::html::{Props, PropsOp};
|
||||
use crate::locale::{CharacterDirection, LangId, LanguageIdentifier, Lc};
|
||||
|
|
@ -348,10 +348,6 @@ impl Contextual for Page {
|
|||
self.context.javascripts()
|
||||
}
|
||||
|
||||
fn responsive_styles(&self) -> &ResponsiveStyles {
|
||||
self.context.responsive_styles()
|
||||
}
|
||||
|
||||
fn body_props(&self) -> &Props {
|
||||
self.context.body_props()
|
||||
}
|
||||
|
|
|
|||
51
src/util.rs
51
src/util.rs
|
|
@ -5,7 +5,6 @@ use crate::trace;
|
|||
use std::borrow::Cow;
|
||||
use std::env;
|
||||
use std::io;
|
||||
use std::panic::Location;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// **< MACROS INTEGRADAS >**************************************************************************
|
||||
|
|
@ -40,7 +39,7 @@ pub fn build_runtime() -> tokio::runtime::Runtime {
|
|||
tokio::runtime::Runtime::new().expect("Failed to build the Tokio runtime")
|
||||
}
|
||||
|
||||
/// Errores al normalizar una cadena ASCII con [`normalize_ascii_non_blank()`].
|
||||
/// Errores posibles al normalizar una cadena ASCII con [`normalize_ascii()`].
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum NormalizeAsciiError {
|
||||
/// La entrada está vacía (`""`).
|
||||
|
|
@ -70,10 +69,9 @@ pub fn non_blank(s: &str) -> Option<&str> {
|
|||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
/// Normaliza una cadena ASCII con uno o varios tokens, exigiendo contenido no vacío.
|
||||
/// Normaliza una cadena ASCII con uno o varios tokens separados.
|
||||
///
|
||||
/// Los *separadores* de los tokens son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`,
|
||||
/// `'\n'` o `'\r'`.
|
||||
/// Los *separadores* son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, `'\n'` o `'\r'`.
|
||||
///
|
||||
/// Reglas:
|
||||
///
|
||||
|
|
@ -87,20 +85,13 @@ pub fn non_blank(s: &str) -> Option<&str> {
|
|||
/// Intenta devolver siempre `Cow::Borrowed` para no reservar memoria, y `Cow::Owned` sólo si ha
|
||||
/// tenido que aplicar cambios para normalizar.
|
||||
///
|
||||
/// Normalmente se usará [`normalize_ascii()`], que trata una cadena en blanco como resultado
|
||||
/// válido, en vez de como un error. Esta variante es para detectar también cuándo un resultado es
|
||||
/// una cadena vacía para rechazarla de inmediato.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::util;
|
||||
/// assert_eq!(
|
||||
/// util::normalize_ascii_non_blank(" Foo\tBAR CLi\r\n").unwrap().as_ref(),
|
||||
/// "foo bar cli"
|
||||
/// );
|
||||
/// assert_eq!(util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), "foo bar cli");
|
||||
/// ```
|
||||
pub fn normalize_ascii_non_blank(input: &str) -> Result<Cow<'_, str>, NormalizeAsciiError> {
|
||||
pub fn normalize_ascii(input: &str) -> Result<Cow<'_, str>, NormalizeAsciiError> {
|
||||
let bytes = input.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return Err(NormalizeAsciiError::IsEmpty);
|
||||
|
|
@ -172,35 +163,19 @@ pub fn normalize_ascii_non_blank(input: &str) -> Result<Cow<'_, str>, NormalizeA
|
|||
Ok(Cow::Owned(output))
|
||||
}
|
||||
|
||||
/// Normaliza una cadena ASCII con uno o varios tokens, aceptando cadenas vacías.
|
||||
/// Normaliza una cadena ASCII, opcionalmente vacía, con uno o varios tokens separados.
|
||||
///
|
||||
/// - Devuelve `Some(Cow)` normalizado (ver [`normalize_ascii_non_blank()`] para las reglas exactas
|
||||
/// de recorte, colapso de espacios y minúsculas) si la entrada es ASCII válida.
|
||||
/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. Una
|
||||
/// entrada en blanco no es un error, es un resultado trivial.
|
||||
/// - Devuelve `None` **sólo** si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con
|
||||
/// la ubicación exacta del llamador (vía `#[track_caller]`) para poder localizar el origen del
|
||||
/// valor rechazado.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pagetop::util;
|
||||
/// assert_eq!(
|
||||
/// util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(),
|
||||
/// "foo bar cli"
|
||||
/// );
|
||||
/// assert_eq!(util::normalize_ascii(" ").unwrap().as_ref(), "");
|
||||
/// assert_eq!(util::normalize_ascii("ñoño"), None);
|
||||
/// ```
|
||||
/// - Devuelve `Some(Cow)` si la entrada es válida ASCII (normalizada a minúsculas).
|
||||
/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar.
|
||||
/// - Devuelve `None` si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con el campo
|
||||
/// `target`.
|
||||
#[inline]
|
||||
#[track_caller]
|
||||
pub fn normalize_ascii(input: &str) -> Option<Cow<'_, str>> {
|
||||
match normalize_ascii_non_blank(input) {
|
||||
pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option<Cow<'a, str>> {
|
||||
match normalize_ascii(input) {
|
||||
Ok(s) => Some(s),
|
||||
Err(NormalizeAsciiError::NonAscii) => {
|
||||
trace::debug!(
|
||||
caller = %Location::caller(),
|
||||
target = %target,
|
||||
input = %input.escape_default(),
|
||||
"Ignoring due to non-ASCII chars"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,416 +0,0 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
// **< ResponsiveStyles::add_style >****************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_basic_adds_declaration() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_multiple_calls_accumulate_in_order() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%; margin-inline-start: 0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_keeps_first_value_when_property_already_exists() {
|
||||
// First-write-wins: a later call for the same (breakpoint, classes, property) is a no-op,
|
||||
// it does not overwrite the value already stored.
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0");
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "33%");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%; margin-inline-start: 0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_property_name_is_case_insensitive() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "Flex-Basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "col", "FLEX-BASIS", "33%");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_value_preserves_case() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "font-family", "Arial");
|
||||
assert_eq!(
|
||||
r.get_style(Breakpoint::Md, "col", "font-family"),
|
||||
Some("Arial".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_trims_whitespace_in_property_and_value() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", " flex-basis ", " 50% ");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_repeated_call_never_looks_at_value_once_property_exists() {
|
||||
// The already-exists path returns before normalizing `value`, so even a blank value on a
|
||||
// repeated call has no effect on the declaration already stored.
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", " ");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_ignores_empty_property_or_value() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "", "50%");
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "");
|
||||
r.add_style(Breakpoint::Md, "col", " ", " ");
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_ignores_empty_or_blank_classes() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, " ", "flex-basis", "50%");
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_ignores_non_ascii_classes() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "cañón", "flex-basis", "50%");
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
// **< Class normalization >************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_normalizes_classes_case_and_whitespace() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, " Foo BAR ", "color", "red");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "foo bar"),
|
||||
Some("color: red".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_does_not_reorder_classes_tokens() {
|
||||
// Documented behavior: classes are stored as normalized but NOT sorted, so declaring the
|
||||
// same classes in a different token order creates a separate entry instead of merging.
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "foo bar", "color", "red");
|
||||
r.add_style(Breakpoint::Md, "bar foo", "font-weight", "bold");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "foo bar"),
|
||||
Some("color: red".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, "bar foo"),
|
||||
Some("font-weight: bold".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// **< Breakpoint isolation >***********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_same_classes_different_breakpoints_do_not_mix() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Sm, "col", "flex-basis", "100%");
|
||||
r.add_style(Breakpoint::Lg, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Sm, "col"),
|
||||
Some("flex-basis: 100%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Lg, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// **< No breakpoint (None) >***********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_accepts_none_as_breakpoint() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(None, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.get_styles(None, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn add_style_none_and_xs_do_not_mix() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(None, "col", "flex-basis", "100%");
|
||||
r.add_style(Breakpoint::Xs, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.get_styles(None, "col"),
|
||||
Some("flex-basis: 100%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Xs, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// **< is_empty >***********************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn responsive_styles_is_empty_on_default() {
|
||||
assert!(ResponsiveStyles::new().is_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn responsive_styles_is_empty_false_after_add_style() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
// **< get_style / get_styles >*********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn get_style_returns_none_for_missing_breakpoint_or_classes() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert_eq!(r.get_style(Breakpoint::Lg, "col", "flex-basis"), None);
|
||||
assert_eq!(r.get_style(Breakpoint::Md, "other", "flex-basis"), None);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn get_style_returns_none_for_missing_property() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert_eq!(r.get_style(Breakpoint::Md, "col", "margin"), None);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn get_style_is_case_insensitive_and_trims_input() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.get_style(Breakpoint::Md, "col", "FLEX-BASIS"),
|
||||
Some("50%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
r.get_style(Breakpoint::Md, "col", " flex-basis "),
|
||||
Some("50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn get_styles_returns_none_when_nothing_stored() {
|
||||
assert_eq!(
|
||||
ResponsiveStyles::new().get_styles(Breakpoint::Md, "col"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn get_styles_matches_classes_after_normalization() {
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "foo bar", "color", "red");
|
||||
assert_eq!(
|
||||
r.get_styles(Breakpoint::Md, " FOO BAR "),
|
||||
Some("color: red".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// **< ResponsiveStyles::render >********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_is_empty_when_nothing_stored() {
|
||||
let cx = Context::default();
|
||||
let r = ResponsiveStyles::new();
|
||||
assert_eq!(r.render(&cx).into_string(), "");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_zero_min_width_breakpoint_has_no_media_query() {
|
||||
// The default theme resolves `Breakpoint::Xs` to `UnitValue::Zero`.
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%");
|
||||
assert_eq!(r.render(&cx).into_string(), ".col { flex-basis: 100% }");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_none_breakpoint_has_no_media_query() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(None, "col", "flex-basis", "100%");
|
||||
assert_eq!(r.render(&cx).into_string(), ".col { flex-basis: 100% }");
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_none_comes_before_every_breakpoint() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(None, "row", "display", "flex");
|
||||
assert_eq!(
|
||||
r.render(&cx).into_string(),
|
||||
".row { display: flex }@media (min-width: 768px) { .col { flex-basis: 50% } }"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_non_zero_breakpoint_wraps_in_media_query() {
|
||||
// The default theme resolves `Breakpoint::Md` to `768px`.
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.render(&cx).into_string(),
|
||||
"@media (min-width: 768px) { .col { flex-basis: 50% } }"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_groups_multiple_properties_in_the_same_rule() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0");
|
||||
assert_eq!(
|
||||
r.render(&cx).into_string(),
|
||||
"@media (min-width: 768px) { .col { flex-basis: 50%; margin-inline-start: 0 } }"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_concatenates_rules_of_different_selectors_in_the_same_breakpoint() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "row", "display", "flex");
|
||||
assert_eq!(
|
||||
r.render(&cx).into_string(),
|
||||
"@media (min-width: 768px) { .col { flex-basis: 50% }.row { display: flex } }"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_converts_multiple_classes_into_a_compound_selector() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Md, "foo bar", "color", "red");
|
||||
assert_eq!(
|
||||
r.render(&cx).into_string(),
|
||||
"@media (min-width: 768px) { .foo.bar { color: red } }"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_orders_breakpoints_mobile_first_regardless_of_insertion_order() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Lg, "col", "flex-basis", "33%");
|
||||
r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%");
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
assert_eq!(
|
||||
r.render(&cx).into_string(),
|
||||
".col { flex-basis: 100% }\
|
||||
@media (min-width: 768px) { .col { flex-basis: 50% } }\
|
||||
@media (min-width: 992px) { .col { flex-basis: 33% } }"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_has_no_line_breaks() {
|
||||
let cx = Context::default();
|
||||
let mut r = ResponsiveStyles::new();
|
||||
r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%");
|
||||
r.add_style(Breakpoint::Md, "col", "flex-basis", "50%");
|
||||
r.add_style(Breakpoint::Md, "row", "display", "flex");
|
||||
assert!(!r.render(&cx).into_string().contains('\n'));
|
||||
}
|
||||
|
||||
// **< Context / AssetsOp integration >*************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn context_add_responsive_style_feeds_responsives() {
|
||||
let cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle(
|
||||
Some(Breakpoint::Md),
|
||||
"col",
|
||||
"flex-basis",
|
||||
"50%",
|
||||
));
|
||||
assert_eq!(
|
||||
cx.responsive_styles().get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn context_add_responsive_style_accumulates_across_calls() {
|
||||
let cx = Context::default()
|
||||
.with_assets(AssetsOp::AddResponsiveStyle(
|
||||
Some(Breakpoint::Md),
|
||||
"col",
|
||||
"flex-basis",
|
||||
"50%",
|
||||
))
|
||||
.with_assets(AssetsOp::AddResponsiveStyle(
|
||||
Some(Breakpoint::Md),
|
||||
"col",
|
||||
"margin-inline-start",
|
||||
"0",
|
||||
));
|
||||
assert_eq!(
|
||||
cx.responsive_styles().get_styles(Breakpoint::Md, "col"),
|
||||
Some("flex-basis: 50%; margin-inline-start: 0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn context_default_has_no_responsive_styles() {
|
||||
assert!(Context::default().responsive_styles().is_empty());
|
||||
}
|
||||
|
||||
// **< Context::render_assets integration >*********************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_assets_includes_style_tag_with_responsive_styles() {
|
||||
let mut cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle(
|
||||
Some(Breakpoint::Xs),
|
||||
"col",
|
||||
"flex-basis",
|
||||
"100%",
|
||||
));
|
||||
assert_eq!(
|
||||
cx.render_assets().into_string(),
|
||||
"<style>.col { flex-basis: 100% }</style>"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn render_assets_omits_style_tag_when_no_responsive_styles() {
|
||||
let mut cx = Context::default();
|
||||
assert_eq!(cx.render_assets().into_string(), "");
|
||||
}
|
||||
|
|
@ -7,10 +7,10 @@ async fn setup() {
|
|||
Application::new().await;
|
||||
}
|
||||
|
||||
// **< Testing normalize_ascii_non_blank() >********************************************************
|
||||
// **< Testing normalize_ascii() >******************************************************************
|
||||
|
||||
fn assert_err(input: &str, expected: util::NormalizeAsciiError) {
|
||||
let out = util::normalize_ascii_non_blank(input);
|
||||
let out = util::normalize_ascii(input);
|
||||
assert_eq!(
|
||||
out,
|
||||
Err(expected),
|
||||
|
|
@ -22,7 +22,7 @@ fn assert_err(input: &str, expected: util::NormalizeAsciiError) {
|
|||
}
|
||||
|
||||
fn assert_borrowed(input: &str, expected: &str) {
|
||||
let out = util::normalize_ascii_non_blank(input).expect("Expected Ok(..)");
|
||||
let out = util::normalize_ascii(input).expect("Expected Ok(..)");
|
||||
assert_eq!(out.as_ref(), expected, "Input {:?}", input);
|
||||
assert!(
|
||||
matches!(out, Cow::Borrowed(_)),
|
||||
|
|
@ -33,7 +33,7 @@ fn assert_borrowed(input: &str, expected: &str) {
|
|||
}
|
||||
|
||||
fn assert_owned(input: &str, expected: &str) {
|
||||
let out = util::normalize_ascii_non_blank(input).expect("Expected Ok(..)");
|
||||
let out = util::normalize_ascii(input).expect("Expected Ok(..)");
|
||||
assert_eq!(out.as_ref(), expected, "Input {:?}", input);
|
||||
assert!(
|
||||
matches!(out, Cow::Owned(_)),
|
||||
|
|
@ -248,8 +248,8 @@ async fn normalize_is_idempotent() {
|
|||
continue;
|
||||
}
|
||||
|
||||
let first = util::normalize_ascii_non_blank(input).unwrap();
|
||||
let second = util::normalize_ascii_non_blank(first.as_ref()).unwrap();
|
||||
let first = util::normalize_ascii(input).unwrap();
|
||||
let second = util::normalize_ascii(first.as_ref()).unwrap();
|
||||
assert_eq!(
|
||||
first.as_ref(),
|
||||
second.as_ref(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue