From 8cb5e035dcf494b0146391e38fb1472883440456 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 9 Aug 2026 18:45:17 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20(theme):=20Evita=20referencias?= =?UTF-8?q?=20c=C3=ADclicas=20de=20`parent()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Como `Theme::parent()` se resuelve en tiempo de ejecución, una referencia cíclica (en la que un tema acaba siendo padre de sí mismo, directa o transitivamente) no puede descartarse en compilación. Se detecta al registrar el tema y aborta el arranque si procede. --- src/core/extension/all.rs | 28 ++++++++++++++++++++++++++++ src/core/theme.rs | 7 +++++++ src/core/theme/definition.rs | 5 +++++ 3 files changed, 40 insertions(+) diff --git a/src/core/extension/all.rs b/src/core/extension/all.rs index 3d8baab6..709af8aa 100644 --- a/src/core/extension/all.rs +++ b/src/core/extension/all.rs @@ -1,5 +1,6 @@ use crate::core::action::add_action; use crate::core::extension::ExtensionRef; +use crate::core::theme::ThemeRef; use crate::core::theme::all::THEMES; use crate::web::Router; use crate::{global, serve_static_files, trace, web}; @@ -45,6 +46,8 @@ fn add_to_enabled(list: &mut Vec, extension: ExtensionRef) { // Comprueba si la extensión tiene un tema asociado que deba registrarse. if let Some(theme) = extension.theme() { + check_theme_parent_chain(theme); + let mut registered_themes = THEMES.write(); // Asegura que el tema no esté ya registrado para evitar duplicados. if !registered_themes @@ -60,6 +63,31 @@ fn add_to_enabled(list: &mut Vec, extension: ExtensionRef) { } } +// Recorre la cadena de `Theme::parent()` para detectar referencias circulares. `parent()` se +// resuelve en tiempo de ejecución, así que un ciclo no puede descartarse al compilar. Se rechaza el +// arranque al detectar uno, antes de provocar un bucle infinito (en `Theme::handle_component()`) o +// un desbordamiento de pila (en los métodos predefinidos de `Theme` que delegan recursivamente en +// el tema padre). +fn check_theme_parent_chain(theme: ThemeRef) { + let mut chain: Vec = vec![theme]; + let mut current = theme; + while let Some(parent) = current.parent() { + if let Some(pos) = chain.iter().position(|t| t.type_id() == parent.type_id()) { + let cycle: Vec<&str> = chain[pos..] + .iter() + .map(|t| t.short_name()) + .chain(std::iter::once(parent.short_name())) + .collect(); + panic!( + "Circular theme parent chain detected: {}", + cycle.join(" -> ") + ); + } + chain.push(parent); + current = parent; + } +} + // **< REGISTRO DE LAS ACCIONES >******************************************************************* pub fn register_actions() { diff --git a/src/core/theme.rs b/src/core/theme.rs index bdc3ac81..86845707 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -20,6 +20,13 @@ //! de [`Theme::handle_component()`], las páginas de error, etc.). Un tema hijo puede ser a su vez //! padre de otro, basta declararlo cada vez con [`Theme::parent()`]. //! +//! Como `parent()` se resuelve en tiempo de ejecución, PageTop no puede descartar en compilación +//! referencias circulares (un tema acaba siendo padre de sí mismo, directa o transitivamente). Ese +//! ciclo provocaría un bucle infinito en [`Theme::handle_component()`] o un desbordamiento de pila +//! en los métodos predefinidos de `Theme` que delegan recursivamente en el padre. Para evitarlo, +//! PageTop recorre la cadena de cada tema al registrarlo y **aborta el arranque de la aplicación** +//! si detecta una referencia circular. +//! //! Sin embargo, no dice nada sobre los componentes. Aunque un tema puede exportar su propio //! catálogo de componentes, realmente no pertenecen como tal a ningún tema ni dependen de esa //! cadena de herencia. Una extensión puede existir únicamente para aportar un componente genérico diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 822cd9a9..95e7e77f 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -56,6 +56,11 @@ pub trait Theme: Extension + Send + Sync { /// no los sobrescribe. /// /// La implementación por defecto devuelve `None` (tema sin padre). + /// + /// Una referencia circular (un tema acaba siendo padre de sí mismo, directa o transitivamente) + /// no puede descartarse en tiempo de compilación. PageTop la detecta al registrar el tema y + /// aborta el arranque de la aplicación si encuentra una, para evitar bucles infinitos o + /// desbordamientos de pila al usar el tema. fn parent(&self) -> Option { None } From 20b6c77c9e6a8f4586dcaad2acc6e187d15792b6 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 10 Aug 2026 13:19:59 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20(base):=20Exporta=20PagerAli?= =?UTF-8?q?gn=20y=20corrige=20aria-label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/component.rs | 2 +- src/base/component/pager.rs | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/base/component.rs b/src/base/component.rs index 1d103341..5a2d6061 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -26,7 +26,7 @@ mod intro; pub use intro::{Intro, IntroOpening}; mod pager; -pub use pager::{Pager, PagerVisibility}; +pub use pager::{Pager, PagerAlign, PagerVisibility}; mod poweredby; pub use poweredby::PoweredBy; diff --git a/src/base/component/pager.rs b/src/base/component/pager.rs index e3dafe5f..d8821468 100644 --- a/src/base/component/pager.rs +++ b/src/base/component/pager.rs @@ -1,7 +1,10 @@ use crate::prelude::*; -/// Define cuándo mostrar los botones de página anterior/siguiente o el formulario de salto a página -/// de [`Pager`]. +/// Establece la visibilidad de elementos esenciales de un [`Pager`]. +/// +/// Se aplica al resumen de páginas mostradas ([`Pager::with_summary()`]), a los botones de +/// navegación anterior/siguiente ([`Pager::with_prev_next()`]) y al formulario de salto directo a +/// página ([`Pager::with_jump()`]). #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum PagerVisibility { /// Nunca se muestra. @@ -232,7 +235,7 @@ impl Component for Pager { }; Ok(html! { - nav (self.props()) aria-label=(self.aria_label().using(cx)) { + nav (self.props()) aria-label=[self.aria_label().lookup(cx)] { @if show_summary { @let items_per_page = self.items_per_page().max(1); @let first = (page - 1) * items_per_page + 1; @@ -251,7 +254,7 @@ impl Component for Pager { a.page-link href=[(!first_disabled).then(|| Self::page_route(&route, page - 1))] aria-disabled=[first_disabled.then_some("true")] - aria-label=(L10n::l("pager_previous_aria_label").using(cx)) { + aria-label=[L10n::l("pager_previous_aria_label").lookup(cx)] { span.page-link-icon { (L10n::l("pager_previous_label").using(cx)) } } } @@ -280,7 +283,7 @@ impl Component for Pager { a.page-link href=[(!last_disabled).then(|| Self::page_route(&route, page + 1))] aria-disabled=[last_disabled.then_some("true")] - aria-label=(L10n::l("pager_next_aria_label").using(cx)) { + aria-label=[L10n::l("pager_next_aria_label").lookup(cx)] { span.page-link-icon { (L10n::l("pager_next_label").using(cx)) } } } From 6bea4a57933f3db6976196f27b82b5fccc3e67d6 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Tue, 11 Aug 2026 00:46:45 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20(base):=20A=C3=B1ade=20componen?= =?UTF-8?q?te=20Breadcrumb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/basic.css | 32 +++++++ src/base/component.rs | 4 + src/base/component/breadcrumb.rs | 7 ++ src/base/component/breadcrumb/component.rs | 93 ++++++++++++++++++ src/base/component/breadcrumb/crumb.rs | 105 +++++++++++++++++++++ src/locale/en-US/base.ftl | 3 + src/locale/es-ES/base.ftl | 3 + 7 files changed, 247 insertions(+) create mode 100644 src/base/component/breadcrumb.rs create mode 100644 src/base/component/breadcrumb/component.rs create mode 100644 src/base/component/breadcrumb/crumb.rs diff --git a/assets/css/basic.css b/assets/css/basic.css index 8332ca99..aa54408b 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -276,6 +276,38 @@ input:disabled + label { background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); } +/* + * Breadcrumb component + */ + +.breadcrumb { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 1rem; + padding: 0.75rem 1rem; + font-size: 0.875rem; +} +.breadcrumb-item + .breadcrumb-item { + padding-left: 0.5rem; +} +.breadcrumb-item + .breadcrumb-item::before { + float: left; + padding-right: 0.5rem; + color: var(--val-color--text--muted); + content: "/"; +} +.breadcrumb-item.active { + color: var(--val-color--text--muted); +} +.breadcrumb-item a { + color: var(--val-color--text); + text-decoration: none; +} +.breadcrumb-item a:hover { + text-decoration: underline; +} + /* * Region Footer */ diff --git a/src/base/component.rs b/src/base/component.rs index 5a2d6061..6bb4e8b9 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -5,6 +5,10 @@ pub mod layout; mod badge; pub use badge::Badge; +pub mod breadcrumb; +#[doc(inline)] +pub use breadcrumb::Breadcrumb; + mod block; pub use block::Block; diff --git a/src/base/component/breadcrumb.rs b/src/base/component/breadcrumb.rs new file mode 100644 index 00000000..ebe4d712 --- /dev/null +++ b/src/base/component/breadcrumb.rs @@ -0,0 +1,7 @@ +//! Definiciones para mostrar rutas de navegación ([`Breadcrumb`]). + +mod component; +pub use component::Breadcrumb; + +mod crumb; +pub use crumb::Crumb; diff --git a/src/base/component/breadcrumb/component.rs b/src/base/component/breadcrumb/component.rs new file mode 100644 index 00000000..abd1e552 --- /dev/null +++ b/src/base/component/breadcrumb/component.rs @@ -0,0 +1,93 @@ +use crate::prelude::*; + +/// Componente para representar una ruta de navegación (*breadcrumb*). +/// +/// Renderiza la estructura HTML de cualquier breadcrumb encapsulando en un elemento `