diff --git a/CREDITS.md b/CREDITS.md
index f5c1b0f..c5a7bd2 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -1,8 +1,7 @@
# 🔃 Dependencias
-`PageTop` está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes
-aprovechando algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs)
-como son:
+PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando
+algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs) como son:
* [Actix Web](https://actix.rs/) para los servicios web.
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración.
@@ -11,14 +10,14 @@ como son:
* [Fluent templates](https://github.com/XAMPPRocky/fluent-templates), que integra
[Fluent](https://projectfluent.org/) para internacionalizar las aplicaciones.
* Además de otros *crates* adicionales que se pueden explorar en los archivos `Cargo.toml` de
- `PageTop` y sus extensiones.
+ PageTop y sus extensiones.
# 🗚 FIGfonts
-`PageTop` usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por
-*yuanbohan* para mostrar un banner de presentación en el terminal con el nombre de la aplicación en
-caracteres [FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son:
+PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan*
+para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres
+[FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son:
* [slant.flf](http://www.figlet.org/fontdb_example.cgi?font=slant.flf) de *Glenn Chappell*
* [small.flf](http://www.figlet.org/fontdb_example.cgi?font=small.flf) de *Glenn Chappell*
diff --git a/Cargo.lock b/Cargo.lock
index 944027d..3053e20 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1307,6 +1307,12 @@ dependencies = [
"hashbrown 0.15.4",
]
+[[package]]
+name = "indoc"
+version = "2.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -1568,6 +1574,7 @@ dependencies = [
"config",
"figlet-rs",
"fluent-templates",
+ "indoc",
"itoa",
"pagetop-build",
"pagetop-macros",
diff --git a/Cargo.toml b/Cargo.toml
index 8ccd69e..ab7551f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,7 @@ colored = "3.0.0"
concat-string = "1.0.1"
config = { version = "0.15.13", default-features = false, features = ["toml"] }
figlet-rs = "0.1.5"
+indoc = "2.0.6"
itoa = "1.0.15"
parking_lot = "0.12.4"
paste = { package = "pastey", version = "0.1.0" }
diff --git a/README.md b/README.md
index e7fab94..c6c12e0 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,8 @@
-`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
-la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
+PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
+creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo:
@@ -24,14 +24,14 @@ según las necesidades de cada proyecto, incluyendo:
* **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs
- de `PageTop` o de terceros.
+ de PageTop o de terceros.
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad.
# ⚡️ Guía rápida
-La aplicación más sencilla de `PageTop` se ve así:
+La aplicación más sencilla de PageTop se ve así:
```rust,no_run
use pagetop::prelude::*;
@@ -42,10 +42,10 @@ async fn main() -> std::io::Result<()> {
}
```
-Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página
-de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
+Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
+bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
-Para personalizar el servicio, se puede crear una extensión de `PageTop` de la siguiente manera:
+Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
@@ -59,8 +59,8 @@ impl Extension for HelloWorld {
}
async fn hello_world(request: HttpRequest) -> ResultPage {
- Page::new(Some(request))
- .with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
+ Page::new(request)
+ .add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
@@ -86,15 +86,15 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
* **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**,
es la librería que permite incluir archivos estáticos en el ejecutable de las aplicaciones
- `PageTop` para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo
- de compilación.
+ PageTop para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo de
+ compilación.
* **[pagetop-build](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-build)**,
prepara los archivos estáticos o archivos SCSS compilados para incluirlos en el binario de las
- aplicaciones `PageTop` durante la compilación de los ejecutables.
+ aplicaciones PageTop durante la compilación de los ejecutables.
* **[pagetop-macros](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-macros)**,
- proporciona una colección de macros que mejoran la experiencia de desarrollo con `PageTop`.
+ proporciona una colección de macros que mejoran la experiencia de desarrollo con PageTop.
# 🧪 Pruebas
@@ -116,7 +116,7 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
# 🚧 Advertencia
-`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
+**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
diff --git a/examples/hello-name.rs b/examples/hello-name.rs
index 7a6db54..e1285d0 100644
--- a/examples/hello-name.rs
+++ b/examples/hello-name.rs
@@ -13,8 +13,8 @@ async fn hello_name(
path: service::web::Path,
) -> ResultPage {
let name = path.into_inner();
- Page::new(Some(request))
- .with_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } }))
+ Page::new(request)
+ .add_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } }))
.render()
}
diff --git a/examples/hello-world.rs b/examples/hello-world.rs
index ba268dc..d56f210 100644
--- a/examples/hello-world.rs
+++ b/examples/hello-world.rs
@@ -9,8 +9,8 @@ impl Extension for HelloWorld {
}
async fn hello_world(request: HttpRequest) -> ResultPage {
- Page::new(Some(request))
- .with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
+ Page::new(request)
+ .add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
diff --git a/helpers/pagetop-build/README.md b/helpers/pagetop-build/README.md
index 80d6bba..57273e8 100644
--- a/helpers/pagetop-build/README.md
+++ b/helpers/pagetop-build/README.md
@@ -113,7 +113,7 @@ impl Extension for MyExtension {
# 🚧 Advertencia
-`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
+**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
diff --git a/helpers/pagetop-macros/README.md b/helpers/pagetop-macros/README.md
index e58d24c..7c9c2e8 100644
--- a/helpers/pagetop-macros/README.md
+++ b/helpers/pagetop-macros/README.md
@@ -26,12 +26,12 @@ Esta librería incluye entre sus macros una adaptación de
[SmartDefault](https://crates.io/crates/smart_default) (0.7.1) de
[Jane Doe](https://crates.io/users/jane-doe), llamada `AutoDefault`. Estas macros eliminan la
necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de
-cada proyecto `PageTop`.
+cada proyecto PageTop.
# 🚧 Advertencia
-`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
+**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs
index 6421ca6..5af5f9c 100644
--- a/helpers/pagetop-macros/src/lib.rs
+++ b/helpers/pagetop-macros/src/lib.rs
@@ -27,7 +27,7 @@ Esta librería incluye entre sus macros una adaptación de
[SmartDefault](https://crates.io/crates/smart_default) (0.7.1) de
[Jane Doe](https://crates.io/users/jane-doe), llamada `AutoDefault`. Estas macros eliminan la
necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de
-cada proyecto `PageTop`.
+cada proyecto PageTop.
*/
#).
#[proc_macro]
@@ -107,119 +107,221 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// `alter_...()`, que permitirá más adelante modificar instancias existentes.
#[proc_macro_attribute]
pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
- let fn_with = parse_macro_input!(item as ItemFn);
- let fn_with_name = fn_with.sig.ident.clone();
- let fn_with_name_str = fn_with.sig.ident.to_string();
+ use syn::{parse2, FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type};
+
+ let ts: proc_macro2::TokenStream = item.clone().into();
+
+ enum Kind {
+ Impl(ImplItemFn),
+ Trait(TraitItemFn),
+ }
+
+ // Detecta si estamos en `impl` o `trait`.
+ let kind = if let Ok(it) = parse2::(ts.clone()) {
+ Kind::Impl(it)
+ } else if let Ok(tt) = parse2::(ts.clone()) {
+ Kind::Trait(tt)
+ } else {
+ return quote! {
+ compile_error!("#[builder_fn] only supports methods in `impl` blocks or `trait` items");
+ }
+ .into();
+ };
+
+ // Extrae piezas comunes (sig, attrs, vis, bloque?, es_trait?).
+ let (sig, attrs, vis, body_opt, is_trait) = match &kind {
+ Kind::Impl(m) => (&m.sig, &m.attrs, Some(&m.vis), Some(&m.block), false),
+ Kind::Trait(t) => (&t.sig, &t.attrs, None, t.default.as_ref(), true),
+ };
+
+ let with_name = sig.ident.clone();
+ let with_name_str = sig.ident.to_string();
// Valida el nombre del método.
- if !fn_with_name_str.starts_with("with_") {
- let expanded = quote_spanned! {
- fn_with.sig.ident.span() =>
- compile_error!("expected a \"pub fn with_...(mut self, ...) -> Self\" method");
- };
- return expanded.into();
- }
- // Valida que el método es público.
- if !matches!(fn_with.vis, syn::Visibility::Public(_)) {
+ if !with_name_str.starts_with("with_") {
return quote_spanned! {
- fn_with.sig.ident.span() => compile_error!("expected method to be `pub`");
+ sig.ident.span() => compile_error!("expected a named `with_...()` method");
}
.into();
}
- // Valida que el primer argumento es exactamente `mut self`.
- if let Some(syn::FnArg::Receiver(receiver)) = fn_with.sig.inputs.first() {
- if receiver.mutability.is_none() || receiver.reference.is_some() {
- return quote_spanned! {
- receiver.span() => compile_error!("expected `mut self` as the first argument");
+
+ // Sólo se exige `pub` en `impl` (en `trait` no aplica).
+ let vis_pub = match (is_trait, vis) {
+ (false, Some(v)) => quote! { #v },
+ _ => quote! {},
+ };
+
+ // Validaciones comunes.
+ if sig.asyncness.is_some() {
+ return quote_spanned! {
+ sig.asyncness.span() => compile_error!("`with_...()` cannot be `async`");
+ }
+ .into();
+ }
+ if sig.constness.is_some() {
+ return quote_spanned! {
+ sig.constness.span() => compile_error!("`with_...()` cannot be `const`");
+ }
+ .into();
+ }
+ if sig.abi.is_some() {
+ return quote_spanned! {
+ sig.abi.span() => compile_error!("`with_...()` cannot be `extern`");
+ }
+ .into();
+ }
+ if sig.unsafety.is_some() {
+ return quote_spanned! {
+ sig.unsafety.span() => compile_error!("`with_...()` cannot be `unsafe`");
+ }
+ .into();
+ }
+
+ // En `impl` se exige exactamente `mut self`; y en `trait` se exige `self` (sin &).
+ let receiver_ok = match sig.inputs.first() {
+ Some(FnArg::Receiver(r)) => {
+ // Rechaza `self: SomeType`.
+ if r.colon_token.is_some() {
+ false
+ } else if is_trait {
+ // Exactamente `self` (sin &, sin mut).
+ r.reference.is_none() && r.mutability.is_none()
+ } else {
+ // Exactamente `mut self`.
+ r.reference.is_none() && r.mutability.is_some()
}
- .into();
}
- } else {
+ _ => false,
+ };
+ if !receiver_ok {
+ let msg = if is_trait {
+ "expected `self` (not `mut self`, `&self` or `&mut self`) in trait method"
+ } else {
+ "expected first argument to be exactly `mut self`"
+ };
+ let err = sig
+ .inputs
+ .first()
+ .map(|a| a.span())
+ .unwrap_or(sig.ident.span());
return quote_spanned! {
- fn_with.sig.ident.span() => compile_error!("expected `mut self` as the first argument");
+ err => compile_error!(#msg);
}
.into();
}
+
// Valida que el método devuelve exactamente `Self`.
- if let syn::ReturnType::Type(_, ty) = &fn_with.sig.output {
- if let syn::Type::Path(type_path) = ty.as_ref() {
- if type_path.qself.is_some() || !type_path.path.is_ident("Self") {
- return quote_spanned! { ty.span() =>
- compile_error!("expected return type to be exactly `Self`");
+ match &sig.output {
+ ReturnType::Type(_, ty) => match ty.as_ref() {
+ Type::Path(p) if p.qself.is_none() && p.path.is_ident("Self") => {}
+ _ => {
+ return quote_spanned! {
+ ty.span() => compile_error!("expected return type to be exactly `Self`");
}
.into();
}
- } else {
- return quote_spanned! { ty.span() =>
- compile_error!("expected return type to be exactly `Self`");
+ },
+ _ => {
+ return quote_spanned! {
+ sig.output.span() => compile_error!("expected return type to be exactly `Self`");
}
.into();
}
- } else {
- return quote_spanned! {
- fn_with.sig.output.span() => compile_error!("expected method to return `Self`");
- }
- .into();
}
// Genera el nombre del método alter_...().
- let fn_alter_name_str = fn_with_name_str.replace("with_", "alter_");
- let fn_alter_name = syn::Ident::new(&fn_alter_name_str, fn_with.sig.ident.span());
+ let stem = with_name_str.strip_prefix("with_").expect("validated");
+ let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span());
// Extrae genéricos y cláusulas where.
- let fn_generics = &fn_with.sig.generics;
- let where_clause = &fn_with.sig.generics.where_clause;
+ let generics = &sig.generics;
+ let where_clause = &sig.generics.where_clause;
- // Extrae argumentos y parámetros de llamada.
- let args: Vec<_> = fn_with.sig.inputs.iter().skip(1).collect();
- let params: Vec<_> = fn_with
- .sig
- .inputs
+ // Extrae identificadores de los argumentos para la llamada (sin `mut` ni patrones complejos).
+ let args: Vec<_> = sig.inputs.iter().skip(1).collect();
+ let call_idents: Vec = {
+ let mut v = Vec::new();
+ for arg in sig.inputs.iter().skip(1) {
+ match arg {
+ FnArg::Typed(pat) => {
+ if let Pat::Ident(pat_ident) = pat.pat.as_ref() {
+ v.push(pat_ident.ident.clone());
+ } else {
+ return quote_spanned! {
+ pat.pat.span() => compile_error!(
+ "each parameter must be a simple identifier, e.g. `value: T`"
+ );
+ }
+ .into();
+ }
+ }
+ _ => {
+ return quote_spanned! {
+ arg.span() => compile_error!("unexpected receiver in parameter list");
+ }
+ .into();
+ }
+ }
+ }
+ v
+ };
+
+ // Extrae atributos descartando la documentación para incluir en `alter_...()`.
+ let non_doc_attrs: Vec<_> = attrs
.iter()
- .skip(1)
- .map(|arg| match arg {
- syn::FnArg::Typed(pat) => &pat.pat,
- _ => panic!("unexpected argument type"),
- })
+ .filter(|&a| !a.path().is_ident("doc"))
+ .cloned()
.collect();
- // Extrae bloque del método.
- let fn_with_block = &fn_with.block;
-
- // Extrae documentación y otros atributos del método.
- let fn_with_attrs = &fn_with.attrs;
-
- // Genera el método alter_...() con el código del método with_...().
- let fn_alter_doc =
- format!("Igual que [`Self::{fn_with_name_str}()`], pero sin usar el patrón *builder*.");
-
- let fn_alter = quote! {
- #[doc = #fn_alter_doc]
- pub fn #fn_alter_name #fn_generics(&mut self, #(#args),*) -> &mut Self #where_clause {
- #fn_with_block
- }
- };
-
- // Redefine el método with_...() para que llame a alter_...().
- let fn_with = quote! {
- #(#fn_with_attrs)*
- #[inline]
- pub fn #fn_with_name #fn_generics(mut self, #(#args),*) -> Self #where_clause {
- self.#fn_alter_name(#(#params),*);
- self
- }
- };
+ // Documentación del método alter_...().
+ let alter_doc =
+ format!("Equivalente a [`Self::{with_name_str}()`], pero fuera del patrón *builder*.");
// Genera el código final.
- let expanded = quote! {
- #fn_with
- #[inline]
- #fn_alter
+ let expanded = match body_opt {
+ None => {
+ quote! {
+ #(#attrs)*
+ fn #with_name #generics (self, #(#args),*) -> Self #where_clause;
+
+ #(#non_doc_attrs)*
+ #[doc = #alter_doc]
+ fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause;
+ }
+ }
+ Some(body) => {
+ let with_fn = if is_trait {
+ quote! {
+ #vis_pub fn #with_name #generics (self, #(#args),*) -> Self #where_clause {
+ let mut s = self;
+ s.#alter_ident(#(#call_idents),*);
+ s
+ }
+ }
+ } else {
+ quote! {
+ #vis_pub fn #with_name #generics (mut self, #(#args),*) -> Self #where_clause {
+ self.#alter_ident(#(#call_idents),*);
+ self
+ }
+ }
+ };
+ quote! {
+ #(#attrs)*
+ #with_fn
+
+ #(#non_doc_attrs)*
+ #[doc = #alter_doc]
+ #vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause {
+ #body
+ }
+ }
+ }
};
expanded.into()
}
-/// Define una función `main` asíncrona como punto de entrada de `PageTop`.
+/// Define una función `main` asíncrona como punto de entrada de PageTop.
///
/// # Ejemplo
///
@@ -240,7 +342,7 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
output
}
-/// Define funciones de prueba asíncronas para usar con `PageTop`.
+/// Define funciones de prueba asíncronas para usar con PageTop.
///
/// # Ejemplo
///
diff --git a/helpers/pagetop-statics/README.md b/helpers/pagetop-statics/README.md
index 92999c0..4168cd4 100644
--- a/helpers/pagetop-statics/README.md
+++ b/helpers/pagetop-statics/README.md
@@ -16,7 +16,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general
-Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones `PageTop` para
+Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación.
@@ -28,13 +28,13 @@ Para ello, adapta el código de los *crates* [static-files](https://crates.io/cr
[4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por
[Alexander Korolev](https://crates.io/users/kilork).
-Estas implementaciones se integran en `PageTop` para evitar que cada proyecto tenga que declarar
+Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar
`static-files` manualmente como dependencia en su `Cargo.toml`.
# 🚧 Advertencia
-`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
+**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
diff --git a/helpers/pagetop-statics/src/lib.rs b/helpers/pagetop-statics/src/lib.rs
index dab50d9..201d90e 100644
--- a/helpers/pagetop-statics/src/lib.rs
+++ b/helpers/pagetop-statics/src/lib.rs
@@ -17,7 +17,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general
-Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones `PageTop` para
+Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación.
@@ -29,7 +29,7 @@ Para ello, adapta el código de los *crates* [static-files](https://crates.io/cr
[4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por
[Alexander Korolev](https://crates.io/users/kilork).
-Estas implementaciones se integran en `PageTop` para evitar que cada proyecto tenga que declarar
+Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar
`static-files` manualmente como dependencia en su `Cargo.toml`.
*/
diff --git a/src/app.rs b/src/app.rs
index c3576fc..c8ffba1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,8 +1,11 @@
-//! Prepara y ejecuta una aplicación creada con `Pagetop`.
+//! Prepara y ejecuta una aplicación creada con PageTop.
mod figfont;
use crate::core::{extension, extension::ExtensionRef};
+use crate::html::Markup;
+use crate::response::page::{ErrorPage, ResultPage};
+use crate::service::HttpRequest;
use crate::{global, locale, service, trace};
use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle};
@@ -14,7 +17,7 @@ use substring::Substring;
use std::io::Error;
use std::sync::LazyLock;
-/// Punto de entrada de una aplicación `PageTop`.
+/// Punto de entrada de una aplicación PageTop.
///
/// No almacena datos, **encapsula** el inicio completo de configuración y puesta en marcha. Para
/// instanciarla se puede usar [`new()`](Application::new) o [`prepare()`](Application::prepare).
@@ -81,7 +84,7 @@ impl Application {
if let Some((Width(term_width), _)) = terminal_size() {
if term_width >= 80 {
let maxlen: usize = ((term_width / 10) - 2).into();
- let mut app = app_name.substring(0, maxlen).to_owned();
+ let mut app = app_name.substring(0, maxlen).to_string();
if app_name.len() > maxlen {
app = format!("{app}...");
}
@@ -170,6 +173,12 @@ impl Application {
InitError = (),
>,
> {
- service::App::new().configure(extension::all::configure_services)
+ service::App::new()
+ .configure(extension::all::configure_services)
+ .default_service(service::web::route().to(service_not_found))
}
}
+
+async fn service_not_found(request: HttpRequest) -> ResultPage {
+ Err(ErrorPage::NotFound(request))
+}
diff --git a/src/base/action.rs b/src/base/action.rs
index be35e92..977ae9e 100644
--- a/src/base/action.rs
+++ b/src/base/action.rs
@@ -1,4 +1,4 @@
-//! Acciones predefinidas para alterar el funcionamiento interno de `PageTop`.
+//! Acciones predefinidas para alterar el funcionamiento interno de PageTop.
use crate::prelude::*;
diff --git a/src/base/action/component/after_render_component.rs b/src/base/action/component/after_render_component.rs
index 917f322..0cb0334 100644
--- a/src/base/action/component/after_render_component.rs
+++ b/src/base/action/component/after_render_component.rs
@@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent;
pub struct AfterRender {
f: FnActionWithComponent,
referer_type_id: Option,
- referer_id: OptionId,
+ referer_id: AttrId,
weight: Weight,
}
@@ -34,7 +34,7 @@ impl AfterRender {
AfterRender {
f,
referer_type_id: Some(UniqueId::of::()),
- referer_id: OptionId::default(),
+ referer_id: AttrId::default(),
weight: 0,
}
}
diff --git a/src/base/action/component/before_render_component.rs b/src/base/action/component/before_render_component.rs
index 8c2e38d..46ff9aa 100644
--- a/src/base/action/component/before_render_component.rs
+++ b/src/base/action/component/before_render_component.rs
@@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent;
pub struct BeforeRender {
f: FnActionWithComponent,
referer_type_id: Option,
- referer_id: OptionId,
+ referer_id: AttrId,
weight: Weight,
}
@@ -34,7 +34,7 @@ impl BeforeRender {
BeforeRender {
f,
referer_type_id: Some(UniqueId::of::()),
- referer_id: OptionId::default(),
+ referer_id: AttrId::default(),
weight: 0,
}
}
diff --git a/src/base/action/component/is_renderable.rs b/src/base/action/component/is_renderable.rs
index baa86f1..5a0e244 100644
--- a/src/base/action/component/is_renderable.rs
+++ b/src/base/action/component/is_renderable.rs
@@ -11,7 +11,7 @@ pub type FnIsRenderable = fn(component: &C, cx: &Context) -> bool;
pub struct IsRenderable {
f: FnIsRenderable,
referer_type_id: Option,
- referer_id: OptionId,
+ referer_id: AttrId,
weight: Weight,
}
@@ -39,7 +39,7 @@ impl IsRenderable {
IsRenderable {
f,
referer_type_id: Some(UniqueId::of::()),
- referer_id: OptionId::default(),
+ referer_id: AttrId::default(),
weight: 0,
}
}
diff --git a/src/base/component.rs b/src/base/component.rs
index 27f0f73..4df64ff 100644
--- a/src/base/component.rs
+++ b/src/base/component.rs
@@ -1,4 +1,10 @@
-//! Componentes nativos proporcionados por `PageTop`.
+//! Componentes nativos proporcionados por PageTop.
mod html;
pub use html::Html;
+
+mod block;
+pub use block::Block;
+
+mod poweredby;
+pub use poweredby::PoweredBy;
diff --git a/src/base/component/block.rs b/src/base/component/block.rs
new file mode 100644
index 0000000..c96f2ba
--- /dev/null
+++ b/src/base/component/block.rs
@@ -0,0 +1,103 @@
+use crate::prelude::*;
+
+/// Componente genérico que representa un bloque de contenido.
+///
+/// Los bloques se utilizan como contenedores de otros componentes o contenidos, con un título
+/// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*).
+#[rustfmt::skip]
+#[derive(AutoDefault)]
+pub struct Block {
+ id : AttrId,
+ classes : AttrClasses,
+ title : L10n,
+ children: Children,
+}
+
+impl Component for Block {
+ fn new() -> Self {
+ Block::default()
+ }
+
+ fn id(&self) -> Option {
+ self.id.get()
+ }
+
+ fn setup_before_prepare(&mut self, _cx: &mut Context) {
+ self.alter_classes(ClassesOp::Prepend, "block");
+ }
+
+ fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
+ let block_body = self.children().render(cx);
+
+ if block_body.is_empty() {
+ return PrepareMarkup::None;
+ }
+
+ let id = cx.required_id::(self.id());
+
+ PrepareMarkup::With(html! {
+ div id=(id) class=[self.classes().get()] {
+ @if let Some(title) = self.title().lookup(cx) {
+ h2 class="block__title" { span { (title) } }
+ }
+ div class="block__body" { (block_body) }
+ }
+ })
+ }
+}
+
+impl Block {
+ // Block BUILDER *******************************************************************************
+
+ /// Establece el identificador único (`id`) del bloque.
+ #[builder_fn]
+ pub fn with_id(mut self, id: impl AsRef) -> Self {
+ self.id.alter_value(id);
+ self
+ }
+
+ /// Modifica la lista de clases CSS aplicadas al bloque.
+ #[builder_fn]
+ pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self {
+ self.classes.alter_value(op, classes);
+ self
+ }
+
+ /// Establece el título del bloque.
+ #[builder_fn]
+ pub fn with_title(mut self, title: L10n) -> Self {
+ self.title = title;
+ self
+ }
+
+ /// Añade un nuevo componente hijo al bloque.
+ pub fn add_component(mut self, component: impl Component) -> Self {
+ self.children
+ .alter_child(ChildOp::Add(Child::with(component)));
+ self
+ }
+
+ /// Modifica la lista de hijos (`children`) aplicando una operación.
+ #[builder_fn]
+ pub fn with_child(mut self, op: ChildOp) -> Self {
+ self.children.alter_child(op);
+ self
+ }
+
+ // Block GETTERS *******************************************************************************
+
+ /// Devuelve las clases CSS asociadas al bloque.
+ pub fn classes(&self) -> &AttrClasses {
+ &self.classes
+ }
+
+ /// Devuelve el título del bloque como [`L10n`].
+ pub fn title(&self) -> &L10n {
+ &self.title
+ }
+
+ /// Devuelve la lista de hijos (`children`) del bloque.
+ pub fn children(&self) -> &Children {
+ &self.children
+ }
+}
diff --git a/src/base/component/html.rs b/src/base/component/html.rs
index 8f273ed..8fa5690 100644
--- a/src/base/component/html.rs
+++ b/src/base/component/html.rs
@@ -25,7 +25,7 @@ use crate::prelude::*;
/// use pagetop::prelude::*;
///
/// let component = Html::with(|cx| {
-/// let user = cx.get_param::("username").unwrap_or(String::from("visitor"));
+/// let user = cx.param::("username").cloned().unwrap_or("visitor".to_string());
/// html! {
/// h1 { "Hello, " (user) }
/// }
@@ -44,11 +44,13 @@ impl Component for Html {
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
- PrepareMarkup::With((self.0)(cx))
+ PrepareMarkup::With(self.html(cx))
}
}
impl Html {
+ // Html BUILDER ********************************************************************************
+
/// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
///
/// El método [`prepare_component()`](crate::core::component::Component::prepare_component)
@@ -66,11 +68,24 @@ impl Html {
/// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando
/// [`prepare_component()`](crate::core::component::Component::prepare_component) invoque esta
/// instancia. La nueva función también recibe una referencia al contexto ([`Context`]).
- pub fn alter_html(&mut self, f: F) -> &mut Self
+ #[builder_fn]
+ pub fn with_fn(mut self, f: F) -> Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
self.0 = Box::new(f);
self
}
+
+ // Html GETTERS ********************************************************************************
+
+ /// Aplica la función interna de renderizado con el [`Context`] proporcionado.
+ ///
+ /// Normalmente no se invoca manualmente, ya que el proceso de renderizado de los componentes lo
+ /// invoca automáticamente durante la construcción de la página. Puede usarse, no obstante, para
+ /// sobrescribir [`prepare_component()`](crate::core::component::Component::prepare_component)
+ /// y alterar el comportamiento del componente.
+ pub fn html(&self, cx: &mut Context) -> Markup {
+ (self.0)(cx)
+ }
}
diff --git a/src/base/component/poweredby.rs b/src/base/component/poweredby.rs
new file mode 100644
index 0000000..bfe3835
--- /dev/null
+++ b/src/base/component/poweredby.rs
@@ -0,0 +1,67 @@
+use crate::prelude::*;
+
+// Enlace a la página oficial de PageTop.
+const LINK: &str = "PageTop";
+
+/// Componente que renderiza la sección 'Powered by' (*Funciona con*) típica del pie de página.
+///
+/// Por defecto, usando [`default()`](Self::default) sólo se muestra un reconocimiento a PageTop.
+/// Sin embargo, se puede usar [`new()`](Self::new) para crear una instancia con un texto de
+/// copyright predeterminado.
+#[derive(AutoDefault)]
+pub struct PoweredBy {
+ copyright: Option,
+}
+
+impl Component for PoweredBy {
+ /// Crea una nueva instancia de `PoweredBy`.
+ ///
+ /// El copyright se genera automáticamente con el año actual y el nombre de la aplicación
+ /// configurada en [`global::SETTINGS`].
+ fn new() -> Self {
+ let year = Utc::now().format("%Y").to_string();
+ let c = join!(year, " © ", global::SETTINGS.app.name);
+ PoweredBy { copyright: Some(c) }
+ }
+
+ fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
+ PrepareMarkup::With(html! {
+ div id=[self.id()] class="poweredby" {
+ @if let Some(c) = self.copyright() {
+ span class="poweredby__copyright" { (c) "." } " "
+ }
+ span class="poweredby__pagetop" {
+ (L10n::l("poweredby_pagetop").with_arg("pagetop_link", LINK).using(cx))
+ }
+ }
+ })
+ }
+}
+
+impl PoweredBy {
+ // PoweredBy BUILDER ***************************************************************************
+
+ /// Establece el texto de copyright que mostrará el componente.
+ ///
+ /// Al pasar `Some(valor)` se sobrescribe el texto de copyright por defecto. Al pasar `None` se
+ /// eliminará, pero en este caso es necesario especificar el tipo explícitamente:
+ ///
+ /// ```rust
+ /// use pagetop::prelude::*;
+ ///
+ /// let p1 = PoweredBy::default().with_copyright(Some("2001 © Foo Inc."));
+ /// let p2 = PoweredBy::new().with_copyright(None::);
+ /// ```
+ #[builder_fn]
+ pub fn with_copyright(mut self, copyright: Option>) -> Self {
+ self.copyright = copyright.map(Into::into);
+ self
+ }
+
+ // PoweredBy GETTERS ***************************************************************************
+
+ /// Devuelve el texto de copyright actual, si existe.
+ pub fn copyright(&self) -> Option<&str> {
+ self.copyright.as_deref()
+ }
+}
diff --git a/src/base/extension.rs b/src/base/extension.rs
index 49e408d..1f94fe2 100644
--- a/src/base/extension.rs
+++ b/src/base/extension.rs
@@ -1,4 +1,4 @@
-//! Extensiones para funcionalidades avanzadas de `PageTop`.
+//! Extensiones para funcionalidades avanzadas de PageTop.
mod welcome;
pub use welcome::Welcome;
diff --git a/src/base/extension/welcome.rs b/src/base/extension/welcome.rs
index 3dda43e..5c6fec5 100644
--- a/src/base/extension/welcome.rs
+++ b/src/base/extension/welcome.rs
@@ -1,6 +1,6 @@
use crate::prelude::*;
-/// Página de bienvenida predeterminada de `PageTop`.
+/// Página de bienvenida predeterminada de PageTop.
///
/// Esta extensión se instala por defecto y muestra una página en la ruta raíz (`/`) cuando no se ha
/// configurado ninguna página de inicio personalizada. Permite confirmar que el servidor está
@@ -24,93 +24,32 @@ impl Extension for Welcome {
async fn homepage(request: HttpRequest) -> ResultPage {
let app = &global::SETTINGS.app.name;
- Page::new(Some(request))
- .with_title(L10n::l("welcome_page"))
+ Page::new(request)
.with_theme("Basic")
- .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css")))
- .with_component(Html::with(move |cx| html! {
- div id="main-header" {
- header {
- h1
- id="header-title"
- aria-label=(L10n::l("welcome_aria").with_arg("app", app).to_markup(cx))
- {
- span { (L10n::l("welcome_title").to_markup(cx)) }
- (L10n::l("welcome_intro").with_arg("app", app).to_markup(cx))
+ .with_layout("PageTopIntro")
+ .with_title(L10n::l("welcome_title"))
+ .with_description(L10n::l("welcome_intro").with_arg("app", app))
+ .with_param("intro_button_txt", L10n::l("welcome_powered"))
+ .with_param("intro_button_lnk", "https://pagetop.cillero.es".to_string())
+ .add_component(
+ Block::new()
+ .with_title(L10n::l("welcome_status_title"))
+ .add_component(Html::with(move |cx| {
+ html! {
+ p { (L10n::l("welcome_status_1").using(cx)) }
+ p { (L10n::l("welcome_status_2").using(cx)) }
}
- }
- aside id="header-image" aria-hidden="true" {
- div id="monster" {
- picture {
- source
- type="image/avif"
- src="/img/monster-pagetop_250.avif"
- srcset="/img/monster-pagetop_500.avif 1.5x";
- source
- type="image/webp"
- src="/img/monster-pagetop_250.webp"
- srcset="/img/monster-pagetop_500.webp 1.5x";
- img
- src="/img/monster-pagetop_250.png"
- srcset="/img/monster-pagetop_500.png 1.5x"
- alt="Monster PageTop";
- }
+ })),
+ )
+ .add_component(
+ Block::new()
+ .with_title(L10n::l("welcome_support_title"))
+ .add_component(Html::with(move |cx| {
+ html! {
+ p { (L10n::l("welcome_support_1").using(cx)) }
+ p { (L10n::l("welcome_support_2").with_arg("app", app).using(cx)) }
}
- }
- }
-
- main id="main-content" {
- section class="content-body" {
- div id="poweredby-button" {
- a
- id="poweredby-link"
- href="https://pagetop.cillero.es"
- target="_blank"
- rel="noreferrer"
- {
- span {} span {} span {}
- div id="poweredby-text" { (L10n::l("welcome_powered").to_markup(cx)) }
- }
- }
- div class="content-text" {
- p { (L10n::l("welcome_text1").to_markup(cx)) }
- p { (L10n::l("welcome_text2").to_markup(cx)) }
-
- div class="subcontent" {
- h1 { span { (L10n::l("welcome_about").to_markup(cx)) } }
- p { (L10n::l("welcome_pagetop").to_markup(cx)) }
- p { (L10n::l("welcome_issues1").to_markup(cx)) }
- p { (L10n::l("welcome_issues2").with_arg("app", app).to_markup(cx)) }
- }
- }
- }
- }
-
- footer id="footer" {
- section class="footer-inner" {
- div class="footer-logo" {
- svg
- viewBox="0 0 1614 1614"
- xmlns="http://www.w3.org/2000/svg"
- role="img"
- aria-label=[L10n::l("pagetop_logo").using(cx)]
- preserveAspectRatio="xMidYMid slice"
- focusable="false"
- {
- path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {}
- path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {}
- path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {}
- }
- }
- div class="footer-links" {
- a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") }
- a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") }
- a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("welcome_code").to_markup(cx)) }
- em { (L10n::l("welcome_have_fun").to_markup(cx)) }
- }
- }
- }
-
- }))
+ })),
+ )
.render()
}
diff --git a/src/base/theme.rs b/src/base/theme.rs
index ea9eeb6..40129bf 100644
--- a/src/base/theme.rs
+++ b/src/base/theme.rs
@@ -1,4 +1,4 @@
-//! Temas básicos soportados por `PageTop`.
+//! Temas básicos soportados por PageTop.
mod basic;
pub use basic::Basic;
diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs
index b02abfb..2f49274 100644
--- a/src/base/theme/basic.rs
+++ b/src/base/theme/basic.rs
@@ -1,8 +1,33 @@
-//! Es el tema básico que incluye `PageTop` por defecto.
-
+/// Es el tema básico que incluye PageTop por defecto.
use crate::prelude::*;
/// Tema básico por defecto.
+///
+/// Ofrece las siguientes composiciones (*layouts*):
+///
+/// - **Composición predeterminada**
+/// - Renderizado genérico con
+/// [`ThemePage::render_body()`](crate::core::theme::ThemePage::render_body) usando las regiones
+/// predefinidas en [`page_regions()`](crate::core::theme::Theme::page_regions).
+///
+/// - **`Intro`**
+/// - Página de entrada con cabecera visual, título y descripción y un botón opcional de llamada a
+/// la acción. Ideal para una página de inicio o bienvenida en el contexto de PageTop.
+/// - **Regiones:** `content` (se renderiza dentro de `.intro-content__body`).
+/// - **Parámetros:**
+/// - `intro_button_txt` (`L10n`) – Texto del botón.
+/// - `intro_button_lnk` (`Option`) – URL del botón; si no se indica, el botón no se
+/// muestra.
+///
+/// - **`PageTopIntro`**
+/// - Variante de `Intro` con textos predefinidos sobre PageTop al inicio del contenido. Añade una
+/// banda de *badges* con la versión de [PageTop en crates.io](https://crates.io/crates/pagetop)
+/// más la fecha de la última versión publicada y la licencia de uso.
+/// - **Regiones:** `content` (igual que `Intro`).
+/// - **Parámetros:** los mismos que `Intro`.
+///
+/// **Nota:** si no se especifica `layout` o el valor no coincide con ninguno de los anteriores, se
+/// aplica la composición predeterminada.
pub struct Basic;
impl Extension for Basic {
@@ -12,11 +37,152 @@ impl Extension for Basic {
}
impl Theme for Basic {
+ fn render_page_body(&self, page: &mut Page) -> Markup {
+ match page.layout() {
+ "Intro" => render_intro(page),
+ "PageTopIntro" => render_pagetop_intro(page),
+ _ => ::render_body(self, page, self.page_regions()),
+ }
+ }
+
fn after_render_page_body(&self, page: &mut Page) {
+ let styles = match page.layout() {
+ "Intro" => "/css/intro.css",
+ "PageTopIntro" => "/css/intro.css",
+ _ => "/css/basic.css",
+ };
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
+ ))
+ .alter_assets(AssetsOp::AddStyleSheet(
+ StyleSheet::from(styles)
+ .with_version(env!("CARGO_PKG_VERSION"))
+ .with_weight(-99),
));
}
}
+
+fn render_intro(page: &mut Page) -> Markup {
+ let title = page.title().unwrap_or_default();
+ let intro = page.description().unwrap_or_default();
+
+ let intro_button_txt: L10n = page.param_or_default("intro_button_txt");
+ let intro_button_lnk: Option<&String> = page.param("intro_button_lnk");
+
+ html! {
+ body id=[page.body_id().get()] class=[page.body_classes().get()] {
+ header class="intro-header" {
+ section class="intro-header__body" {
+ h1 class="intro-header__title" {
+ span { (title) }
+ (intro)
+ }
+ }
+ aside class="intro-header__image" aria-hidden="true" {
+ div class="intro-header__monster" {
+ picture {
+ source
+ type="image/avif"
+ src="/img/monster-pagetop_250.avif"
+ srcset="/img/monster-pagetop_500.avif 1.5x";
+ source
+ type="image/webp"
+ src="/img/monster-pagetop_250.webp"
+ srcset="/img/monster-pagetop_500.webp 1.5x";
+ img
+ src="/img/monster-pagetop_250.png"
+ srcset="/img/monster-pagetop_500.png 1.5x"
+ alt="Monster PageTop";
+ }
+ }
+ }
+ }
+ main class="intro-content" {
+ section class="intro-content__body" {
+ @if intro_button_lnk.is_some() {
+ div class="intro-button" {
+ a
+ class="intro-button__link"
+ href=[intro_button_lnk]
+ target="_blank"
+ rel="noreferrer"
+ {
+ span {} span {} span {}
+ div class="intro-button__text" {
+ (intro_button_txt.using(page))
+ }
+ }
+ }
+ }
+ div class="intro-text" { (page.render_region("content")) }
+ }
+ }
+ footer class="intro-footer" {
+ section class="intro-footer__body" {
+ div class="intro-footer__logo" {
+ svg
+ viewBox="0 0 1614 1614"
+ xmlns="http://www.w3.org/2000/svg"
+ role="img"
+ aria-label=[L10n::l("pagetop_logo").lookup(page)]
+ preserveAspectRatio="xMidYMid slice"
+ focusable="false"
+ {
+ path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {}
+ path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {}
+ path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {}
+ }
+ }
+ div class="intro-footer__links" {
+ a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") }
+ a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") }
+ a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("intro_code").using(page)) }
+ em { (L10n::l("intro_have_fun").using(page)) }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_pagetop_intro(page: &mut Page) -> Markup {
+ page.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx|
+ util::indoc!(r#"
+ try {
+ const resp = await fetch("https://crates.io/api/v1/crates/pagetop");
+ const data = await resp.json();
+ const date = new Date(data.versions[0].created_at);
+ const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" });
+ document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`;
+ document.getElementById("intro-badges").style.display = "block";
+ } catch (e) {
+ console.error("Failed to fetch release date from crates.io:", e);
+ }
+ "#)
+ .replace("LANGID", cx.langid().to_string().as_str())
+ .replace("LABEL", L10n::l("intro_release_label").using(cx).as_str())
+ .to_string(),
+ )))
+ .alter_child_in("content", ChildOp::Prepend(Child::with(Html::with(|cx| html! {
+ p { (L10n::l("intro_text1").using(cx)) }
+ div id="intro-badges" style="display: none; margin-bottom: 1.1rem;" {
+ img
+ src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge"
+ alt=[L10n::l("intro_pagetop_label").lookup(cx)] {} (" ")
+ img
+ id="intro-release"
+ alt=[L10n::l("intro_release_label").lookup(cx)] {} (" ")
+ img
+ src=(format!(
+ "https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge",
+ L10n::l("intro_license_label").lookup(cx).unwrap_or_default()
+ ))
+ alt=[L10n::l("intro_license_label").lookup(cx)] {}
+ }
+ p { (L10n::l("intro_text2").using(cx)) }
+ }))));
+
+ render_intro(page)
+}
diff --git a/src/config.rs b/src/config.rs
index 27cf630..f2fb9f7 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -3,7 +3,7 @@
//! Estos ajustes se obtienen de archivos [TOML](https://toml.io) como pares `clave = valor` que se
//! mapean a estructuras **fuertemente tipadas** y valores predefinidos.
//!
-//! Siguiendo la metodología [Twelve-Factor App](https://12factor.net/config), `PageTop` separa el
+//! Siguiendo la metodología [Twelve-Factor App](https://12factor.net/config), PageTop separa el
//! **código** de la **configuración**, lo que permite tener configuraciones diferentes para cada
//! despliegue, como *dev*, *staging* o *production*, sin modificar el código fuente.
//!
@@ -13,14 +13,14 @@
//! Si tu aplicación necesita archivos de configuración, crea un directorio `config` en la raíz del
//! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación.
//!
-//! `PageTop` carga en este orden, y siempre de forma opcional, los siguientes archivos TOML:
+//! PageTop carga en este orden, y siempre de forma opcional, los siguientes archivos TOML:
//!
//! 1. **config/common.toml**, para ajustes comunes a todos los entornos. Este enfoque simplifica el
//! mantenimiento al centralizar los valores de configuración comunes.
//!
//! 2. **config/{rm}.toml**, donde `{rm}` es el valor de la variable de entorno `PAGETOP_RUN_MODE`:
//!
-//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y `PageTop` intentará
+//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y PageTop intentará
//! cargar *config/default.toml* si el archivo existe.
//!
//! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.ej.
diff --git a/src/core.rs b/src/core.rs
index 0c8aa21..79d9207 100644
--- a/src/core.rs
+++ b/src/core.rs
@@ -117,7 +117,7 @@ impl TypeInfo {
///
/// Este *trait* se implementa automáticamente para **todos** los tipos que implementen [`Any`], de
/// modo que basta con traer [`AnyInfo`] al ámbito (`use crate::AnyInfo;`) para disponer de estos
-/// métodos adicionales, o usar el [`prelude`](crate::prelude) de `PageTop`.
+/// métodos adicionales, o usar el [`prelude`](crate::prelude) de PageTop.
///
/// # Ejemplo
///
diff --git a/src/core/component.rs b/src/core/component.rs
index 17b9b73..3691472 100644
--- a/src/core/component.rs
+++ b/src/core/component.rs
@@ -7,3 +7,6 @@ mod children;
pub use children::Children;
pub use children::{Child, ChildOp};
pub use children::{Typed, TypedOp};
+
+mod slot;
+pub use slot::TypedSlot;
diff --git a/src/core/component/children.rs b/src/core/component/children.rs
index fb85db7..cb112e1 100644
--- a/src/core/component/children.rs
+++ b/src/core/component/children.rs
@@ -9,13 +9,13 @@ use std::vec::IntoIter;
/// Representa un componente encapsulado de forma segura y compartida.
///
-/// Esta estructura permite manipular y renderizar cualquier tipo que implemente [`Component`],
-/// garantizando acceso concurrente a través de [`Arc>`].
+/// Esta estructura permite manipular y renderizar un componente que implemente [`Component`], y
+/// habilita acceso concurrente mediante [`Arc>`].
#[derive(Clone)]
pub struct Child(Arc>);
impl Child {
- /// Crea un nuevo [`Child`] a partir de un componente.
+ /// Crea un nuevo `Child` a partir de un componente.
pub fn with(component: impl Component) -> Self {
Child(Arc::new(RwLock::new(component)))
}
@@ -46,7 +46,8 @@ impl Child {
/// Variante tipada de [`Child`] para evitar conversiones durante el uso.
///
-/// Facilita el acceso a componentes del mismo tipo sin necesidad de hacer `downcast`.
+/// Esta estructura permite manipular y renderizar un componente concreto que implemente
+/// [`Component`], y habilita acceso concurrente mediante [`Arc>`].
pub struct Typed(Arc>);
impl Clone for Typed {
@@ -56,7 +57,7 @@ impl Clone for Typed {
}
impl Typed {
- /// Crea un nuevo [`Typed`] a partir de un componente.
+ /// Crea un nuevo `Typed` a partir de un componente.
pub fn with(component: C) -> Self {
Typed(Arc::new(RwLock::new(component)))
}
@@ -284,7 +285,7 @@ impl IntoIterator for Children {
///
/// # Ejemplo de uso:
///
- /// ```rust#ignore
+ /// ```rust,ignore
/// let children = Children::new().with(child1).with(child2);
/// for child in children {
/// println!("{:?}", child.id());
@@ -303,7 +304,7 @@ impl<'a> IntoIterator for &'a Children {
///
/// # Ejemplo de uso:
///
- /// ```rust#ignore
+ /// ```rust,ignore
/// let children = Children::new().with(child1).with(child2);
/// for child in &children {
/// println!("{:?}", child.id());
@@ -322,7 +323,7 @@ impl<'a> IntoIterator for &'a mut Children {
///
/// # Ejemplo de uso:
///
- /// ```rust#ignore
+ /// ```rust,ignore
/// let mut children = Children::new().with(child1).with(child2);
/// for child in &mut children {
/// child.render(&mut context);
diff --git a/src/core/component/definition.rs b/src/core/component/definition.rs
index 2818570..333cf69 100644
--- a/src/core/component/definition.rs
+++ b/src/core/component/definition.rs
@@ -1,6 +1,6 @@
use crate::base::action;
use crate::core::{AnyInfo, TypeInfo};
-use crate::html::{html, Context, Markup, PrepareMarkup, Render};
+use crate::html::{html, Context, Markup, PrepareMarkup};
/// Define la función de renderizado para todos los componentes.
///
@@ -11,7 +11,7 @@ pub trait ComponentRender {
fn render(&mut self, cx: &mut Context) -> Markup;
}
-/// Interfaz común que debe implementar un componente renderizable en `PageTop`.
+/// Interfaz común que debe implementar un componente renderizable en PageTop.
///
/// Se recomienda que los componentes deriven [`AutoDefault`](crate::AutoDefault). También deben
/// implementar explícitamente el método [`new()`](Self::new) y pueden sobrescribir los otros
@@ -29,14 +29,14 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync {
TypeInfo::ShortName.of::()
}
- /// Devuelve una descripción opcional del componente.
+ /// Devuelve una descripción del componente, si existe.
///
/// Por defecto, no se proporciona ninguna descripción (`None`).
fn description(&self) -> Option {
None
}
- /// Devuelve un identificador opcional para el componente.
+ /// Devuelve el identificador del componente, si existe.
///
/// Este identificador puede usarse para referenciar el componente en el HTML. Por defecto, no
/// tiene ningún identificador (`None`).
@@ -51,12 +51,17 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync {
#[allow(unused_variables)]
fn setup_before_prepare(&mut self, cx: &mut Context) {}
- /// Devuelve una representación estructurada del componente lista para renderizar.
+ /// Devuelve una representación renderizada del componente.
///
/// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente
/// durante el proceso de construcción del documento. Puede sobrescribirse para generar
/// dinámicamente el contenido HTML con acceso al contexto de renderizado.
///
+ /// Este método debe ser capaz de preparar el renderizado del componente con los métodos del
+ /// propio componente y el contexto proporcionado, no debería hacerlo accediendo directamente a
+ /// los campos de la estructura del componente. Es una forma de garantizar que los programadores
+ /// podrán sobrescribir este método sin preocuparse por los detalles internos del componente.
+ ///
/// Por defecto, devuelve [`PrepareMarkup::None`].
#[allow(unused_variables)]
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
diff --git a/src/core/component/slot.rs b/src/core/component/slot.rs
new file mode 100644
index 0000000..19ed72a
--- /dev/null
+++ b/src/core/component/slot.rs
@@ -0,0 +1,64 @@
+use crate::builder_fn;
+use crate::core::component::{Component, Typed};
+use crate::html::{html, Context, Markup};
+
+/// Contenedor para un componente [`Typed`] opcional.
+///
+/// Un `TypedSlot` actúa como un contenedor dentro de otro componente para incluir o no un
+/// subcomponente. Internamente encapsula `Option>`, pero proporciona una API más sencilla
+/// para construir estructuras jerárquicas.
+///
+/// # Ejemplo
+///
+/// ```rust,ignore
+/// use pagetop::prelude::*;
+///
+/// let comp = MyComponent::new();
+/// let opt = TypedSlot::new(comp);
+/// assert!(opt.get().is_some());
+/// ```
+pub struct TypedSlot(Option>);
+
+impl Default for TypedSlot {
+ fn default() -> Self {
+ TypedSlot(None)
+ }
+}
+
+impl TypedSlot {
+ /// Crea un nuevo [`TypedSlot`].
+ ///
+ /// El componente se envuelve automáticamente en un [`Typed`] y se almacena.
+ pub fn new(component: C) -> Self {
+ TypedSlot(Some(Typed::with(component)))
+ }
+
+ // TypedSlot BUILDER *********************************************************************
+
+ /// Establece un componente nuevo, o lo vacía.
+ ///
+ /// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia.
+ #[builder_fn]
+ pub fn with_value(mut self, component: Option) -> Self {
+ self.0 = component.map(Typed::with);
+ self
+ }
+
+ // TypedSlot GETTERS *********************************************************************
+
+ /// Devuelve un clon (incrementa el contador `Arc`) de [`Typed`], si existe.
+ pub fn get(&self) -> Option> {
+ self.0.clone()
+ }
+
+ // TypedSlot RENDER ************************************************************************
+
+ /// Renderiza el componente, si existe.
+ pub fn render(&self, cx: &mut Context) -> Markup {
+ if let Some(component) = &self.0 {
+ component.render(cx)
+ } else {
+ html! {}
+ }
+ }
+}
diff --git a/src/core/extension.rs b/src/core/extension.rs
index cabae5c..6ae6d33 100644
--- a/src/core/extension.rs
+++ b/src/core/extension.rs
@@ -1,6 +1,6 @@
//! API para añadir nuevas funcionalidades usando extensiones.
//!
-//! Cada funcionalidad adicional que quiera incorporarse a una aplicación `PageTop` se debe modelar
+//! Cada funcionalidad adicional que quiera incorporarse a una aplicación PageTop se debe modelar
//! como una **extensión**. Todas comparten la misma interfaz declarada en [`Extension`].
mod definition;
diff --git a/src/core/extension/definition.rs b/src/core/extension/definition.rs
index ac29259..90bdbad 100644
--- a/src/core/extension/definition.rs
+++ b/src/core/extension/definition.rs
@@ -10,7 +10,7 @@ use crate::{actions_boxed, service};
/// cualquier hilo de la ejecución sin necesidad de sincronización adicional.
pub type ExtensionRef = &'static dyn Extension;
-/// Interfaz común que debe implementar cualquier extensión de `PageTop`.
+/// Interfaz común que debe implementar cualquier extensión de PageTop.
///
/// Este *trait* es fácil de implementar, basta con declarar una estructura de tamaño cero para la
/// extensión y sobreescribir los métodos que sea necesario.
@@ -26,7 +26,7 @@ pub type ExtensionRef = &'static dyn Extension;
/// }
/// ```
pub trait Extension: AnyInfo + Send + Sync {
- /// Nombre legible para el usuario.
+ /// Nombre localizado de la extensión legible para el usuario.
///
/// Predeterminado por el [`short_name()`](AnyInfo::short_name) del tipo asociado a la
/// extensión.
@@ -34,18 +34,15 @@ pub trait Extension: AnyInfo + Send + Sync {
L10n::n(self.short_name())
}
- /// Descripción corta para paneles, listados, etc.
+ /// Descripción corta localizada de la extensión para paneles, listados, etc.
fn description(&self) -> L10n {
L10n::default()
}
- /// Los temas son extensiones que implementan [`Extension`] y también
- /// [`Theme`](crate::core::theme::Theme).
+ /// Devuelve una referencia a esta misma extensión cuando se trata de un tema.
///
- /// Si la extensión no es un tema, este método devuelve `None` por defecto.
- ///
- /// En caso contrario, este método debe implementarse para devolver una referencia de sí mismo
- /// como tema. Por ejemplo:
+ /// Para ello, debe implementar [`Extension`] y también [`Theme`](crate::core::theme::Theme). Si
+ /// la extensión no es un tema, este método devuelve `None` por defecto.
///
/// ```rust
/// use pagetop::prelude::*;
@@ -66,7 +63,7 @@ pub trait Extension: AnyInfo + Send + Sync {
/// Otras extensiones que deben habilitarse **antes** de esta.
///
- /// `PageTop` las resolverá automáticamente respetando el orden durante el arranque de la
+ /// PageTop las resolverá automáticamente respetando el orden durante el arranque de la
/// aplicación.
fn dependencies(&self) -> Vec {
vec![]
@@ -81,7 +78,7 @@ pub trait Extension: AnyInfo + Send + Sync {
actions_boxed![]
}
- /// Inicializa la extensión durante la lógica de arranque de la aplicación.
+ /// Inicializa la extensión durante la fase de arranque de la aplicación.
///
/// Se llama una sola vez, después de que todas las dependencias se han inicializado y antes de
/// aceptar cualquier petición HTTP.
@@ -104,8 +101,8 @@ pub trait Extension: AnyInfo + Send + Sync {
#[allow(unused_variables)]
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {}
- /// Permite crear extensiones para deshabilitar y desinstalar los recursos de otras extensiones
- /// utilizadas en versiones anteriores de la aplicación.
+ /// Permite crear extensiones para deshabilitar y desinstalar recursos de otras de versiones
+ /// anteriores de la aplicación.
///
/// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones.
fn drop_extensions(&self) -> Vec {
diff --git a/src/core/theme.rs b/src/core/theme.rs
index 0a0f819..61d820b 100644
--- a/src/core/theme.rs
+++ b/src/core/theme.rs
@@ -1,26 +1,24 @@
//! API para añadir y gestionar nuevos temas.
//!
-//! En `PageTop` un tema es la *piel* de la aplicación, decide cómo se muestra cada documento HTML,
+//! En PageTop un tema es la *piel* de la aplicación, decide cómo se muestra cada documento HTML,
//! especialmente las páginas de contenido ([`Page`](crate::response::page::Page)), sin alterar la
//! lógica interna de sus componentes.
//!
//! Un tema **declara las regiones** (*cabecera*, *barra lateral*, *pie*, etc.) que estarán
//! disponibles para colocar contenido. Los temas son responsables últimos de los estilos,
//! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (como animaciones,
-//! *scripts* de interfaz, etc.).
+//! scripts de interfaz, etc.).
//!
-//! Es una extensión más (implementando [`Extension`](crate::core::extension::Extension)). Se
-//! instala, activa y declara dependencias igual que el resto de extensiones; y se señala a sí misma
-//! como tema (implementando [`theme()`](crate::core::extension::Extension::theme) y [`Theme`]).
+//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension); por
+//! lo que se instancian, declaran sus dependencias y se inician igual que el resto de extensiones;
+//! pero serán temas si además implementan [`theme()`](crate::core::extension::Extension::theme) y
+//! [`Theme`].
mod definition;
-pub use definition::{Theme, ThemeRef};
+pub use definition::{Theme, ThemePage, ThemeRef};
mod regions;
-pub(crate) use regions::ChildrenInRegions;
-pub use regions::InRegion;
+pub(crate) use regions::{ChildrenInRegions, REGION_CONTENT};
+pub use regions::{InRegion, Region};
pub(crate) mod all;
-
-/// Nombre de la región por defecto: `content`.
-pub const CONTENT_REGION_NAME: &str = "content";
diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs
index 8de88bd..38a0bfc 100644
--- a/src/core/theme/definition.rs
+++ b/src/core/theme/definition.rs
@@ -1,52 +1,49 @@
use crate::core::extension::Extension;
-use crate::core::theme::CONTENT_REGION_NAME;
+use crate::core::theme::Region;
use crate::global;
use crate::html::{html, Markup};
use crate::locale::L10n;
use crate::response::page::Page;
-/// Representa una referencia a un tema.
+use std::sync::LazyLock;
+
+/// Referencia estática a un tema.
///
-/// Los temas son también extensiones. Por tanto se deben definir igual, es decir, como instancias
-/// estáticas globales que implementan [`Theme`], pero también [`Extension`].
+/// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que
+/// implementen [`Theme`] y, a su vez, [`Extension`].
pub type ThemeRef = &'static dyn Theme;
-/// Interfaz común que debe implementar cualquier tema de `PageTop`.
+/// Métodos predefinidos de renderizado para las páginas de un tema.
///
-/// Un tema implementará [`Theme`] y los métodos que sean necesarios de [`Extension`], aunque el
-/// único obligatorio es [`theme()`](Extension::theme).
+/// Contiene las implementaciones base de las **secciones** `` y ``. Se implementa
+/// automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente no requiere
+/// implementación explícita.
///
-/// ```rust
-/// use pagetop::prelude::*;
+/// Si un tema **sobrescribe** [`render_page_head()`](Theme::render_page_head) o
+/// [`render_page_body()`](Theme::render_page_body), se puede volver al comportamiento por defecto
+/// cuando se necesite usando FQS (*Fully Qualified Syntax*):
///
-/// pub struct MyTheme;
-///
-/// impl Extension for MyTheme {
-/// fn name(&self) -> L10n { L10n::n("My theme") }
-/// fn description(&self) -> L10n { L10n::n("Un tema personal") }
-///
-/// fn theme(&self) -> Option {
-/// Some(&Self)
-/// }
-/// }
-///
-/// impl Theme for MyTheme {}
-/// ```
-pub trait Theme: Extension + Send + Sync {
- fn regions(&self) -> Vec<(&'static str, L10n)> {
- vec![(CONTENT_REGION_NAME, L10n::l("content"))]
- }
-
- #[allow(unused_variables)]
- fn before_render_page_body(&self, page: &mut Page) {}
-
- fn render_page_body(&self, page: &mut Page) -> Markup {
+/// - `::render_body(self, page, self.page_regions())`
+/// - `::render_head(self, page)`
+pub trait ThemePage {
+ /// Renderiza el contenido del `` de la página.
+ ///
+ /// Recorre `regions` en el **orden declarado** y, para cada región con contenido, genera un
+ /// contenedor con `role="region"` y un `aria-label` localizado. Se asume que cada identificador
+ /// de región es **único** dentro de la página.
+ fn render_body(&self, page: &mut Page, regions: &[(Region, L10n)]) -> Markup {
html! {
body id=[page.body_id().get()] class=[page.body_classes().get()] {
- @for (region_name, _) in self.regions() {
- @let output = page.render_region(region_name);
+ @for (region, region_label) in regions {
+ @let output = page.render_region(region.key());
@if !output.is_empty() {
- div id=(region_name) class={ "region-container region-" (region_name) } {
+ @let region_name = region.name();
+ div
+ id=(region_name)
+ class={ "region region--" (region_name) }
+ role="region"
+ aria-label=[region_label.lookup(page)]
+ {
(output)
}
}
@@ -55,10 +52,12 @@ pub trait Theme: Extension + Send + Sync {
}
}
- #[allow(unused_variables)]
- fn after_render_page_body(&self, page: &mut Page) {}
-
- fn render_page_head(&self, page: &mut Page) -> Markup {
+ /// Renderiza el contenido del `` de la página.
+ ///
+ /// Por defecto incluye las etiquetas básicas (`charset`, `title`, `description`, `viewport`,
+ /// `X-UA-Compatible`), los metadatos (`name/content`) y propiedades (`property/content`),
+ /// además de los recursos CSS/JS de la página.
+ fn render_head(&self, page: &mut Page) -> Markup {
let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no";
html! {
head {
@@ -88,12 +87,115 @@ pub trait Theme: Extension + Send + Sync {
}
}
}
+}
- fn error403(&self, _page: &mut Page) -> Markup {
- html! { div { h1 { ("FORBIDDEN ACCESS") } } }
+/// Interfaz común que debe implementar cualquier tema de PageTop.
+///
+/// Un tema implementa [`Theme`] y los métodos necesarios de [`Extension`]. El único método
+/// **obligatorio** de `Extension` para un tema es [`theme()`](Extension::theme).
+///
+/// ```rust
+/// use pagetop::prelude::*;
+///
+/// pub struct MyTheme;
+///
+/// impl Extension for MyTheme {
+/// fn name(&self) -> L10n {
+/// L10n::n("My theme")
+/// }
+///
+/// fn description(&self) -> L10n {
+/// L10n::n("A personal theme")
+/// }
+///
+/// fn theme(&self) -> Option {
+/// Some(&Self)
+/// }
+/// }
+///
+/// impl Theme for MyTheme {}
+/// ```
+pub trait Theme: Extension + ThemePage + Send + Sync {
+ /// **Obsoleto desde la versión 0.4.0**: usar [`page_regions()`](Self::page_regions) en su
+ /// lugar.
+ #[deprecated(since = "0.4.0", note = "Use `page_regions()` instead")]
+ fn regions(&self) -> Vec<(&'static str, L10n)> {
+ vec![("content", L10n::l("content"))]
}
- fn error404(&self, _page: &mut Page) -> Markup {
- html! { div { h1 { ("RESOURCE NOT FOUND") } } }
+ /// Declaración ordenada de las regiones disponibles en la página.
+ ///
+ /// Devuelve una **lista estática** de pares `(Region, L10n)` que se usará para renderizar todas
+ /// las regiones que componen una página en el orden indicado .
+ ///
+ /// Si un tema necesita un conjunto distinto de regiones, se puede **sobrescribir** este método
+ /// con los siguientes requisitos y recomendaciones:
+ ///
+ /// - Los identificadores deben ser **estables** (p. ej. `"sidebar-left"`, `"content"`).
+ /// - La región `"content"` es **obligatoria**. Se puede usar [`Region::default()`] para
+ /// declararla.
+ /// - La etiqueta `L10n` se evalúa con el idioma activo de la página.
+ ///
+ /// Por defecto devuelve:
+ ///
+ /// - `"header"`: cabecera.
+ /// - `"content"`: contenido principal (**obligatoria**).
+ /// - `"footer"`: pie.
+ fn page_regions(&self) -> &'static [(Region, L10n)] {
+ static REGIONS: LazyLock<[(Region, L10n); 3]> = LazyLock::new(|| {
+ [
+ (Region::declare("header"), L10n::l("region_header")),
+ (Region::default(), L10n::l("region_content")),
+ (Region::declare("footer"), L10n::l("region_footer")),
+ ]
+ });
+ ®IONS[..]
+ }
+
+ /// Acciones específicas del tema antes de renderizar el `` de la página.
+ ///
+ /// Útil para preparar clases, inyectar recursos o ajustar metadatos.
+ #[allow(unused_variables)]
+ fn before_render_page_body(&self, page: &mut Page) {}
+
+ /// Renderiza el contenido del `` de la página.
+ ///
+ /// Si se sobrescribe este método, se puede volver al comportamiento base con:
+ /// `::render_body(self, page, self.page_regions())`.
+ #[inline]
+ fn render_page_body(&self, page: &mut Page) -> Markup {
+ ::render_body(self, page, self.page_regions())
+ }
+
+ /// Acciones específicas del tema después de renderizar el `` de la página.
+ ///
+ /// Útil para *tracing*, métricas o ajustes finales del estado de la página.
+ #[allow(unused_variables)]
+ fn after_render_page_body(&self, page: &mut Page) {}
+
+ /// Renderiza el contenido del `` de la página.
+ ///
+ /// Si se sobrescribe este método, se puede volver al comportamiento base con:
+ /// `::render_head(self, page)`.
+ #[inline]
+ fn render_page_head(&self, page: &mut Page) -> Markup {
+ ::render_head(self, page)
+ }
+
+ /// Contenido predeterminado para la página de error "*403 – Forbidden*".
+ ///
+ /// Se puede sobrescribir este método para personalizar y adaptar este contenido al tema.
+ fn error403(&self, page: &mut Page) -> Markup {
+ html! { div { h1 { (L10n::l("error403_notice").using(page)) } } }
+ }
+
+ /// Contenido predeterminado para la página de error "*404 – Not Found*".
+ ///
+ /// Se puede sobrescribir este método para personalizar y adaptar este contenido al tema.
+ fn error404(&self, page: &mut Page) -> Markup {
+ html! { div { h1 { (L10n::l("error404_notice").using(page)) } } }
}
}
+
+/// Se implementa automáticamente `ThemePage` para cualquier tema.
+impl ThemePage for T {}
diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs
index 22ab6f2..8082aac 100644
--- a/src/core/theme/regions.rs
+++ b/src/core/theme/regions.rs
@@ -1,5 +1,5 @@
use crate::core::component::{Child, ChildOp, Children};
-use crate::core::theme::{ThemeRef, CONTENT_REGION_NAME};
+use crate::core::theme::ThemeRef;
use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock;
@@ -7,25 +7,81 @@ use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::LazyLock;
-// Regiones globales con componentes para un tema dado.
+// Conjunto de regiones globales asociadas a un tema específico.
static THEME_REGIONS: LazyLock>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
-// Regiones globales con componentes para cualquier tema.
+// Conjunto de regiones globales comunes a todos los temas.
static COMMON_REGIONS: LazyLock> =
LazyLock::new(|| RwLock::new(ChildrenInRegions::default()));
-// Estructura interna para mantener los componentes de una región.
+/// Nombre de la región de contenido por defecto (`"content"`).
+pub const REGION_CONTENT: &str = "content";
+
+/// Identificador de una región de página.
+///
+/// Incluye una **clave estática** ([`key()`](Self::key)) que identifica la región en el tema, y un
+/// **nombre normalizado** ([`name()`](Self::name)) en minúsculas para su uso en atributos HTML
+/// (p.ej., clases `region__{name}`).
+///
+/// Se utiliza para declarar las regiones que componen una página en un tema (ver
+/// [`page_regions()`](crate::core::theme::Theme::page_regions)).
+pub struct Region {
+ key: &'static str,
+ name: String,
+}
+
+impl Default for Region {
+ #[inline]
+ fn default() -> Self {
+ Self {
+ key: REGION_CONTENT,
+ name: REGION_CONTENT.to_string(),
+ }
+ }
+}
+
+impl Region {
+ /// Declara una región a partir de su clave estática.
+ ///
+ /// Genera además un nombre normalizado de la clave, eliminando espacios iniciales y finales,
+ /// convirtiendo a minúsculas y sustituyendo los espacios intermedios por guiones (`-`).
+ ///
+ /// Esta clave se usará para añadir componentes a la región; por ello se recomiendan nombres
+ /// sencillos, limitando los caracteres a `[a-z0-9-]` (p.ej., `"sidebar"` o `"main-menu"`), cuyo
+ /// nombre normalizado coincidirá con la clave.
+ #[inline]
+ pub fn declare(key: &'static str) -> Self {
+ Self {
+ key,
+ name: key.trim().to_ascii_lowercase().replace(' ', "-"),
+ }
+ }
+
+ /// Devuelve la clave estática asignada a la región.
+ #[inline]
+ pub fn key(&self) -> &'static str {
+ self.key
+ }
+
+ /// Devuelve el nombre normalizado de la región (para atributos y búsquedas).
+ #[inline]
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+// Contenedor interno de componentes agrupados por región.
#[derive(AutoDefault)]
pub struct ChildrenInRegions(HashMap<&'static str, Children>);
impl ChildrenInRegions {
pub fn with(region_name: &'static str, child: Child) -> Self {
- ChildrenInRegions::default().with_child_in_region(region_name, ChildOp::Add(child))
+ ChildrenInRegions::default().with_child_in(region_name, ChildOp::Add(child))
}
#[builder_fn]
- pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
+ pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self {
if let Some(region) = self.0.get_mut(region_name) {
region.alter_child(op);
} else {
@@ -48,25 +104,24 @@ impl ChildrenInRegions {
}
}
-/// Permite añadir componentes a regiones globales o regiones de temas concretos.
+/// Punto de acceso para añadir componentes a regiones globales o específicas de un tema.
///
-/// Dada una región, según la variante seleccionada, se le podrán añadir ([`add()`](Self::add))
-/// componentes que se mantendrán durante la ejecución de la aplicación.
+/// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán
+/// disponibles durante toda la ejecución.
///
-/// Estas estructuras de componentes se renderizarán automáticamente al procesar los documentos HTML
-/// que las usan, como las páginas de contenido ([`Page`](crate::response::page::Page)), por
-/// ejemplo.
+/// Estos componentes se renderizarán automáticamente al procesar los documentos HTML que incluyen
+/// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)).
pub enum InRegion {
- /// Representa la región por defecto en la que se pueden añadir componentes.
+ /// Región de contenido por defecto.
Content,
- /// Representa la región con el nombre del argumento.
+ /// Región identificada por el nombre proporcionado.
Named(&'static str),
- /// Representa la región con el nombre y del tema especificado en los argumentos.
+ /// Región identificada por un nombre y asociada a un tema concreto.
OfTheme(&'static str, ThemeRef),
}
impl InRegion {
- /// Permite añadir un componente en la región de la variante seleccionada.
+ /// Añade un componente a la región indicada por la variante.
///
/// # Ejemplo
///
@@ -88,17 +143,17 @@ impl InRegion {
InRegion::Content => {
COMMON_REGIONS
.write()
- .alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(child));
+ .alter_child_in(REGION_CONTENT, ChildOp::Add(child));
}
- InRegion::Named(name) => {
+ InRegion::Named(region_name) => {
COMMON_REGIONS
.write()
- .alter_child_in_region(name, ChildOp::Add(child));
+ .alter_child_in(region_name, ChildOp::Add(child));
}
InRegion::OfTheme(region_name, theme_ref) => {
let mut regions = THEME_REGIONS.write();
if let Some(r) = regions.get_mut(&theme_ref.type_id()) {
- r.alter_child_in_region(region_name, ChildOp::Add(child));
+ r.alter_child_in(region_name, ChildOp::Add(child));
} else {
regions.insert(
theme_ref.type_id(),
diff --git a/src/global.rs b/src/global.rs
index 8a03589..ccc6d9d 100644
--- a/src/global.rs
+++ b/src/global.rs
@@ -50,12 +50,11 @@ pub struct App {
pub theme: String,
/// Idioma por defecto para la aplicación.
///
- /// Si no se especifica un valor válido, normalmente se usará el idioma devuelto por la
- /// implementación de [`LangId`](crate::locale::LangId) para [`Context`](crate::html::Context),
- /// en el siguiente orden: primero, el idioma establecido explícitamente con
- /// [`Context::with_langid()`](crate::html::Context::with_langid); si no se ha definido, se
- /// usará el indicado en la cabecera `Accept-Language` del navegador; y, si ninguno aplica, se
- /// empleará el idioma de respaldo ("en-US").
+ /// Si no está definido o no es válido, el idioma efectivo para el renderizado se resolverá
+ /// según la implementación de [`LangId`](crate::locale::LangId) en este orden: primero intenta
+ /// con el establecido en [`Contextual::with_langid()`](crate::html::Contextual::with_langid);
+ /// pero si no se ha definido explícitamente, usará el indicado en la cabecera `Accept-Language`
+ /// del navegador; y, si ninguno aplica, se empleará el idioma de respaldo ("en-US").
pub language: String,
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
/// *"Starwars"*.
@@ -68,7 +67,7 @@ pub struct App {
#[derive(Debug, Deserialize)]
/// Sección `[Dev]` de la configuración. Forma parte de [`Settings`].
pub struct Dev {
- /// Directorio desde el que servir los archivos estáticos de `PageTop`.
+ /// Directorio desde el que servir los archivos estáticos de PageTop.
///
/// Por defecto, los archivos se integran en el binario de la aplicación. Si aquí se indica una
/// ruta válida, ya sea absoluta o relativa al directorio del proyecto o del binario en
diff --git a/src/html.rs b/src/html.rs
index 82fa906..4858bbf 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -1,54 +1,84 @@
//! HTML en código.
mod maud;
-pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, Render, DOCTYPE};
+pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, DOCTYPE};
+
+// HTML DOCUMENT ASSETS ****************************************************************************
mod assets;
pub use assets::favicon::Favicon;
pub use assets::javascript::JavaScript;
pub use assets::stylesheet::{StyleSheet, TargetMedia};
-pub(crate) use assets::Assets;
+pub use assets::{Asset, Assets};
+
+// HTML DOCUMENT CONTEXT ***************************************************************************
mod context;
-pub use context::{AssetsOp, Context, ErrorParam};
+pub use context::{AssetsOp, Context, Contextual, ErrorParam};
-mod opt_id;
-pub use opt_id::OptionId;
+// HTML ATTRIBUTES *********************************************************************************
-mod opt_name;
-pub use opt_name::OptionName;
+mod attr_id;
+pub use attr_id::AttrId;
+/// **Obsoleto desde la versión 0.4.0**: usar [`AttrId`] en su lugar.
+#[deprecated(since = "0.4.0", note = "Use `AttrId` instead")]
+pub type OptionId = AttrId;
-mod opt_string;
-pub use opt_string::OptionString;
+mod attr_name;
+pub use attr_name::AttrName;
+/// **Obsoleto desde la versión 0.4.0**: usar [`AttrName`] en su lugar.
+#[deprecated(since = "0.4.0", note = "Use `AttrName` instead")]
+pub type OptionName = AttrName;
-mod opt_translated;
-pub use opt_translated::OptionTranslated;
+mod attr_value;
+pub use attr_value::AttrValue;
+/// **Obsoleto desde la versión 0.4.0**: usar [`AttrValue`] en su lugar.
+#[deprecated(since = "0.4.0", note = "Use `AttrValue` instead")]
+pub type OptionString = AttrValue;
-mod opt_classes;
-pub use opt_classes::{ClassesOp, OptionClasses};
+mod attr_l10n;
+pub use attr_l10n::AttrL10n;
+/// **Obsoleto desde la versión 0.4.0**: usar [`AttrL10n`] en su lugar.
+#[deprecated(since = "0.4.0", note = "Use `AttrL10n` instead")]
+pub type OptionTranslated = AttrL10n;
-mod opt_component;
-pub use opt_component::OptionComponent;
+mod attr_classes;
+pub use attr_classes::{AttrClasses, ClassesOp};
+/// **Obsoleto desde la versión 0.4.0**: usar [`AttrClasses`] en su lugar.
+#[deprecated(since = "0.4.0", note = "Use `AttrClasses` instead")]
+pub type OptionClasses = AttrClasses;
-use crate::AutoDefault;
+use crate::{core, AutoDefault};
+
+/// **Obsoleto desde la versión 0.4.0**: usar [`TypedSlot`](crate::core::component::TypedSlot) en su
+/// lugar.
+#[deprecated(
+ since = "0.4.0",
+ note = "Use `pagetop::core::component::TypedSlot` instead"
+)]
+#[allow(type_alias_bounds)]
+pub type OptionComponent = core::component::TypedSlot;
/// Prepara contenido HTML para su conversión a [`Markup`].
///
-/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML escapado o marcado
-/// ya procesado) para renderizar de forma homogénea en plantillas sin interferir con el uso
-/// estándar de [`Markup`].
+/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML sin escapar o
+/// fragmentos ya procesados) para renderizarlos de forma homogénea en plantillas, sin interferir
+/// con el uso estándar de [`Markup`].
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
-/// let fragment = PrepareMarkup::Text(String::from("Hola mundo"));
+/// // Texto normal, se escapa automáticamente para evitar inyección de HTML.
+/// let fragment = PrepareMarkup::Escaped("Hola mundo".to_string());
/// assert_eq!(fragment.render().into_string(), "Hola <b>mundo</b>");
///
-/// let raw_html = PrepareMarkup::Escaped(String::from("negrita"));
+/// // HTML literal, se inserta directamente, sin escapado adicional.
+/// let raw_html = PrepareMarkup::Raw("negrita".to_string());
/// assert_eq!(raw_html.render().into_string(), "negrita");
///
+/// // Fragmento ya preparado con la macro `html!`.
/// let prepared = PrepareMarkup::With(html! {
/// h2 { "Título de ejemplo" }
/// p { "Este es un párrafo con contenido dinámico." }
@@ -60,14 +90,22 @@ use crate::AutoDefault;
/// ```
#[derive(AutoDefault)]
pub enum PrepareMarkup {
- /// No se genera contenido HTML (devuelve `html! {}`).
+ /// No se genera contenido HTML (equivale a `html! {}`).
#[default]
None,
- /// Texto estático que se escapará automáticamente para no ser interpretado como HTML.
- Text(String),
- /// Contenido sin escapado adicional, útil para HTML generado externamente.
+ /// Texto plano que se **escapará automáticamente** para que no sea interpretado como HTML.
+ ///
+ /// Úsalo con textos que provengan de usuarios u otras fuentes externas para garantizar la
+ /// seguridad contra inyección de código.
Escaped(String),
+ /// HTML literal que se inserta **sin escapado adicional**.
+ ///
+ /// Úsalo únicamente para contenido generado de forma confiable o controlada, ya que cualquier
+ /// etiqueta o script incluido será renderizado directamente en el documento.
+ Raw(String),
/// Fragmento HTML ya preparado como [`Markup`], listo para insertarse directamente.
+ ///
+ /// Normalmente proviene de expresiones `html! { ... }`.
With(Markup),
}
@@ -76,20 +114,18 @@ impl PrepareMarkup {
pub fn is_empty(&self) -> bool {
match self {
PrepareMarkup::None => true,
- PrepareMarkup::Text(text) => text.is_empty(),
- PrepareMarkup::Escaped(string) => string.is_empty(),
+ PrepareMarkup::Escaped(text) => text.is_empty(),
+ PrepareMarkup::Raw(string) => string.is_empty(),
PrepareMarkup::With(markup) => markup.is_empty(),
}
}
-}
-impl Render for PrepareMarkup {
/// Integra el renderizado fácilmente en la macro [`html!`].
- fn render(&self) -> Markup {
+ pub fn render(&self) -> Markup {
match self {
PrepareMarkup::None => html! {},
- PrepareMarkup::Text(text) => html! { (text) },
- PrepareMarkup::Escaped(string) => html! { (PreEscaped(string)) },
+ PrepareMarkup::Escaped(text) => html! { (text) },
+ PrepareMarkup::Raw(string) => html! { (PreEscaped(string)) },
PrepareMarkup::With(markup) => html! { (markup) },
}
}
diff --git a/src/html/assets.rs b/src/html/assets.rs
index 894b7e8..41cd471 100644
--- a/src/html/assets.rs
+++ b/src/html/assets.rs
@@ -2,25 +2,55 @@ pub mod favicon;
pub mod javascript;
pub mod stylesheet;
-use crate::html::{html, Markup, Render};
+use crate::html::{html, Context, Markup};
use crate::{AutoDefault, Weight};
-pub trait AssetsTrait: Render {
- // Devuelve el nombre del recurso, utilizado como clave única.
+/// Representación genérica de un script [`JavaScript`](crate::html::JavaScript) o una hoja de
+/// estilos [`StyleSheet`](crate::html::StyleSheet).
+///
+/// Estos recursos se incluyen en los conjuntos de recursos ([`Assets`]) que suelen renderizarse en
+/// un documento HTML.
+///
+/// Cada recurso se identifica por un **nombre único** ([`Asset::name()`]), usado como clave; y un
+/// **peso** ([`Asset::weight()`]), que determina su orden relativo de renderizado.
+pub trait Asset {
+ /// Devuelve el nombre del recurso, utilizado como clave única.
fn name(&self) -> &str;
- // Devuelve el peso del recurso, durante el renderizado se procesan de menor a mayor peso.
+ /// Devuelve el peso del recurso, usado para ordenar el renderizado de menor a mayor peso.
fn weight(&self) -> Weight;
+
+ /// Renderiza el recurso en el contexto proporcionado.
+ fn render(&self, cx: &mut Context) -> Markup;
}
+/// Gestión común para conjuntos de recursos como [`JavaScript`](crate::html::JavaScript) y
+/// [`StyleSheet`](crate::html::StyleSheet).
+///
+/// Se emplea normalmente para agrupar, administrar y renderizar los recursos de un documento HTML.
+/// Cada recurso se identifica por un nombre único ([`Asset::name()`]) y tiene asociado un peso
+/// ([`Asset::weight()`]) que determina su orden de renderizado.
+///
+/// Durante el renderizado, los recursos se procesan en orden ascendente de peso. En caso de
+/// igualdad, se respeta el orden de inserción.
#[derive(AutoDefault)]
-pub(crate) struct Assets(Vec);
+pub struct Assets(Vec);
-impl Assets {
+impl Assets {
+ /// Crea un nuevo conjunto vacío de recursos.
+ ///
+ /// Normalmente no se instancia directamente, sino como parte de la gestión de recursos que
+ /// hacen páginas o temas.
pub fn new() -> Self {
- Assets::(Vec::::new())
+ Self(Vec::new())
}
+ /// Inserta un recurso.
+ ///
+ /// Si no existe otro con el mismo nombre, lo añade. Si ya existe y su peso era mayor, lo
+ /// reemplaza. Y si su peso era menor o igual, entonces no realiza ningún cambio.
+ ///
+ /// Devuelve `true` si el recurso fue insertado o reemplazado.
pub fn add(&mut self, asset: T) -> bool {
match self.0.iter().position(|x| x.name() == asset.name()) {
Some(index) => {
@@ -39,6 +69,9 @@ impl Assets {
}
}
+ /// Elimina un recurso por nombre.
+ ///
+ /// Devuelve `true` si el recurso existía y fue eliminado.
pub fn remove(&mut self, name: impl AsRef) -> bool {
if let Some(index) = self.0.iter().position(|x| x.name() == name.as_ref()) {
self.0.remove(index);
@@ -47,16 +80,13 @@ impl Assets {
false
}
}
-}
-impl Render for Assets {
- fn render(&self) -> Markup {
+ pub fn render(&self, cx: &mut Context) -> Markup {
let mut assets = self.0.iter().collect::>();
assets.sort_by_key(|a| a.weight());
-
html! {
@for a in assets {
- (a.render())
+ (a.render(cx))
}
}
}
diff --git a/src/html/assets/favicon.rs b/src/html/assets/favicon.rs
index 1a8b29e..d731b8f 100644
--- a/src/html/assets/favicon.rs
+++ b/src/html/assets/favicon.rs
@@ -1,4 +1,4 @@
-use crate::html::{html, Markup, Render};
+use crate::html::{html, Context, Markup};
use crate::AutoDefault;
/// Un **Favicon** es un recurso gráfico que usa el navegador como icono asociado al sitio.
@@ -129,7 +129,7 @@ impl Favicon {
icon_color: Option,
) -> Self {
let icon_type = match icon_source.rfind('.') {
- Some(i) => match icon_source[i..].to_owned().to_lowercase().as_str() {
+ Some(i) => match icon_source[i..].to_string().to_lowercase().as_str() {
".avif" => Some("image/avif"),
".gif" => Some("image/gif"),
".ico" => Some("image/x-icon"),
@@ -151,10 +151,12 @@ impl Favicon {
});
self
}
-}
-impl Render for Favicon {
- fn render(&self) -> Markup {
+ /// Renderiza el **Favicon** completo con todas las etiquetas declaradas.
+ ///
+ /// El parámetro `Context` se acepta por coherencia con el resto de *assets*, aunque en este
+ /// caso es ignorado.
+ pub fn render(&self, _cx: &mut Context) -> Markup {
html! {
@for item in &self.0 {
(item)
diff --git a/src/html/assets/javascript.rs b/src/html/assets/javascript.rs
index db5754e..a8ed3e8 100644
--- a/src/html/assets/javascript.rs
+++ b/src/html/assets/javascript.rs
@@ -1,35 +1,45 @@
-use crate::html::assets::AssetsTrait;
-use crate::html::{html, Markup, Render};
+use crate::html::assets::Asset;
+use crate::html::{html, Context, Markup, PreEscaped};
use crate::{join, join_pair, AutoDefault, Weight};
// Define el origen del recurso JavaScript y cómo debe cargarse en el navegador.
//
// Los distintos modos de carga permiten optimizar el rendimiento y controlar el comportamiento del
-// script.
+// script en relación con el análisis del documento HTML y la ejecución del resto de scripts.
//
-// - [`From`] – Carga el script de forma estándar con la etiqueta ``. El parámetro `name` se usa como identificador interno del
- /// *script*.
- pub fn inline(name: impl Into, script: impl Into) -> Self {
+ /// script.
+ ///
+ /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
+ pub fn inline(name: impl Into, f: F) -> Self
+ where
+ F: Fn(&mut Context) -> String + Send + Sync + 'static,
+ {
JavaScript {
- source: Source::Inline(name.into(), script.into()),
+ source: Source::Inline(name.into(), Box::new(f)),
..Default::default()
}
}
- /// Crea un **script embebido** que se ejecuta automáticamente al terminar de cargarse el
- /// documento HTML.
+ /// Crea un **script embebido** que se ejecuta cuando **el DOM está listo**.
///
- /// El código se envuelve automáticamente en un `addEventListener('DOMContentLoaded', ...)`. El
- /// parámetro `name` se usa como identificador interno del *script*.
- pub fn on_load(name: impl Into, script: impl Into) -> Self {
+ /// El código se envuelve en un `addEventListener('DOMContentLoaded',function(){...})` que lo
+ /// ejecuta tras analizar el documento HTML, **no** espera imágenes ni otros recursos externos.
+ /// Útil para inicializaciones que no dependen de `await`. El parámetro `name` se usa como
+ /// identificador interno del script.
+ ///
+ /// Los scripts con `defer` se ejecutan antes de `DOMContentLoaded`.
+ ///
+ /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
+ pub fn on_load(name: impl Into, f: F) -> Self
+ where
+ F: Fn(&mut Context) -> String + Send + Sync + 'static,
+ {
JavaScript {
- source: Source::OnLoad(name.into(), script.into()),
+ source: Source::OnLoad(name.into(), Box::new(f)),
+ ..Default::default()
+ }
+ }
+
+ /// Crea un **script embebido** con un **manejador asíncrono**.
+ ///
+ /// El código se envuelve en un `addEventListener('DOMContentLoaded',async()=>{...})`, que
+ /// emplea una función `async` para que el cuerpo devuelto por la función *closure* pueda usar
+ /// `await`. Ideal para hidratar la interfaz, cargar módulos dinámicos o realizar lecturas
+ /// iniciales.
+ ///
+ /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
+ pub fn on_load_async(name: impl Into, f: F) -> Self
+ where
+ F: Fn(&mut Context) -> String + Send + Sync + 'static,
+ {
+ JavaScript {
+ source: Source::OnLoadAsync(name.into(), Box::new(f)),
..Default::default()
}
}
// JavaScript BUILDER **************************************************************************
- /// Asocia una versión al recurso (usada para control de la caché del navegador).
+ /// Asocia una **versión** al recurso (usada para control de la caché del navegador).
///
- /// Si `version` está vacío, no se añade ningún parámetro a la URL.
+ /// Si `version` está vacío, **no** se añade ningún parámetro a la URL.
pub fn with_version(mut self, version: impl Into) -> Self {
self.version = version.into();
self
}
- /// Modifica el peso del recurso.
+ /// Modifica el **peso** del recurso.
///
/// Los recursos se renderizan de menor a mayor peso. Por defecto es `0`, que respeta el orden
/// de creación.
@@ -137,8 +191,10 @@ impl JavaScript {
}
}
-impl AssetsTrait for JavaScript {
- // Para *scripts* externos es la ruta; para *scripts* embebidos, un identificador.
+impl Asset for JavaScript {
+ /// Devuelve el nombre del recurso, utilizado como clave única.
+ ///
+ /// Para scripts externos es la ruta del recurso; para scripts embebidos, un identificador.
fn name(&self) -> &str {
match &self.source {
Source::From(path) => path,
@@ -146,16 +202,15 @@ impl AssetsTrait for JavaScript {
Source::Async(path) => path,
Source::Inline(name, _) => name,
Source::OnLoad(name, _) => name,
+ Source::OnLoadAsync(name, _) => name,
}
}
fn weight(&self) -> Weight {
self.weight
}
-}
-impl Render for JavaScript {
- fn render(&self) -> Markup {
+ fn render(&self, cx: &mut Context) -> Markup {
match &self.source {
Source::From(path) => html! {
script src=(join_pair!(path, "?v=", self.version.as_str())) {};
@@ -166,12 +221,15 @@ impl Render for JavaScript {
Source::Async(path) => html! {
script src=(join_pair!(path, "?v=", self.version.as_str())) async {};
},
- Source::Inline(_, code) => html! {
- script { (code) };
+ Source::Inline(_, f) => html! {
+ script { (PreEscaped((f)(cx))) };
},
- Source::OnLoad(_, code) => html! { (join!(
- "document.addEventListener('DOMContentLoaded',function(){", code, "});"
- )) },
+ Source::OnLoad(_, f) => html! { script { (PreEscaped(join!(
+ "document.addEventListener(\"DOMContentLoaded\",function(){", (f)(cx), "});"
+ ))) } },
+ Source::OnLoadAsync(_, f) => html! { script { (PreEscaped(join!(
+ "document.addEventListener(\"DOMContentLoaded\",async()=>{", (f)(cx), "});"
+ ))) } },
}
}
}
diff --git a/src/html/assets/stylesheet.rs b/src/html/assets/stylesheet.rs
index bb60b01..3ecc77f 100644
--- a/src/html/assets/stylesheet.rs
+++ b/src/html/assets/stylesheet.rs
@@ -1,5 +1,5 @@
-use crate::html::assets::AssetsTrait;
-use crate::html::{html, Markup, PreEscaped, Render};
+use crate::html::assets::Asset;
+use crate::html::{html, Context, Markup, PreEscaped};
use crate::{join_pair, AutoDefault, Weight};
// Define el origen del recurso CSS y cómo se incluye en el documento.
@@ -14,7 +14,8 @@ use crate::{join_pair, AutoDefault, Weight};
enum Source {
#[default]
From(String),
- Inline(String, String),
+ // `name`, `closure(Context) -> String`.
+ Inline(String, Box String + Send + Sync>),
}
/// Define el medio objetivo para la hoja de estilos.
@@ -34,7 +35,7 @@ pub enum TargetMedia {
Speech,
}
-/// Devuelve el texto asociado al punto de interrupción usado por Bootstrap.
+/// Devuelve el valor para el atributo `media` (`Some(...)`) o `None` para `Default`.
#[rustfmt::skip]
impl TargetMedia {
fn as_str_opt(&self) -> Option<&str> {
@@ -69,12 +70,12 @@ impl TargetMedia {
/// .with_weight(-10);
///
/// // Crea una hoja de estilos embebida en el documento HTML.
-/// let embedded = StyleSheet::inline("custom_theme", r#"
+/// let embedded = StyleSheet::inline("custom_theme", |_| r#"
/// body {
/// background-color: #f5f5f5;
/// font-family: 'Segoe UI', sans-serif;
/// }
-/// "#);
+/// "#.to_string());
/// ```
#[rustfmt::skip]
#[derive(AutoDefault)]
@@ -100,9 +101,14 @@ impl StyleSheet {
///
/// Equivale a ``. El parámetro `name` se usa como identificador interno del
/// recurso.
- pub fn inline(name: impl Into, styles: impl Into) -> Self {
+ ///
+ /// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
+ pub fn inline(name: impl Into, f: F) -> Self
+ where
+ F: Fn(&mut Context) -> String + Send + Sync + 'static,
+ {
StyleSheet {
- source: Source::Inline(name.into(), styles.into()),
+ source: Source::Inline(name.into(), Box::new(f)),
..Default::default()
}
}
@@ -133,17 +139,19 @@ impl StyleSheet {
/// Según el argumento `media`:
///
/// - `TargetMedia::Default` - Se aplica en todos los casos (medio por defecto).
- /// - `TargetMedia::Print` - Se aplican cuando el documento se imprime.
- /// - `TargetMedia::Screen` - Se aplican en pantallas.
- /// - `TargetMedia::Speech` - Se aplican en dispositivos que convierten el texto a voz.
+ /// - `TargetMedia::Print` - Se aplica cuando el documento se imprime.
+ /// - `TargetMedia::Screen` - Se aplica en pantallas.
+ /// - `TargetMedia::Speech` - Se aplica en dispositivos que convierten el texto a voz.
pub fn for_media(mut self, media: TargetMedia) -> Self {
self.media = media;
self
}
}
-impl AssetsTrait for StyleSheet {
- // Para hojas de estilos externas es la ruta; para las embebidas, un identificador.
+impl Asset for StyleSheet {
+ /// Devuelve el nombre del recurso, utilizado como clave única.
+ ///
+ /// Para hojas de estilos externas es la ruta del recurso; para las embebidas, un identificador.
fn name(&self) -> &str {
match &self.source {
Source::From(path) => path,
@@ -154,10 +162,8 @@ impl AssetsTrait for StyleSheet {
fn weight(&self) -> Weight {
self.weight
}
-}
-impl Render for StyleSheet {
- fn render(&self) -> Markup {
+ fn render(&self, cx: &mut Context) -> Markup {
match &self.source {
Source::From(path) => html! {
link
@@ -165,8 +171,8 @@ impl Render for StyleSheet {
href=(join_pair!(path, "?v=", self.version.as_str()))
media=[self.media.as_str_opt()];
},
- Source::Inline(_, code) => html! {
- style { (PreEscaped(code)) };
+ Source::Inline(_, f) => html! {
+ style { (PreEscaped((f)(cx))) };
},
}
}
diff --git a/src/html/opt_classes.rs b/src/html/attr_classes.rs
similarity index 84%
rename from src/html/opt_classes.rs
rename to src/html/attr_classes.rs
index abb3ba4..098c26c 100644
--- a/src/html/opt_classes.rs
+++ b/src/html/attr_classes.rs
@@ -1,6 +1,6 @@
use crate::{builder_fn, AutoDefault};
-/// Operaciones disponibles sobre la lista de clases en [`OptionClasses`].
+/// Operaciones disponibles sobre la lista de clases en [`AttrClasses`].
pub enum ClassesOp {
/// Añade al final (si no existe).
Add,
@@ -25,6 +25,7 @@ pub enum ClassesOp {
///
/// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS.
/// - No se permiten clases duplicadas.
+/// - Las clases se convierten a minúsculas.
/// - Las clases vacías se ignoran.
///
/// # Ejemplo
@@ -32,26 +33,26 @@ pub enum ClassesOp {
/// ```rust
/// use pagetop::prelude::*;
///
-/// let classes = OptionClasses::new("btn btn-primary")
-/// .with_value(ClassesOp::Add, "active")
+/// let classes = AttrClasses::new("Btn btn-primary")
+/// .with_value(ClassesOp::Add, "Active")
/// .with_value(ClassesOp::Remove, "btn-primary");
///
-/// assert_eq!(classes.get(), Some(String::from("btn active")));
+/// assert_eq!(classes.get(), Some("btn active".to_string()));
/// assert!(classes.contains("active"));
/// ```
#[derive(AutoDefault, Clone, Debug)]
-pub struct OptionClasses(Vec);
+pub struct AttrClasses(Vec);
-impl OptionClasses {
+impl AttrClasses {
pub fn new(classes: impl AsRef) -> Self {
- OptionClasses::default().with_value(ClassesOp::Prepend, classes)
+ AttrClasses::default().with_value(ClassesOp::Prepend, classes)
}
- // OptionClasses BUILDER ***********************************************************************
+ // AttrClasses BUILDER *************************************************************************
#[builder_fn]
pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef) -> Self {
- let classes: &str = classes.as_ref();
+ let classes = classes.as_ref().to_ascii_lowercase();
let classes: Vec<&str> = classes.split_ascii_whitespace().collect();
if classes.is_empty() {
@@ -113,9 +114,9 @@ impl OptionClasses {
}
}
- // OptionClasses GETTERS ***********************************************************************
+ // AttrClasses GETTERS *************************************************************************
- /// Devuele la cadena de clases, si existe.
+ /// Devuelve la cadena de clases, si existe.
pub fn get(&self) -> Option {
if self.0.is_empty() {
None
diff --git a/src/html/attr_id.rs b/src/html/attr_id.rs
new file mode 100644
index 0000000..8bb1d33
--- /dev/null
+++ b/src/html/attr_id.rs
@@ -0,0 +1,63 @@
+use crate::{builder_fn, AutoDefault};
+
+/// Identificador normalizado para el atributo `id` o similar de HTML.
+///
+/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso:
+///
+/// - Se eliminan los espacios al principio y al final.
+/// - Se convierte a minúsculas.
+/// - Se sustituyen los espacios intermedios por guiones bajos (`_`).
+/// - Si el resultado es una cadena vacía, se guarda `None`.
+///
+/// # Ejemplo
+///
+/// ```rust
+/// use pagetop::prelude::*;
+///
+/// let id = AttrId::new(" main Section ");
+/// assert_eq!(id.as_str(), Some("main_section"));
+///
+/// let empty = AttrId::default();
+/// assert_eq!(empty.get(), None);
+/// ```
+#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
+pub struct AttrId(Option);
+
+impl AttrId {
+ /// Crea un nuevo `AttrId` normalizando el valor.
+ pub fn new(value: impl AsRef) -> Self {
+ AttrId::default().with_value(value)
+ }
+
+ // AttrId BUILDER ******************************************************************************
+
+ /// Establece un identificador nuevo normalizando el valor.
+ #[builder_fn]
+ pub fn with_value(mut self, value: impl AsRef) -> Self {
+ let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_");
+ self.0 = if value.is_empty() { None } else { Some(value) };
+ self
+ }
+
+ // AttrId GETTERS ******************************************************************************
+
+ /// Devuelve el identificador normalizado, si existe.
+ pub fn get(&self) -> Option {
+ self.0.as_ref().cloned()
+ }
+
+ /// Devuelve el identificador normalizado (sin clonar), si existe.
+ pub fn as_str(&self) -> Option<&str> {
+ self.0.as_deref()
+ }
+
+ /// Devuelve el identificador normalizado (propiedad), si existe.
+ pub fn into_inner(self) -> Option {
+ self.0
+ }
+
+ /// `true` si no hay valor.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_none()
+ }
+}
diff --git a/src/html/attr_l10n.rs b/src/html/attr_l10n.rs
new file mode 100644
index 0000000..8250c74
--- /dev/null
+++ b/src/html/attr_l10n.rs
@@ -0,0 +1,68 @@
+use crate::html::Markup;
+use crate::locale::{L10n, LangId};
+use crate::{builder_fn, AutoDefault};
+
+/// Texto para [traducir](crate::locale) en atributos HTML.
+///
+/// Encapsula un [`L10n`] para manejar traducciones de forma segura en atributos.
+///
+/// # Ejemplo
+///
+/// ```rust
+/// use pagetop::prelude::*;
+///
+/// // Traducción por clave en las locales por defecto de PageTop.
+/// let hello = AttrL10n::new(L10n::l("test-hello-world"));
+///
+/// // Español disponible.
+/// assert_eq!(
+/// hello.lookup(&LangMatch::resolve("es-ES")),
+/// Some("¡Hola mundo!".to_string())
+/// );
+///
+/// // Japonés no disponible, traduce al idioma de respaldo ("en-US").
+/// assert_eq!(
+/// hello.lookup(&LangMatch::resolve("ja-JP")),
+/// Some("Hello world!".to_string())
+/// );
+///
+/// // Uso típico en un atributo:
+/// let title = hello.value(&LangMatch::resolve("es-ES"));
+/// // Ejemplo: html! { a title=(title) { "Link" } }
+/// ```
+#[derive(AutoDefault, Clone, Debug)]
+pub struct AttrL10n(L10n);
+
+impl AttrL10n {
+ /// Crea una nueva instancia `AttrL10n`.
+ pub fn new(value: L10n) -> Self {
+ AttrL10n(value)
+ }
+
+ // AttrL10n BUILDER ****************************************************************************
+
+ /// Establece una traducción nueva.
+ #[builder_fn]
+ pub fn with_value(mut self, value: L10n) -> Self {
+ self.0 = value;
+ self
+ }
+
+ // AttrL10n GETTERS ****************************************************************************
+
+ /// Devuelve la traducción para `language`, si existe.
+ pub fn lookup(&self, language: &impl LangId) -> Option {
+ self.0.lookup(language)
+ }
+
+ /// Devuelve la traducción para `language` o una cadena vacía si no existe.
+ pub fn value(&self, language: &impl LangId) -> String {
+ self.0.lookup(language).unwrap_or_default()
+ }
+
+ /// **Obsoleto desde la versión 0.4.0**: no recomendado para atributos HTML.
+ #[deprecated(since = "0.4.0", note = "For attributes use `lookup()` or `value()`")]
+ pub fn to_markup(&self, language: &impl LangId) -> Markup {
+ self.0.using(language)
+ }
+}
diff --git a/src/html/attr_name.rs b/src/html/attr_name.rs
new file mode 100644
index 0000000..928f841
--- /dev/null
+++ b/src/html/attr_name.rs
@@ -0,0 +1,63 @@
+use crate::{builder_fn, AutoDefault};
+
+/// Nombre normalizado para el atributo `name` o similar de HTML.
+///
+/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso:
+///
+/// - Se eliminan los espacios al principio y al final.
+/// - Se convierte a minúsculas.
+/// - Se sustituyen los espacios intermedios por guiones bajos (`_`).
+/// - Si el resultado es una cadena vacía, se guarda `None`.
+///
+/// # Ejemplo
+///
+/// ```rust
+/// use pagetop::prelude::*;
+///
+/// let name = AttrName::new(" DISplay name ");
+/// assert_eq!(name.as_str(), Some("display_name"));
+///
+/// let empty = AttrName::default();
+/// assert_eq!(empty.get(), None);
+/// ```
+#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
+pub struct AttrName(Option);
+
+impl AttrName {
+ /// Crea un nuevo `AttrName` normalizando el valor.
+ pub fn new(value: impl AsRef) -> Self {
+ AttrName::default().with_value(value)
+ }
+
+ // AttrName BUILDER ****************************************************************************
+
+ /// Establece un nombre nuevo normalizando el valor.
+ #[builder_fn]
+ pub fn with_value(mut self, value: impl AsRef) -> Self {
+ let value = value.as_ref().trim().to_ascii_lowercase().replace(' ', "_");
+ self.0 = if value.is_empty() { None } else { Some(value) };
+ self
+ }
+
+ // AttrName GETTERS ****************************************************************************
+
+ /// Devuelve el nombre normalizado, si existe.
+ pub fn get(&self) -> Option {
+ self.0.as_ref().cloned()
+ }
+
+ /// Devuelve el nombre normalizado (sin clonar), si existe.
+ pub fn as_str(&self) -> Option<&str> {
+ self.0.as_deref()
+ }
+
+ /// Devuelve el nombre normalizado (propiedad), si existe.
+ pub fn into_inner(self) -> Option {
+ self.0
+ }
+
+ /// `true` si no hay valor.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_none()
+ }
+}
diff --git a/src/html/attr_value.rs b/src/html/attr_value.rs
new file mode 100644
index 0000000..4e03120
--- /dev/null
+++ b/src/html/attr_value.rs
@@ -0,0 +1,65 @@
+use crate::{builder_fn, AutoDefault};
+
+/// Cadena normalizada para renderizar en atributos HTML.
+///
+/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso:
+///
+/// - Se eliminan los espacios al principio y al final.
+/// - Si el resultado es una cadena vacía, se guarda `None`.
+///
+/// # Ejemplo
+///
+/// ```rust
+/// use pagetop::prelude::*;
+///
+/// let s = AttrValue::new(" a new string ");
+/// assert_eq!(s.as_str(), Some("a new string"));
+///
+/// let empty = AttrValue::default();
+/// assert_eq!(empty.get(), None);
+/// ```
+#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
+pub struct AttrValue(Option);
+
+impl AttrValue {
+ /// Crea un nuevo `AttrValue` normalizando el valor.
+ pub fn new(value: impl AsRef) -> Self {
+ AttrValue::default().with_value(value)
+ }
+
+ // AttrValue BUILDER ***************************************************************************
+
+ /// Establece una cadena nueva normalizando el valor.
+ #[builder_fn]
+ pub fn with_value(mut self, value: impl AsRef) -> Self {
+ let value = value.as_ref().trim();
+ self.0 = if value.is_empty() {
+ None
+ } else {
+ Some(value.to_string())
+ };
+ self
+ }
+
+ // AttrValue GETTERS ***************************************************************************
+
+ /// Devuelve la cadena normalizada, si existe.
+ pub fn get(&self) -> Option {
+ self.0.as_ref().cloned()
+ }
+
+ /// Devuelve la cadena normalizada (sin clonar), si existe.
+ pub fn as_str(&self) -> Option<&str> {
+ self.0.as_deref()
+ }
+
+ /// Devuelve la cadena normalizada (propiedad), si existe.
+ pub fn into_inner(self) -> Option {
+ self.0
+ }
+
+ /// `true` si no hay valor.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_none()
+ }
+}
diff --git a/src/html/context.rs b/src/html/context.rs
index 5fbb39b..7b78268 100644
--- a/src/html/context.rs
+++ b/src/html/context.rs
@@ -7,13 +7,10 @@ use crate::locale::{LangId, LangMatch, LanguageIdentifier, DEFAULT_LANGID, FALLB
use crate::service::HttpRequest;
use crate::{builder_fn, join};
+use std::any::Any;
use std::collections::HashMap;
-use std::error::Error;
-use std::str::FromStr;
-use std::fmt;
-
-/// Operaciones para modificar el contexto ([`Context`]) del documento.
+/// Operaciones para modificar el contexto ([`Context`]) de un documento.
pub enum AssetsOp {
// Favicon.
/// Define el *favicon* del documento. Sobrescribe cualquier valor anterior.
@@ -28,38 +25,138 @@ pub enum AssetsOp {
RemoveStyleSheet(&'static str),
// JavaScripts.
- /// Añade un *script* JavaScript al documento.
+ /// Añade un script JavaScript al documento.
AddJavaScript(JavaScript),
- /// Elimina un *script* por su ruta o identificador.
+ /// Elimina un script por su ruta o identificador.
RemoveJavaScript(&'static str),
}
-/// Errores de lectura o conversión de parámetros almacenados en el contexto.
+/// Errores de acceso a parámetros dinámicos del contexto.
+///
+/// - [`ErrorParam::NotFound`]: la clave no existe.
+/// - [`ErrorParam::TypeMismatch`]: la clave existe, pero el valor guardado no coincide con el tipo
+/// solicitado. Incluye nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente
+/// guardado (`saved`) para facilitar el diagnóstico.
#[derive(Debug)]
pub enum ErrorParam {
- /// El parámetro solicitado no existe.
NotFound,
- /// El valor del parámetro no pudo convertirse al tipo requerido.
- ParseError(String),
+ TypeMismatch {
+ key: &'static str,
+ expected: &'static str,
+ saved: &'static str,
+ },
}
-impl fmt::Display for ErrorParam {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- ErrorParam::NotFound => write!(f, "Parameter not found"),
- ErrorParam::ParseError(e) => write!(f, "Parse error: {e}"),
- }
- }
-}
-
-impl Error for ErrorParam {}
-
-/// Representa el contexto de un documento HTML.
+/// Interfaz para gestionar el **contexto de renderizado** de un documento HTML.
///
-/// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de
-/// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]),
-/// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como parámetros de contexto
-/// definidos en tiempo de ejecución.
+/// `Contextual` extiende [`LangId`] y define los métodos para:
+///
+/// - Establecer el **idioma** del documento.
+/// - Almacenar la **solicitud HTTP** de origen.
+/// - Seleccionar **tema** y **composición** (*layout*) de renderizado.
+/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
+/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`].
+/// - Leer y mantener **parámetros dinámicos tipados** de contexto.
+/// - Generar **identificadores únicos** por tipo de componente.
+///
+/// Lo implementan, típicamente, estructuras que representan el contexto de renderizado, como
+/// [`Context`](crate::html::Context) o [`Page`](crate::response::page::Page).
+///
+/// # Ejemplo
+///
+/// ```rust
+/// use pagetop::prelude::*;
+///
+/// fn prepare_context(cx: C) -> C {
+/// cx.with_langid(&LangMatch::resolve("es-ES"))
+/// .with_theme("aliner")
+/// .with_layout("default")
+/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
+/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
+/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js")))
+/// .with_param("usuario_id", 42_i32)
+/// }
+/// ```
+pub trait Contextual: LangId {
+ // Contextual BUILDER **************************************************************************
+
+ /// Establece el idioma del documento.
+ #[builder_fn]
+ fn with_langid(self, language: &impl LangId) -> Self;
+
+ /// Almacena la solicitud HTTP de origen en el contexto.
+ #[builder_fn]
+ fn with_request(self, request: Option) -> Self;
+
+ /// Especifica el tema para renderizar el documento.
+ #[builder_fn]
+ fn with_theme(self, theme_name: &'static str) -> Self;
+
+ /// Especifica la composición para renderizar el documento.
+ #[builder_fn]
+ fn with_layout(self, layout_name: &'static str) -> Self;
+
+ /// Añade o modifica un parámetro dinámico del contexto.
+ #[builder_fn]
+ fn with_param(self, key: &'static str, value: T) -> Self;
+
+ /// Define los recursos del contexto usando [`AssetsOp`].
+ #[builder_fn]
+ fn with_assets(self, op: AssetsOp) -> Self;
+
+ // Contextual GETTERS **************************************************************************
+
+ /// Devuelve una referencia a la solicitud HTTP asociada, si existe.
+ fn request(&self) -> Option<&HttpRequest>;
+
+ /// Devuelve el tema que se usará para renderizar el documento.
+ fn theme(&self) -> ThemeRef;
+
+ /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`.
+ fn layout(&self) -> &str;
+
+ /// Recupera un parámetro como [`Option`].
+ fn param(&self, key: &'static str) -> Option<&T>;
+
+ /// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`).
+ fn param_or_default(&self, key: &'static str) -> T {
+ self.param::(key).cloned().unwrap_or_default()
+ }
+
+ /// Devuelve el parámetro clonado o un **valor por defecto** si no existe.
+ fn param_or(&self, key: &'static str, default: T) -> T {
+ self.param::(key).cloned().unwrap_or(default)
+ }
+
+ /// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe.
+ fn param_or_else T>(&self, key: &'static str, f: F) -> T {
+ self.param::(key).cloned().unwrap_or_else(f)
+ }
+
+ /// Devuelve el Favicon de los recursos del contexto.
+ fn favicon(&self) -> Option<&Favicon>;
+
+ /// Devuelve las hojas de estilo de los recursos del contexto.
+ fn stylesheets(&self) -> &Assets;
+
+ /// Devuelve los scripts JavaScript de los recursos del contexto.
+ fn javascripts(&self) -> &Assets;
+
+ // Contextual HELPERS **************************************************************************
+
+ /// Genera un identificador único por tipo (`-`) cuando no se aporta uno explícito.
+ ///
+ /// Es útil para componentes u otros elementos HTML que necesitan un identificador predecible si
+ /// no se proporciona ninguno.
+ fn required_id(&mut self, id: Option) -> String;
+}
+
+/// Implementa un **contexto de renderizado** para un documento HTML.
+///
+/// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto,
+/// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo
+/// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos
+/// tipados** con nuevos métodos.
///
/// # Ejemplos
///
@@ -96,7 +193,7 @@ impl Error for ErrorParam {}
/// assert_eq!(active_theme.short_name(), "aliner");
///
/// // Recupera el parámetro a su tipo original.
-/// let id: i32 = cx.get_param("usuario_id").unwrap();
+/// let id: i32 = *cx.get_param::("usuario_id").unwrap();
/// assert_eq!(id, 42);
///
/// // Genera un identificador para un componente de tipo `Menu`.
@@ -114,10 +211,16 @@ pub struct Context {
favicon : Option, // Favicon, si se ha definido.
stylesheets: Assets, // Hojas de estilo CSS.
javascripts: Assets, // Scripts JavaScript.
- params : HashMap<&'static str, String>, // Parámetros definidos en tiempo de ejecución.
+ params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución.
id_counter : usize, // Contador para generar identificadores únicos.
}
+impl Default for Context {
+ fn default() -> Self {
+ Context::new(None)
+ }
+}
+
impl Context {
/// Crea un nuevo contexto asociado a una solicitud HTTP.
///
@@ -147,40 +250,199 @@ impl Context {
favicon : None,
stylesheets: Assets::::new(),
javascripts: Assets::::new(),
- params : HashMap::<&str, String>::new(),
+ params : HashMap::default(),
id_counter : 0,
}
}
- // Context BUILDER *****************************************************************************
+ // Context RENDER ******************************************************************************
+
+ /// Renderiza los recursos del contexto.
+ pub fn render_assets(&mut self) -> Markup {
+ use std::mem::take as mem_take;
+
+ // Extrae temporalmente los recursos.
+ let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self.
+ let stylesheets = mem_take(&mut self.stylesheets); // Assets::default() en self.
+ let javascripts = mem_take(&mut self.javascripts); // Assets::default() en self.
+
+ // Renderiza con `&mut self` como contexto.
+ let markup = html! {
+ @if let Some(fi) = &favicon {
+ (fi.render(self))
+ }
+ (stylesheets.render(self))
+ (javascripts.render(self))
+ };
+
+ // Restaura los campos tal y como estaban.
+ self.favicon = favicon;
+ self.stylesheets = stylesheets;
+ self.javascripts = javascripts;
+
+ markup
+ }
+
+ // Context PARAMS ******************************************************************************
+
+ /// Recupera una *referencia tipada* al parámetro solicitado.
+ ///
+ /// Devuelve:
+ ///
+ /// - `Ok(&T)` si la clave existe y el tipo coincide.
+ /// - `Err(ErrorParam::NotFound)` si la clave no existe.
+ /// - `Err(ErrorParam::TypeMismatch)` si la clave existe pero el tipo no coincide.
+ ///
+ /// # Ejemplos
+ ///
+ /// ```rust
+ /// use pagetop::prelude::*;
+ ///
+ /// let cx = Context::new(None)
+ /// .with_param("usuario_id", 42_i32)
+ /// .with_param("titulo", "Hola".to_string());
+ ///
+ /// let id: &i32 = cx.get_param("usuario_id").unwrap();
+ /// let titulo: &String = cx.get_param("titulo").unwrap();
+ ///
+ /// // Error de tipo:
+ /// assert!(cx.get_param::("usuario_id").is_err());
+ /// ```
+ pub fn get_param(&self, key: &'static str) -> Result<&T, ErrorParam> {
+ let (any, type_name) = self.params.get(key).ok_or(ErrorParam::NotFound)?;
+ any.downcast_ref::()
+ .ok_or_else(|| ErrorParam::TypeMismatch {
+ key,
+ expected: TypeInfo::FullName.of::(),
+ saved: type_name,
+ })
+ }
+
+ /// Recupera el parámetro solicitado y lo elimina del contexto.
+ ///
+ /// Devuelve:
+ ///
+ /// - `Ok(T)` si la clave existía y el tipo coincide.
+ /// - `Err(ErrorParam::NotFound)` si la clave no existe.
+ /// - `Err(ErrorParam::TypeMismatch)` si el tipo no coincide.
+ ///
+ /// # Ejemplos
+ ///
+ /// ```rust
+ /// use pagetop::prelude::*;
+ ///
+ /// let mut cx = Context::new(None)
+ /// .with_param("contador", 7_i32)
+ /// .with_param("titulo", "Hola".to_string());
+ ///
+ /// let n: i32 = cx.take_param("contador").unwrap();
+ /// assert!(cx.get_param::("contador").is_err()); // ya no está
+ ///
+ /// // Error de tipo:
+ /// assert!(cx.take_param::("titulo").is_err());
+ /// ```
+ pub fn take_param(&mut self, key: &'static str) -> Result {
+ let (boxed, saved) = self.params.remove(key).ok_or(ErrorParam::NotFound)?;
+ boxed
+ .downcast::()
+ .map(|b| *b)
+ .map_err(|_| ErrorParam::TypeMismatch {
+ key,
+ expected: TypeInfo::FullName.of::(),
+ saved,
+ })
+ }
+
+ /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó.
+ ///
+ /// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada.
+ ///
+ /// # Ejemplos
+ ///
+ /// ```rust
+ /// use pagetop::prelude::*;
+ ///
+ /// let mut cx = Context::new(None).with_param("temp", 1u8);
+ /// assert!(cx.remove_param("temp"));
+ /// assert!(!cx.remove_param("temp")); // ya no existe
+ /// ```
+ pub fn remove_param(&mut self, key: &'static str) -> bool {
+ self.params.remove(key).is_some()
+ }
+}
+
+/// Permite a [`Context`](crate::html::Context) actuar como proveedor de idioma.
+///
+/// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad:
+///
+/// 1. Un idioma válido establecido explícitamente con [`Context::with_langid`].
+/// 2. El idioma por defecto configurado para la aplicación.
+/// 3. Un idioma válido extraído de la cabecera `Accept-Language` del navegador.
+/// 4. Y si ninguna de las opciones anteriores aplica, se usa el idioma de respaldo (`"en-US"`).
+///
+/// Resulta útil para usar un contexto ([`Context`]) como fuente de traducción en
+/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using).
+impl LangId for Context {
+ fn langid(&self) -> &'static LanguageIdentifier {
+ self.langid
+ }
+}
+
+impl Contextual for Context {
+ // Contextual BUILDER **************************************************************************
- /// Modifica la fuente de idioma del documento.
#[builder_fn]
- pub fn with_langid(mut self, language: &impl LangId) -> Self {
+ fn with_request(mut self, request: Option) -> Self {
+ self.request = request;
+ self
+ }
+
+ #[builder_fn]
+ fn with_langid(mut self, language: &impl LangId) -> Self {
self.langid = language.langid();
self
}
- /// Modifica el tema que se usará para renderizar el documento.
+ /// Asigna el tema para renderizar el documento.
///
/// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica
/// ninguno entonces usará el tema por defecto.
#[builder_fn]
- pub fn with_theme(mut self, theme_name: &'static str) -> Self {
+ fn with_theme(mut self, theme_name: &'static str) -> Self {
self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME);
self
}
- /// Modifica la composición para renderizar el documento.
#[builder_fn]
- pub fn with_layout(mut self, layout_name: &'static str) -> Self {
+ fn with_layout(mut self, layout_name: &'static str) -> Self {
self.layout = layout_name;
self
}
- /// Define los recursos del contexto usando [`AssetsOp`].
+ /// Añade o modifica un parámetro dinámico del contexto.
+ ///
+ /// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error
+ /// posteriores.
+ ///
+ /// # Ejemplos
+ ///
+ /// ```rust
+ /// use pagetop::prelude::*;
+ ///
+ /// let cx = Context::new(None)
+ /// .with_param("usuario_id", 42_i32)
+ /// .with_param("titulo", "Hola".to_string())
+ /// .with_param("flags", vec!["a", "b"]);
+ /// ```
#[builder_fn]
- pub fn with_assets(mut self, op: AssetsOp) -> Self {
+ fn with_param(mut self, key: &'static str, value: T) -> Self {
+ let type_name = TypeInfo::FullName.of::();
+ self.params.insert(key, (Box::new(value), type_name));
+ self
+ }
+
+ #[builder_fn]
+ fn with_assets(mut self, op: AssetsOp) -> Self {
match op {
// Favicon.
AssetsOp::SetFavicon(favicon) => {
@@ -209,69 +471,74 @@ impl Context {
self
}
- // Context GETTERS *****************************************************************************
+ // Contextual GETTERS **************************************************************************
- /// Devuelve una referencia a la solicitud HTTP asociada, si existe.
- pub fn request(&self) -> Option<&HttpRequest> {
+ fn request(&self) -> Option<&HttpRequest> {
self.request.as_ref()
}
- /// Devuelve el tema que se usará para renderizar el documento.
- pub fn theme(&self) -> ThemeRef {
+ fn theme(&self) -> ThemeRef {
self.theme
}
- /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`.
- pub fn layout(&self) -> &str {
+ fn layout(&self) -> &str {
self.layout
}
- // Context RENDER ******************************************************************************
-
- /// Renderiza los recursos del contexto.
- pub fn render_assets(&self) -> Markup {
- html! {
- @if let Some(favicon) = &self.favicon {
- (favicon)
- }
- (self.stylesheets)
- (self.javascripts)
- }
- }
-
- // Context PARAMS ******************************************************************************
-
- /// Añade o modifica un parámetro del contexto almacenando el valor como [`String`].
- #[builder_fn]
- pub fn with_param(mut self, key: &'static str, value: T) -> Self {
- self.params.insert(key, value.to_string());
- self
- }
-
- /// Recupera un parámetro del contexto convertido al tipo especificado.
+ /// Recupera un parámetro como [`Option`], simplificando el acceso.
///
- /// Devuelve un error si el parámetro no existe ([`ErrorParam::NotFound`]) o la conversión falla
- /// ([`ErrorParam::ParseError`]).
- pub fn get_param(&self, key: &'static str) -> Result {
- self.params
- .get(key)
- .ok_or(ErrorParam::NotFound)
- .and_then(|v| T::from_str(v).map_err(|_| ErrorParam::ParseError(v.clone())))
+ /// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información
+ /// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor
+ /// guardado no coincide con el tipo solicitado.
+ ///
+ /// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo
+ /// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo.
+ ///
+ /// # Ejemplo
+ ///
+ /// ```rust
+ /// use pagetop::prelude::*;
+ ///
+ /// let cx = Context::new(None).with_param("username", "Alice".to_string());
+ ///
+ /// // Devuelve Some(&String) si existe y coincide el tipo.
+ /// assert_eq!(cx.param::("username").map(|s| s.as_str()), Some("Alice"));
+ ///
+ /// // Devuelve None si no existe o si el tipo no coincide.
+ /// assert!(cx.param::("username").is_none());
+ /// assert!(cx.param::("missing").is_none());
+ ///
+ /// // Acceso con valor por defecto.
+ /// let user = cx.param::("missing")
+ /// .cloned()
+ /// .unwrap_or_else(|| "visitor".to_string());
+ /// assert_eq!(user, "visitor");
+ /// ```
+ fn param(&self, key: &'static str) -> Option<&T> {
+ self.get_param::(key).ok()
}
- /// Elimina un parámetro del contexto. Devuelve `true` si existía y se eliminó.
- pub fn remove_param(&mut self, key: &'static str) -> bool {
- self.params.remove(key).is_some()
+ fn favicon(&self) -> Option<&Favicon> {
+ self.favicon.as_ref()
}
- // Context EXTRAS ******************************************************************************
+ fn stylesheets(&self) -> &Assets {
+ &self.stylesheets
+ }
- /// Genera un identificador único si no se proporciona uno explícito.
+ fn javascripts(&self) -> &Assets {
+ &self.javascripts
+ }
+
+ // Contextual HELPERS **************************************************************************
+
+ /// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona
+ /// un `id` explícito.
///
/// Si no se proporciona un `id`, se genera un identificador único en la forma `-`
/// donde `` es el nombre corto del tipo en minúsculas (sin espacios) y `` es un
/// contador interno incremental.
- pub fn required_id(&mut self, id: Option) -> String {
+ fn required_id(&mut self, id: Option) -> String {
if let Some(id) = id {
id
} else {
@@ -281,7 +548,7 @@ impl Context {
.replace(' ', "_")
.to_lowercase();
let prefix = if prefix.is_empty() {
- "prefix".to_owned()
+ "prefix".to_string()
} else {
prefix
};
@@ -290,21 +557,3 @@ impl Context {
}
}
}
-
-/// Permite a [`Context`](crate::html::Context) actuar como proveedor de idioma.
-///
-/// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad:
-///
-/// 1. Un idioma válido establecido explícitamente con [`Context::with_langid`].
-/// 2. El idioma por defecto configurado para la aplicación.
-/// 3. Un idioma válido extraído de la cabecera `Accept-Language` del navegador.
-/// 4. Y si ninguna de las opciones anteriores aplica, se usa el idioma de respaldo (`"en-US"`).
-///
-/// Resulta útil para usar un contexto ([`Context`]) como fuente de traducción en
-/// [`L10n::using()`](crate::locale::L10n::using) o
-/// [`L10n::to_markup()`](crate::locale::L10n::to_markup).
-impl LangId for Context {
- fn langid(&self) -> &'static LanguageIdentifier {
- self.langid
- }
-}
diff --git a/src/html/maud.rs b/src/html/maud.rs
index 9bf179e..6536036 100644
--- a/src/html/maud.rs
+++ b/src/html/maud.rs
@@ -69,23 +69,6 @@ impl fmt::Write for Escaper<'_> {
/// `.render()` or `.render_to()`. Since the default definitions of
/// these methods call each other, not doing this will result in
/// infinite recursion.
-///
-/// # Example
-///
-/// ```rust
-/// use pagetop::prelude::*;
-///
-/// /// Provides a shorthand for linking to a CSS stylesheet.
-/// pub struct Stylesheet(&'static str);
-///
-/// impl Render for Stylesheet {
-/// fn render(&self) -> Markup {
-/// html! {
-/// link rel="stylesheet" type="text/css" href=(self.0);
-/// }
-/// }
-/// }
-/// ```
pub trait Render {
/// Renders `self` as a block of `Markup`.
fn render(&self) -> Markup {
@@ -238,6 +221,10 @@ impl Markup {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
+
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
}
impl> PreEscaped {
diff --git a/src/html/opt_component.rs b/src/html/opt_component.rs
deleted file mode 100644
index 39106d9..0000000
--- a/src/html/opt_component.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-use crate::builder_fn;
-use crate::core::component::{Component, Typed};
-use crate::html::{html, Context, Markup};
-
-/// Contenedor de componente para incluir en otros componentes.
-///
-/// Este tipo encapsula `Option>` para incluir un componente de manera segura en otros
-/// componentes, útil para representar estructuras complejas.
-///
-/// # Ejemplo
-///
-/// ```rust,ignore
-/// use pagetop::prelude::*;
-///
-/// let comp = MyComponent::new();
-/// let opt = OptionComponent::new(comp);
-/// assert!(opt.get().is_some());
-/// ```
-pub struct OptionComponent(Option>);
-
-impl Default for OptionComponent {
- fn default() -> Self {
- OptionComponent(None)
- }
-}
-
-impl OptionComponent {
- /// Crea un nuevo [`OptionComponent`].
- ///
- /// El componente se envuelve automáticamente en un [`Typed`] y se almacena.
- pub fn new(component: C) -> Self {
- OptionComponent::default().with_value(Some(component))
- }
-
- // OptionComponent BUILDER *********************************************************************
-
- /// Establece un componente nuevo, o lo vacía.
- ///
- /// Si se proporciona `Some(component)`, se guarda en [`Typed`]; y si es `None`, se limpia.
- #[builder_fn]
- pub fn with_value(mut self, component: Option) -> Self {
- if let Some(component) = component {
- self.0 = Some(Typed::with(component));
- } else {
- self.0 = None;
- }
- self
- }
-
- // OptionComponent GETTERS *********************************************************************
-
- /// Devuelve el componente, si existe.
- pub fn get(&self) -> Option> {
- if let Some(value) = &self.0 {
- return Some(value.clone());
- }
- None
- }
-
- /// Renderiza el componente, si existe.
- pub fn render(&self, cx: &mut Context) -> Markup {
- if let Some(component) = &self.0 {
- component.render(cx)
- } else {
- html! {}
- }
- }
-}
diff --git a/src/html/opt_id.rs b/src/html/opt_id.rs
deleted file mode 100644
index 893ac6d..0000000
--- a/src/html/opt_id.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-use crate::{builder_fn, AutoDefault};
-
-/// Identificador normalizado para el atributo `id` o similar de HTML.
-///
-/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso.
-///
-/// # Normalización
-///
-/// - Se eliminan los espacios al principio y al final.
-/// - Se sustituyen los espacios intermedios por guiones bajos (`_`).
-/// - Si el resultado es una cadena vacía, se guarda `None`.
-///
-/// # Ejemplo
-///
-/// ```rust
-/// use pagetop::prelude::*;
-///
-/// let id = OptionId::new("main section");
-/// assert_eq!(id.get(), Some(String::from("main_section")));
-///
-/// let empty = OptionId::default();
-/// assert_eq!(empty.get(), None);
-/// ```
-#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
-pub struct OptionId(Option);
-
-impl OptionId {
- /// Crea un nuevo [`OptionId`].
- ///
- /// El valor se normaliza automáticamente.
- pub fn new(value: impl AsRef) -> Self {
- OptionId::default().with_value(value)
- }
-
- // OptionId BUILDER ****************************************************************************
-
- /// Establece un identificador nuevo.
- ///
- /// El valor se normaliza automáticamente.
- #[builder_fn]
- pub fn with_value(mut self, value: impl AsRef) -> Self {
- let value = value.as_ref().trim().replace(' ', "_");
- self.0 = (!value.is_empty()).then_some(value);
- self
- }
-
- // OptionId GETTERS ****************************************************************************
-
- /// Devuelve el identificador, si existe.
- pub fn get(&self) -> Option {
- if let Some(value) = &self.0 {
- if !value.is_empty() {
- return Some(value.to_owned());
- }
- }
- None
- }
-}
diff --git a/src/html/opt_name.rs b/src/html/opt_name.rs
deleted file mode 100644
index aa74e3b..0000000
--- a/src/html/opt_name.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-use crate::{builder_fn, AutoDefault};
-
-/// Nombre normalizado para el atributo `name` o similar de HTML.
-///
-/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso.
-///
-/// # Normalización
-///
-/// - Se eliminan los espacios al principio y al final.
-/// - Se sustituyen los espacios intermedios por guiones bajos (`_`).
-/// - Si el resultado es una cadena vacía, se guarda `None`.
-///
-/// # Ejemplo
-///
-/// ```rust
-/// use pagetop::prelude::*;
-///
-/// let name = OptionName::new(" display name ");
-/// assert_eq!(name.get(), Some(String::from("display_name")));
-///
-/// let empty = OptionName::default();
-/// assert_eq!(empty.get(), None);
-/// ```
-#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
-pub struct OptionName(Option);
-
-impl OptionName {
- /// Crea un nuevo [`OptionName`].
- ///
- /// El valor se normaliza automáticamente.
- pub fn new(value: impl AsRef) -> Self {
- OptionName::default().with_value(value)
- }
-
- // OptionName BUILDER **************************************************************************
-
- /// Establece un nombre nuevo.
- ///
- /// El valor se normaliza automáticamente.
- #[builder_fn]
- pub fn with_value(mut self, value: impl AsRef) -> Self {
- let value = value.as_ref().trim().replace(' ', "_");
- self.0 = (!value.is_empty()).then_some(value);
- self
- }
-
- // OptionName GETTERS **************************************************************************
-
- /// Devuelve el nombre, si existe.
- pub fn get(&self) -> Option {
- if let Some(value) = &self.0 {
- if !value.is_empty() {
- return Some(value.to_owned());
- }
- }
- None
- }
-}
diff --git a/src/html/opt_string.rs b/src/html/opt_string.rs
deleted file mode 100644
index 5bfd9c7..0000000
--- a/src/html/opt_string.rs
+++ /dev/null
@@ -1,57 +0,0 @@
-use crate::{builder_fn, AutoDefault};
-
-/// Cadena normalizada para renderizar en atributos HTML.
-///
-/// Este tipo encapsula `Option` garantizando un valor normalizado para su uso.
-///
-/// # Normalización
-///
-/// - Se eliminan los espacios al principio y al final.
-/// - Si el resultado es una cadena vacía, se guarda `None`.
-///
-/// # Ejemplo
-///
-/// ```rust
-/// use pagetop::prelude::*;
-///
-/// let s = OptionString::new(" a new string ");
-/// assert_eq!(s.get(), Some(String::from("a new string")));
-///
-/// let empty = OptionString::default();
-/// assert_eq!(empty.get(), None);
-/// ```
-#[derive(AutoDefault, Clone, Debug, Hash, Eq, PartialEq)]
-pub struct OptionString(Option);
-
-impl OptionString {
- /// Crea un nuevo [`OptionString`].
- ///
- /// El valor se normaliza automáticamente.
- pub fn new(value: impl AsRef) -> Self {
- OptionString::default().with_value(value)
- }
-
- // OptionString BUILDER ************************************************************************
-
- /// Establece una cadena nueva.
- ///
- /// El valor se normaliza automáticamente.
- #[builder_fn]
- pub fn with_value(mut self, value: impl AsRef) -> Self {
- let value = value.as_ref().trim().to_owned();
- self.0 = (!value.is_empty()).then_some(value);
- self
- }
-
- // OptionString GETTERS ************************************************************************
-
- /// Devuelve la cadena, si existe.
- pub fn get(&self) -> Option {
- if let Some(value) = &self.0 {
- if !value.is_empty() {
- return Some(value.to_owned());
- }
- }
- None
- }
-}
diff --git a/src/html/opt_translated.rs b/src/html/opt_translated.rs
deleted file mode 100644
index b15ea18..0000000
--- a/src/html/opt_translated.rs
+++ /dev/null
@@ -1,65 +0,0 @@
-use crate::html::Markup;
-use crate::locale::{L10n, LangId};
-use crate::{builder_fn, AutoDefault};
-
-/// Cadena para traducir al renderizar ([`locale`](crate::locale)).
-///
-/// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura.
-///
-/// # Ejemplo
-///
-/// ```rust
-/// use pagetop::prelude::*;
-///
-/// // Traducción por clave en las locales por defecto de PageTop.
-/// let hello = OptionTranslated::new(L10n::l("test-hello-world"));
-///
-/// // Español disponible.
-/// assert_eq!(
-/// hello.using(&LangMatch::resolve("es-ES")),
-/// Some(String::from("¡Hola mundo!"))
-/// );
-///
-/// // Japonés no disponible, traduce al idioma de respaldo ("en-US").
-/// assert_eq!(
-/// hello.using(&LangMatch::resolve("ja-JP")),
-/// Some(String::from("Hello world!"))
-/// );
-///
-/// // Para incrustar en HTML escapado:
-/// let markup = hello.to_markup(&LangMatch::resolve("es-ES"));
-/// assert_eq!(markup.into_string(), "¡Hola mundo!");
-/// ```
-#[derive(AutoDefault, Clone, Debug)]
-pub struct OptionTranslated(L10n);
-
-impl OptionTranslated {
- /// Crea una nueva instancia [`OptionTranslated`].
- pub fn new(value: L10n) -> Self {
- OptionTranslated(value)
- }
-
- // OptionTranslated BUILDER ********************************************************************
-
- /// Establece una traducción nueva.
- #[builder_fn]
- pub fn with_value(mut self, value: L10n) -> Self {
- self.0 = value;
- self
- }
-
- // OptionTranslated GETTERS ********************************************************************
-
- /// Devuelve la traducción para `language`, si existe.
- pub fn using(&self, language: &impl LangId) -> Option {
- self.0.using(language)
- }
-
- /// Devuelve la traducción *escapada* como [`Markup`] para `language`, si existe.
- ///
- /// Útil para incrustar el texto directamente en plantillas HTML sin riesgo de inyección de
- /// contenido.
- pub fn to_markup(&self, language: &impl LangId) -> Markup {
- self.0.to_markup(language)
- }
-}
diff --git a/src/lib.rs b/src/lib.rs
index 90ea462..1c1ba2c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,8 +15,8 @@
-`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
-la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
+PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
+creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo:
@@ -25,14 +25,14 @@ según las necesidades de cada proyecto, incluyendo:
* **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs
- de `PageTop` o de terceros.
+ de PageTop o de terceros.
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad.
# ⚡️ Guía rápida
-La aplicación más sencilla de `PageTop` se ve así:
+La aplicación más sencilla de PageTop se ve así:
```rust,no_run
use pagetop::prelude::*;
@@ -43,10 +43,10 @@ async fn main() -> std::io::Result<()> {
}
```
-Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página
-de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
+Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
+bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
-Para personalizar el servicio, se puede crear una extensión de `PageTop` de la siguiente manera:
+Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
@@ -60,8 +60,8 @@ impl Extension for HelloWorld {
}
async fn hello_world(request: HttpRequest) -> ResultPage {
- Page::new(Some(request))
- .with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
+ Page::new(request)
+ .add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
@@ -77,11 +77,11 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági
# 🧩 Gestión de Dependencias
-Los proyectos que utilizan `PageTop` gestionan las dependencias con `cargo`, como cualquier otro
+Los proyectos que utilizan PageTop gestionan las dependencias con `cargo`, como cualquier otro
proyecto en Rust.
Sin embargo, es fundamental que cada extensión declare explícitamente sus
-[dependencias](core::extension::Extension::dependencies), si las tiene, para que `PageTop` pueda
+[dependencias](core::extension::Extension::dependencies), si las tiene, para que PageTop pueda
estructurar e inicializar la aplicación de forma modular.
*/
@@ -138,7 +138,7 @@ pub type Weight = i8;
// API *********************************************************************************************
-// Funciones y macros útiles.
+// Macros y funciones útiles.
pub mod util;
// Carga las opciones de configuración.
pub mod config;
diff --git a/src/locale.rs b/src/locale.rs
index f23f51e..2bf0da9 100644
--- a/src/locale.rs
+++ b/src/locale.rs
@@ -1,6 +1,6 @@
//! Localización (L10n).
//!
-//! `PageTop` utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la
+//! PageTop utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la
//! localización de aplicaciones, y aprovecha [fluent-templates](https://docs.rs/fluent-templates/)
//! para integrar los recursos de traducción directamente en el binario de la aplicación.
//!
@@ -13,7 +13,7 @@
//!
//! # Recursos Fluent
//!
-//! Por defecto las traducciones están en el directorio `src/locale`, con subdirectorios para cada
+//! Por defecto, las traducciones están en el directorio `src/locale`, con subdirectorios para cada
//! [Identificador de Idioma Unicode](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier)
//! válido. Podríamos tener una estructura como esta:
//!
@@ -34,7 +34,7 @@
//! └── main.ftl
//! ```
//!
-//! Ejemplo de un archivo en `src/locale/en-US/main.ftl`
+//! Ejemplo de un archivo en `src/locale/en-US/main.ftl`:
//!
//! ```text
//! hello-world = Hello world!
@@ -53,7 +53,7 @@
//! Y su archivo equivalente para español en `src/locale/es-ES/main.ftl`:
//!
//! ```text
-//! hello-world = Hola mundo!
+//! hello-world = ¡Hola, mundo!
//! hello-user = ¡Hola, {$userName}!
//! shared-photos =
//! {$userName} {$photoCount ->
@@ -81,13 +81,13 @@
//! include_locales!(LOCALES_SAMPLE);
//! ```
//!
-//! Si están ubicados en otro directorio se puede usar la forma:
+//! Si están ubicados en otro directorio, se puede usar la forma:
//!
//! ```rust,ignore
//! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones");
//! ```
//!
-//! Y *voilà*, sólo queda operar con los idiomas soportados por `PageTop` usando [`LangMatch`] y
+//! Y *voilà*, sólo queda operar con los idiomas soportados por PageTop usando [`LangMatch`] y
//! traducir textos con [`L10n`].
use crate::html::{Markup, PreEscaped};
@@ -129,7 +129,7 @@ pub(crate) static FALLBACK_LANGID: LazyLock =
// Identificador de idioma **por defecto** para la aplicación.
//
// Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador
-// de idioma no es válido o no está disponible entonces resuelve como [`FALLBACK_LANGID`].
+// de idioma no es válido o no está disponible, se usa [`FALLBACK_LANGID`].
pub(crate) static DEFAULT_LANGID: LazyLock