Compare commits

..

No commits in common. "36e2d9bec81788f6a1f7fad855ea59e4cd40567a" and "bf2c298d189e774edf263d9369764c28a6908217" have entirely different histories.

79 changed files with 1421 additions and 2677 deletions

View file

@ -1,7 +1,8 @@
# 🔃 Dependencias # 🔃 Dependencias
PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando `PageTop` está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes
algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs) como son: 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. * [Actix Web](https://actix.rs/) para los servicios web.
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración. * [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración.
@ -10,14 +11,14 @@ algunas de las librerías más robustas y populares del [ecosistema Rust](https:
* [Fluent templates](https://github.com/XAMPPRocky/fluent-templates), que integra * [Fluent templates](https://github.com/XAMPPRocky/fluent-templates), que integra
[Fluent](https://projectfluent.org/) para internacionalizar las aplicaciones. [Fluent](https://projectfluent.org/) para internacionalizar las aplicaciones.
* Además de otros *crates* adicionales que se pueden explorar en los archivos `Cargo.toml` de * 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 # 🗚 FIGfonts
PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan* `PageTop` usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por
para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres *yuanbohan* para mostrar un banner de presentación en el terminal con el nombre de la aplicación en
[FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son: 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* * [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* * [small.flf](http://www.figlet.org/fontdb_example.cgi?font=small.flf) de *Glenn Chappell*

7
Cargo.lock generated
View file

@ -1307,12 +1307,6 @@ dependencies = [
"hashbrown 0.15.4", "hashbrown 0.15.4",
] ]
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@ -1574,7 +1568,6 @@ dependencies = [
"config", "config",
"figlet-rs", "figlet-rs",
"fluent-templates", "fluent-templates",
"indoc",
"itoa", "itoa",
"pagetop-build", "pagetop-build",
"pagetop-macros", "pagetop-macros",

View file

@ -20,7 +20,6 @@ colored = "3.0.0"
concat-string = "1.0.1" concat-string = "1.0.1"
config = { version = "0.15.13", default-features = false, features = ["toml"] } config = { version = "0.15.13", default-features = false, features = ["toml"] }
figlet-rs = "0.1.5" figlet-rs = "0.1.5"
indoc = "2.0.6"
itoa = "1.0.15" itoa = "1.0.15"
parking_lot = "0.12.4" parking_lot = "0.12.4"
paste = { package = "pastey", version = "0.1.0" } paste = { package = "pastey", version = "0.1.0" }

View file

@ -14,8 +14,8 @@
<br> <br>
</div> </div>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la `PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript. 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 Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo: 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, * **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables. configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs * **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 * **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad. componentes sin comprometer su funcionalidad.
# ⚡️ Guía rápida # ⚡️ 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 ```rust,no_run
use pagetop::prelude::*; 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 Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. 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 ```rust,no_run
use pagetop::prelude::*; use pagetop::prelude::*;
@ -59,8 +59,8 @@ impl Extension for HelloWorld {
} }
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> { async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request) Page::new(Some(request))
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render() .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)**, * **[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 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 `PageTop` para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo
compilación. de compilación.
* **[pagetop-build](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-build)**, * **[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 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)**, * **[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 # 🧪 Pruebas
@ -116,7 +116,7 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
# 🚧 Advertencia # 🚧 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 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**. hasta que se libere la versión **1.0.0**.

View file

@ -13,8 +13,8 @@ async fn hello_name(
path: service::web::Path<String>, path: service::web::Path<String>,
) -> ResultPage<Markup, ErrorPage> { ) -> ResultPage<Markup, ErrorPage> {
let name = path.into_inner(); let name = path.into_inner();
Page::new(request) Page::new(Some(request))
.add_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } })) .with_component(Html::with(move |_| html! { h1 { "Hello " (name) "!" } }))
.render() .render()
} }

View file

@ -9,8 +9,8 @@ impl Extension for HelloWorld {
} }
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> { async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request) Page::new(Some(request))
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render() .render()
} }

View file

@ -113,7 +113,7 @@ impl Extension for MyExtension {
# 🚧 Advertencia # 🚧 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 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**. hasta que se libere la versión **1.0.0**.

View file

@ -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 [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 [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 necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de
cada proyecto PageTop. cada proyecto `PageTop`.
# 🚧 Advertencia # 🚧 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 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**. hasta que se libere la versión **1.0.0**.

View file

@ -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 [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 [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 necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de
cada proyecto PageTop. cada proyecto `PageTop`.
*/ */
#![doc( #![doc(
@ -39,7 +39,7 @@ mod smart_default;
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::{quote, quote_spanned}; use quote::{quote, quote_spanned};
use syn::{parse_macro_input, spanned::Spanned, DeriveInput}; use syn::{parse_macro_input, spanned::Spanned, DeriveInput, ItemFn};
/// Macro para escribir plantillas HTML (basada en [Maud](https://docs.rs/maud)). /// Macro para escribir plantillas HTML (basada en [Maud](https://docs.rs/maud)).
#[proc_macro] #[proc_macro]
@ -107,221 +107,119 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// `alter_...()`, que permitirá más adelante modificar instancias existentes. /// `alter_...()`, que permitirá más adelante modificar instancias existentes.
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream { pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
use syn::{parse2, FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type}; let fn_with = parse_macro_input!(item as ItemFn);
let fn_with_name = fn_with.sig.ident.clone();
let ts: proc_macro2::TokenStream = item.clone().into(); let fn_with_name_str = fn_with.sig.ident.to_string();
enum Kind {
Impl(ImplItemFn),
Trait(TraitItemFn),
}
// Detecta si estamos en `impl` o `trait`.
let kind = if let Ok(it) = parse2::<ImplItemFn>(ts.clone()) {
Kind::Impl(it)
} else if let Ok(tt) = parse2::<TraitItemFn>(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. // Valida el nombre del método.
if !with_name_str.starts_with("with_") { if !fn_with_name_str.starts_with("with_") {
return quote_spanned! { let expanded = quote_spanned! {
sig.ident.span() => compile_error!("expected a named `with_...()` method"); fn_with.sig.ident.span() =>
} compile_error!("expected a \"pub fn with_...(mut self, ...) -> Self\" method");
.into();
}
// Sólo se exige `pub` en `impl` (en `trait` no aplica).
let vis_pub = match (is_trait, vis) {
(false, Some(v)) => quote! { #v },
_ => quote! {},
}; };
return expanded.into();
// Validaciones comunes. }
if sig.asyncness.is_some() { // Valida que el método es público.
if !matches!(fn_with.vis, syn::Visibility::Public(_)) {
return quote_spanned! { return quote_spanned! {
sig.asyncness.span() => compile_error!("`with_...()` cannot be `async`"); fn_with.sig.ident.span() => compile_error!("expected method to be `pub`");
} }
.into(); .into();
} }
if sig.constness.is_some() { // 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! { return quote_spanned! {
sig.constness.span() => compile_error!("`with_...()` cannot be `const`"); receiver.span() => compile_error!("expected `mut self` as the first argument");
} }
.into(); .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 { } else {
// Exactamente `mut self`.
r.reference.is_none() && r.mutability.is_some()
}
}
_ => 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! { return quote_spanned! {
err => compile_error!(#msg); fn_with.sig.ident.span() => compile_error!("expected `mut self` as the first argument");
} }
.into(); .into();
} }
// Valida que el método devuelve exactamente `Self`. // Valida que el método devuelve exactamente `Self`.
match &sig.output { if let syn::ReturnType::Type(_, ty) = &fn_with.sig.output {
ReturnType::Type(_, ty) => match ty.as_ref() { if let syn::Type::Path(type_path) = ty.as_ref() {
Type::Path(p) if p.qself.is_none() && p.path.is_ident("Self") => {} if type_path.qself.is_some() || !type_path.path.is_ident("Self") {
_ => { return quote_spanned! { ty.span() =>
return quote_spanned! { compile_error!("expected return type to be exactly `Self`");
ty.span() => compile_error!("expected return type to be exactly `Self`");
} }
.into(); .into();
} }
}, } else {
_ => { return quote_spanned! { ty.span() =>
return quote_spanned! { compile_error!("expected return type to be exactly `Self`");
sig.output.span() => compile_error!("expected return type to be exactly `Self`");
} }
.into(); .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_...(). // Genera el nombre del método alter_...().
let stem = with_name_str.strip_prefix("with_").expect("validated"); let fn_alter_name_str = fn_with_name_str.replace("with_", "alter_");
let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span()); let fn_alter_name = syn::Ident::new(&fn_alter_name_str, fn_with.sig.ident.span());
// Extrae genéricos y cláusulas where. // Extrae genéricos y cláusulas where.
let generics = &sig.generics; let fn_generics = &fn_with.sig.generics;
let where_clause = &sig.generics.where_clause; let where_clause = &fn_with.sig.generics.where_clause;
// Extrae identificadores de los argumentos para la llamada (sin `mut` ni patrones complejos). // Extrae argumentos y parámetros de llamada.
let args: Vec<_> = sig.inputs.iter().skip(1).collect(); let args: Vec<_> = fn_with.sig.inputs.iter().skip(1).collect();
let call_idents: Vec<Ident> = { let params: Vec<_> = fn_with
let mut v = Vec::new(); .sig
for arg in sig.inputs.iter().skip(1) { .inputs
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() .iter()
.filter(|&a| !a.path().is_ident("doc")) .skip(1)
.cloned() .map(|arg| match arg {
syn::FnArg::Typed(pat) => &pat.pat,
_ => panic!("unexpected argument type"),
})
.collect(); .collect();
// Documentación del método alter_...(). // Extrae bloque del método.
let alter_doc = let fn_with_block = &fn_with.block;
format!("Equivalente a [`Self::{with_name_str}()`], pero fuera del patrón *builder*.");
// Genera el código final. // Extrae documentación y otros atributos del método.
let expanded = match body_opt { let fn_with_attrs = &fn_with.attrs;
None => {
quote! {
#(#attrs)*
fn #with_name #generics (self, #(#args),*) -> Self #where_clause;
#(#non_doc_attrs)* // Genera el método alter_...() con el código del método with_...().
#[doc = #alter_doc] let fn_alter_doc =
fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause; format!("Igual que [`Self::{fn_with_name_str}()`], pero sin usar el patrón *builder*.");
}
} let fn_alter = quote! {
Some(body) => { #[doc = #fn_alter_doc]
let with_fn = if is_trait { pub fn #fn_alter_name #fn_generics(&mut self, #(#args),*) -> &mut Self #where_clause {
quote! { #fn_with_block
#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)* // Redefine el método with_...() para que llame a alter_...().
#[doc = #alter_doc] let fn_with = quote! {
#vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause { #(#fn_with_attrs)*
#body #[inline]
} pub fn #fn_with_name #fn_generics(mut self, #(#args),*) -> Self #where_clause {
} self.#fn_alter_name(#(#params),*);
self
} }
}; };
// Genera el código final.
let expanded = quote! {
#fn_with
#[inline]
#fn_alter
};
expanded.into() 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 /// # Ejemplo
/// ///
@ -342,7 +240,7 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
output output
} }
/// Define funciones de prueba asíncronas para usar con PageTop. /// Define funciones de prueba asíncronas para usar con `PageTop`.
/// ///
/// # Ejemplo /// # Ejemplo
/// ///

View file

@ -16,7 +16,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general ## 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 servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación. 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 [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). [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`. `static-files` manualmente como dependencia en su `Cargo.toml`.
# 🚧 Advertencia # 🚧 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 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**. hasta que se libere la versión **1.0.0**.

View file

@ -17,7 +17,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general ## 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 servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación. 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 [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). [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`. `static-files` manualmente como dependencia en su `Cargo.toml`.
*/ */

View file

@ -1,11 +1,8 @@
//! Prepara y ejecuta una aplicación creada con PageTop. //! Prepara y ejecuta una aplicación creada con `Pagetop`.
mod figfont; mod figfont;
use crate::core::{extension, extension::ExtensionRef}; 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 crate::{global, locale, service, trace};
use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle}; use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle};
@ -17,7 +14,7 @@ use substring::Substring;
use std::io::Error; use std::io::Error;
use std::sync::LazyLock; 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 /// 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). /// instanciarla se puede usar [`new()`](Application::new) o [`prepare()`](Application::prepare).
@ -84,7 +81,7 @@ impl Application {
if let Some((Width(term_width), _)) = terminal_size() { if let Some((Width(term_width), _)) = terminal_size() {
if term_width >= 80 { if term_width >= 80 {
let maxlen: usize = ((term_width / 10) - 2).into(); let maxlen: usize = ((term_width / 10) - 2).into();
let mut app = app_name.substring(0, maxlen).to_string(); let mut app = app_name.substring(0, maxlen).to_owned();
if app_name.len() > maxlen { if app_name.len() > maxlen {
app = format!("{app}..."); app = format!("{app}...");
} }
@ -173,12 +170,6 @@ impl Application {
InitError = (), InitError = (),
>, >,
> { > {
service::App::new() service::App::new().configure(extension::all::configure_services)
.configure(extension::all::configure_services)
.default_service(service::web::route().to(service_not_found))
} }
} }
async fn service_not_found(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Err(ErrorPage::NotFound(request))
}

View file

@ -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::*; use crate::prelude::*;

View file

@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent;
pub struct AfterRender<C: Component> { pub struct AfterRender<C: Component> {
f: FnActionWithComponent<C>, f: FnActionWithComponent<C>,
referer_type_id: Option<UniqueId>, referer_type_id: Option<UniqueId>,
referer_id: AttrId, referer_id: OptionId,
weight: Weight, weight: Weight,
} }
@ -34,7 +34,7 @@ impl<C: Component> AfterRender<C> {
AfterRender { AfterRender {
f, f,
referer_type_id: Some(UniqueId::of::<C>()), referer_type_id: Some(UniqueId::of::<C>()),
referer_id: AttrId::default(), referer_id: OptionId::default(),
weight: 0, weight: 0,
} }
} }

View file

@ -6,7 +6,7 @@ use crate::base::action::FnActionWithComponent;
pub struct BeforeRender<C: Component> { pub struct BeforeRender<C: Component> {
f: FnActionWithComponent<C>, f: FnActionWithComponent<C>,
referer_type_id: Option<UniqueId>, referer_type_id: Option<UniqueId>,
referer_id: AttrId, referer_id: OptionId,
weight: Weight, weight: Weight,
} }
@ -34,7 +34,7 @@ impl<C: Component> BeforeRender<C> {
BeforeRender { BeforeRender {
f, f,
referer_type_id: Some(UniqueId::of::<C>()), referer_type_id: Some(UniqueId::of::<C>()),
referer_id: AttrId::default(), referer_id: OptionId::default(),
weight: 0, weight: 0,
} }
} }

View file

@ -11,7 +11,7 @@ pub type FnIsRenderable<C> = fn(component: &C, cx: &Context) -> bool;
pub struct IsRenderable<C: Component> { pub struct IsRenderable<C: Component> {
f: FnIsRenderable<C>, f: FnIsRenderable<C>,
referer_type_id: Option<UniqueId>, referer_type_id: Option<UniqueId>,
referer_id: AttrId, referer_id: OptionId,
weight: Weight, weight: Weight,
} }
@ -39,7 +39,7 @@ impl<C: Component> IsRenderable<C> {
IsRenderable { IsRenderable {
f, f,
referer_type_id: Some(UniqueId::of::<C>()), referer_type_id: Some(UniqueId::of::<C>()),
referer_id: AttrId::default(), referer_id: OptionId::default(),
weight: 0, weight: 0,
} }
} }

View file

@ -1,10 +1,4 @@
//! Componentes nativos proporcionados por PageTop. //! Componentes nativos proporcionados por `PageTop`.
mod html; mod html;
pub use html::Html; pub use html::Html;
mod block;
pub use block::Block;
mod poweredby;
pub use poweredby::PoweredBy;

View file

@ -1,103 +0,0 @@
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<String> {
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::<Block>(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<str>) -> 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<str>) -> 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
}
}

View file

@ -25,7 +25,7 @@ use crate::prelude::*;
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// ///
/// let component = Html::with(|cx| { /// let component = Html::with(|cx| {
/// let user = cx.param::<String>("username").cloned().unwrap_or("visitor".to_string()); /// let user = cx.get_param::<String>("username").unwrap_or(String::from("visitor"));
/// html! { /// html! {
/// h1 { "Hello, " (user) } /// h1 { "Hello, " (user) }
/// } /// }
@ -44,13 +44,11 @@ impl Component for Html {
} }
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With(self.html(cx)) PrepareMarkup::With((self.0)(cx))
} }
} }
impl Html { impl Html {
// Html BUILDER ********************************************************************************
/// Crea una instancia que generará el `Markup`, con acceso opcional al contexto. /// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
/// ///
/// El método [`prepare_component()`](crate::core::component::Component::prepare_component) /// El método [`prepare_component()`](crate::core::component::Component::prepare_component)
@ -68,24 +66,11 @@ impl Html {
/// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando /// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando
/// [`prepare_component()`](crate::core::component::Component::prepare_component) invoque esta /// [`prepare_component()`](crate::core::component::Component::prepare_component) invoque esta
/// instancia. La nueva función también recibe una referencia al contexto ([`Context`]). /// instancia. La nueva función también recibe una referencia al contexto ([`Context`]).
#[builder_fn] pub fn alter_html<F>(&mut self, f: F) -> &mut Self
pub fn with_fn<F>(mut self, f: F) -> Self
where where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static, F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{ {
self.0 = Box::new(f); self.0 = Box::new(f);
self 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)
}
} }

View file

@ -1,67 +0,0 @@
use crate::prelude::*;
// Enlace a la página oficial de PageTop.
const LINK: &str = "<a href=\"https://pagetop.cillero.es\" rel=\"noreferrer\">PageTop</a>";
/// 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<String>,
}
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::<String>);
/// ```
#[builder_fn]
pub fn with_copyright(mut self, copyright: Option<impl Into<String>>) -> 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()
}
}

View file

@ -1,4 +1,4 @@
//! Extensiones para funcionalidades avanzadas de PageTop. //! Extensiones para funcionalidades avanzadas de `PageTop`.
mod welcome; mod welcome;
pub use welcome::Welcome; pub use welcome::Welcome;

View file

@ -1,6 +1,6 @@
use crate::prelude::*; 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 /// 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á /// configurado ninguna página de inicio personalizada. Permite confirmar que el servidor está
@ -24,32 +24,93 @@ impl Extension for Welcome {
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> { async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
let app = &global::SETTINGS.app.name; let app = &global::SETTINGS.app.name;
Page::new(request) Page::new(Some(request))
.with_title(L10n::l("welcome_page"))
.with_theme("Basic") .with_theme("Basic")
.with_layout("PageTopIntro") .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css")))
.with_title(L10n::l("welcome_title")) .with_component(Html::with(move |cx| html! {
.with_description(L10n::l("welcome_intro").with_arg("app", app)) div id="main-header" {
.with_param("intro_button_txt", L10n::l("welcome_powered")) header {
.with_param("intro_button_lnk", "https://pagetop.cillero.es".to_string()) h1
.add_component( id="header-title"
Block::new() aria-label=(L10n::l("welcome_aria").with_arg("app", app).to_markup(cx))
.with_title(L10n::l("welcome_status_title")) {
.add_component(Html::with(move |cx| { span { (L10n::l("welcome_title").to_markup(cx)) }
html! { (L10n::l("welcome_intro").with_arg("app", app).to_markup(cx))
p { (L10n::l("welcome_status_1").using(cx)) }
p { (L10n::l("welcome_status_2").using(cx)) }
} }
})),
)
.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)) }
} }
})), 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";
}
}
}
}
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() .render()
} }

View file

@ -1,4 +1,4 @@
//! Temas básicos soportados por PageTop. //! Temas básicos soportados por `PageTop`.
mod basic; mod basic;
pub use basic::Basic; pub use basic::Basic;

View file

@ -1,33 +1,8 @@
/// Es el tema básico que incluye PageTop por defecto. //! Es el tema básico que incluye `PageTop` por defecto.
use crate::prelude::*; use crate::prelude::*;
/// Tema básico por defecto. /// 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<String>`) 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; pub struct Basic;
impl Extension for Basic { impl Extension for Basic {
@ -37,152 +12,11 @@ impl Extension for Basic {
} }
impl Theme 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),
_ => <Self as ThemePage>::render_body(self, page, self.page_regions()),
}
}
fn after_render_page_body(&self, page: &mut Page) { 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( page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css") StyleSheet::from("/css/normalize.css")
.with_version("8.0.1") .with_version("8.0.1")
.with_weight(-99), .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)
}

View file

@ -3,7 +3,7 @@
//! Estos ajustes se obtienen de archivos [TOML](https://toml.io) como pares `clave = valor` que se //! Estos ajustes se obtienen de archivos [TOML](https://toml.io) como pares `clave = valor` que se
//! mapean a estructuras **fuertemente tipadas** y valores predefinidos. //! 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 //! **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. //! 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 //! 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. //! 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 //! 1. **config/common.toml**, para ajustes comunes a todos los entornos. Este enfoque simplifica el
//! mantenimiento al centralizar los valores de configuración comunes. //! 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`: //! 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. //! cargar *config/default.toml* si el archivo existe.
//! //!
//! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.ej. //! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.ej.

View file

@ -117,7 +117,7 @@ impl TypeInfo {
/// ///
/// Este *trait* se implementa automáticamente para **todos** los tipos que implementen [`Any`], de /// 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 /// 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 /// # Ejemplo
/// ///

View file

@ -7,6 +7,3 @@ mod children;
pub use children::Children; pub use children::Children;
pub use children::{Child, ChildOp}; pub use children::{Child, ChildOp};
pub use children::{Typed, TypedOp}; pub use children::{Typed, TypedOp};
mod slot;
pub use slot::TypedSlot;

View file

@ -9,13 +9,13 @@ use std::vec::IntoIter;
/// Representa un componente encapsulado de forma segura y compartida. /// Representa un componente encapsulado de forma segura y compartida.
/// ///
/// Esta estructura permite manipular y renderizar un componente que implemente [`Component`], y /// Esta estructura permite manipular y renderizar cualquier tipo que implemente [`Component`],
/// habilita acceso concurrente mediante [`Arc<RwLock<_>>`]. /// garantizando acceso concurrente a través de [`Arc<RwLock<_>>`].
#[derive(Clone)] #[derive(Clone)]
pub struct Child(Arc<RwLock<dyn Component>>); pub struct Child(Arc<RwLock<dyn Component>>);
impl Child { 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 { pub fn with(component: impl Component) -> Self {
Child(Arc::new(RwLock::new(component))) Child(Arc::new(RwLock::new(component)))
} }
@ -46,8 +46,7 @@ impl Child {
/// Variante tipada de [`Child`] para evitar conversiones durante el uso. /// Variante tipada de [`Child`] para evitar conversiones durante el uso.
/// ///
/// Esta estructura permite manipular y renderizar un componente concreto que implemente /// Facilita el acceso a componentes del mismo tipo sin necesidad de hacer `downcast`.
/// [`Component`], y habilita acceso concurrente mediante [`Arc<RwLock<_>>`].
pub struct Typed<C: Component>(Arc<RwLock<C>>); pub struct Typed<C: Component>(Arc<RwLock<C>>);
impl<C: Component> Clone for Typed<C> { impl<C: Component> Clone for Typed<C> {
@ -57,7 +56,7 @@ impl<C: Component> Clone for Typed<C> {
} }
impl<C: Component> Typed<C> { impl<C: Component> Typed<C> {
/// Crea un nuevo `Typed` a partir de un componente. /// Crea un nuevo [`Typed`] a partir de un componente.
pub fn with(component: C) -> Self { pub fn with(component: C) -> Self {
Typed(Arc::new(RwLock::new(component))) Typed(Arc::new(RwLock::new(component)))
} }
@ -285,7 +284,7 @@ impl IntoIterator for Children {
/// ///
/// # Ejemplo de uso: /// # Ejemplo de uso:
/// ///
/// ```rust,ignore /// ```rust#ignore
/// let children = Children::new().with(child1).with(child2); /// let children = Children::new().with(child1).with(child2);
/// for child in children { /// for child in children {
/// println!("{:?}", child.id()); /// println!("{:?}", child.id());
@ -304,7 +303,7 @@ impl<'a> IntoIterator for &'a Children {
/// ///
/// # Ejemplo de uso: /// # Ejemplo de uso:
/// ///
/// ```rust,ignore /// ```rust#ignore
/// let children = Children::new().with(child1).with(child2); /// let children = Children::new().with(child1).with(child2);
/// for child in &children { /// for child in &children {
/// println!("{:?}", child.id()); /// println!("{:?}", child.id());
@ -323,7 +322,7 @@ impl<'a> IntoIterator for &'a mut Children {
/// ///
/// # Ejemplo de uso: /// # Ejemplo de uso:
/// ///
/// ```rust,ignore /// ```rust#ignore
/// let mut children = Children::new().with(child1).with(child2); /// let mut children = Children::new().with(child1).with(child2);
/// for child in &mut children { /// for child in &mut children {
/// child.render(&mut context); /// child.render(&mut context);

View file

@ -1,6 +1,6 @@
use crate::base::action; use crate::base::action;
use crate::core::{AnyInfo, TypeInfo}; use crate::core::{AnyInfo, TypeInfo};
use crate::html::{html, Context, Markup, PrepareMarkup}; use crate::html::{html, Context, Markup, PrepareMarkup, Render};
/// Define la función de renderizado para todos los componentes. /// 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; 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 /// 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 /// 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::<Self>() TypeInfo::ShortName.of::<Self>()
} }
/// Devuelve una descripción del componente, si existe. /// Devuelve una descripción opcional del componente.
/// ///
/// Por defecto, no se proporciona ninguna descripción (`None`). /// Por defecto, no se proporciona ninguna descripción (`None`).
fn description(&self) -> Option<String> { fn description(&self) -> Option<String> {
None None
} }
/// Devuelve el identificador del componente, si existe. /// Devuelve un identificador opcional para el componente.
/// ///
/// Este identificador puede usarse para referenciar el componente en el HTML. Por defecto, no /// Este identificador puede usarse para referenciar el componente en el HTML. Por defecto, no
/// tiene ningún identificador (`None`). /// tiene ningún identificador (`None`).
@ -51,17 +51,12 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync {
#[allow(unused_variables)] #[allow(unused_variables)]
fn setup_before_prepare(&mut self, cx: &mut Context) {} fn setup_before_prepare(&mut self, cx: &mut Context) {}
/// Devuelve una representación renderizada del componente. /// Devuelve una representación estructurada del componente lista para renderizar.
/// ///
/// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente /// 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 /// durante el proceso de construcción del documento. Puede sobrescribirse para generar
/// dinámicamente el contenido HTML con acceso al contexto de renderizado. /// 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`]. /// Por defecto, devuelve [`PrepareMarkup::None`].
#[allow(unused_variables)] #[allow(unused_variables)]
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {

View file

@ -1,64 +0,0 @@
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<Typed<C>>`, 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<C: Component>(Option<Typed<C>>);
impl<C: Component> Default for TypedSlot<C> {
fn default() -> Self {
TypedSlot(None)
}
}
impl<C: Component> TypedSlot<C> {
/// 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<C>) -> Self {
self.0 = component.map(Typed::with);
self
}
// TypedSlot GETTERS *********************************************************************
/// Devuelve un clon (incrementa el contador `Arc`) de [`Typed<C>`], si existe.
pub fn get(&self) -> Option<Typed<C>> {
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! {}
}
}
}

View file

@ -1,6 +1,6 @@
//! API para añadir nuevas funcionalidades usando extensiones. //! 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`]. //! como una **extensión**. Todas comparten la misma interfaz declarada en [`Extension`].
mod definition; mod definition;

View file

@ -10,7 +10,7 @@ use crate::{actions_boxed, service};
/// cualquier hilo de la ejecución sin necesidad de sincronización adicional. /// cualquier hilo de la ejecución sin necesidad de sincronización adicional.
pub type ExtensionRef = &'static dyn Extension; 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 /// 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. /// 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 { pub trait Extension: AnyInfo + Send + Sync {
/// Nombre localizado de la extensión legible para el usuario. /// Nombre legible para el usuario.
/// ///
/// Predeterminado por el [`short_name()`](AnyInfo::short_name) del tipo asociado a la /// Predeterminado por el [`short_name()`](AnyInfo::short_name) del tipo asociado a la
/// extensión. /// extensión.
@ -34,15 +34,18 @@ pub trait Extension: AnyInfo + Send + Sync {
L10n::n(self.short_name()) L10n::n(self.short_name())
} }
/// Descripción corta localizada de la extensión para paneles, listados, etc. /// Descripción corta para paneles, listados, etc.
fn description(&self) -> L10n { fn description(&self) -> L10n {
L10n::default() L10n::default()
} }
/// Devuelve una referencia a esta misma extensión cuando se trata de un tema. /// Los temas son extensiones que implementan [`Extension`] y también
/// [`Theme`](crate::core::theme::Theme).
/// ///
/// Para ello, debe implementar [`Extension`] y también [`Theme`](crate::core::theme::Theme). Si /// Si la extensión no es un tema, este método devuelve `None` por defecto.
/// 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:
/// ///
/// ```rust /// ```rust
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
@ -63,7 +66,7 @@ pub trait Extension: AnyInfo + Send + Sync {
/// Otras extensiones que deben habilitarse **antes** de esta. /// 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. /// aplicación.
fn dependencies(&self) -> Vec<ExtensionRef> { fn dependencies(&self) -> Vec<ExtensionRef> {
vec![] vec![]
@ -78,7 +81,7 @@ pub trait Extension: AnyInfo + Send + Sync {
actions_boxed![] actions_boxed![]
} }
/// Inicializa la extensión durante la fase de arranque de la aplicación. /// Inicializa la extensión durante la lógica de arranque de la aplicación.
/// ///
/// Se llama una sola vez, después de que todas las dependencias se han inicializado y antes de /// Se llama una sola vez, después de que todas las dependencias se han inicializado y antes de
/// aceptar cualquier petición HTTP. /// aceptar cualquier petición HTTP.
@ -101,8 +104,8 @@ pub trait Extension: AnyInfo + Send + Sync {
#[allow(unused_variables)] #[allow(unused_variables)]
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {} fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {}
/// Permite crear extensiones para deshabilitar y desinstalar recursos de otras de versiones /// Permite crear extensiones para deshabilitar y desinstalar los recursos de otras extensiones
/// anteriores de la aplicación. /// utilizadas en versiones anteriores de la aplicación.
/// ///
/// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones. /// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones.
fn drop_extensions(&self) -> Vec<ExtensionRef> { fn drop_extensions(&self) -> Vec<ExtensionRef> {

View file

@ -1,24 +1,26 @@
//! API para añadir y gestionar nuevos temas. //! 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 //! especialmente las páginas de contenido ([`Page`](crate::response::page::Page)), sin alterar la
//! lógica interna de sus componentes. //! lógica interna de sus componentes.
//! //!
//! Un tema **declara las regiones** (*cabecera*, *barra lateral*, *pie*, etc.) que estarán //! 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, //! disponibles para colocar contenido. Los temas son responsables últimos de los estilos,
//! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (comoanimaciones, //! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (comoanimaciones,
//! scripts de interfaz, etc.). //! *scripts* de interfaz, etc.).
//! //!
//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension); por //! Es una extensión más (implementando [`Extension`](crate::core::extension::Extension)). Se
//! lo que se instancian, declaran sus dependencias y se inician igual que el resto de extensiones; //! instala, activa y declara dependencias igual que el resto de extensiones; y se señala a sí misma
//! pero serán temas si además implementan [`theme()`](crate::core::extension::Extension::theme) y //! como tema (implementando [`theme()`](crate::core::extension::Extension::theme) y [`Theme`]).
//! [`Theme`].
mod definition; mod definition;
pub use definition::{Theme, ThemePage, ThemeRef}; pub use definition::{Theme, ThemeRef};
mod regions; mod regions;
pub(crate) use regions::{ChildrenInRegions, REGION_CONTENT}; pub(crate) use regions::ChildrenInRegions;
pub use regions::{InRegion, Region}; pub use regions::InRegion;
pub(crate) mod all; pub(crate) mod all;
/// Nombre de la región por defecto: `content`.
pub const CONTENT_REGION_NAME: &str = "content";

View file

@ -1,49 +1,52 @@
use crate::core::extension::Extension; use crate::core::extension::Extension;
use crate::core::theme::Region; use crate::core::theme::CONTENT_REGION_NAME;
use crate::global; use crate::global;
use crate::html::{html, Markup}; use crate::html::{html, Markup};
use crate::locale::L10n; use crate::locale::L10n;
use crate::response::page::Page; use crate::response::page::Page;
use std::sync::LazyLock; /// Representa una referencia a un tema.
/// Referencia estática a un tema.
/// ///
/// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que /// Los temas son también extensiones. Por tanto se deben definir igual, es decir, como instancias
/// implementen [`Theme`] y, a su vez, [`Extension`]. /// estáticas globales que implementan [`Theme`], pero también [`Extension`].
pub type ThemeRef = &'static dyn Theme; pub type ThemeRef = &'static dyn Theme;
/// Métodos predefinidos de renderizado para las páginas de un tema. /// Interfaz común que debe implementar cualquier tema de `PageTop`.
/// ///
/// Contiene las implementaciones base de las **secciones** `<head>` y `<body>`. Se implementa /// Un tema implementará [`Theme`] y los métodos que sean necesarios de [`Extension`], aunque el
/// automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente no requiere /// único obligatorio es [`theme()`](Extension::theme).
/// implementación explícita.
/// ///
/// Si un tema **sobrescribe** [`render_page_head()`](Theme::render_page_head) o /// ```rust
/// [`render_page_body()`](Theme::render_page_body), se puede volver al comportamiento por defecto /// use pagetop::prelude::*;
/// cuando se necesite usando FQS (*Fully Qualified Syntax*):
/// ///
/// - `<Self as ThemePage>::render_body(self, page, self.page_regions())` /// pub struct MyTheme;
/// - `<Self as ThemePage>::render_head(self, page)` ///
pub trait ThemePage { /// impl Extension for MyTheme {
/// Renderiza el contenido del `<body>` de la página. /// fn name(&self) -> L10n { L10n::n("My theme") }
/// /// fn description(&self) -> L10n { L10n::n("Un tema personal") }
/// 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 /// fn theme(&self) -> Option<ThemeRef> {
/// de región es **único** dentro de la página. /// Some(&Self)
fn render_body(&self, page: &mut Page, regions: &[(Region, L10n)]) -> Markup { /// }
/// }
///
/// 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 {
html! { html! {
body id=[page.body_id().get()] class=[page.body_classes().get()] { body id=[page.body_id().get()] class=[page.body_classes().get()] {
@for (region, region_label) in regions { @for (region_name, _) in self.regions() {
@let output = page.render_region(region.key()); @let output = page.render_region(region_name);
@if !output.is_empty() { @if !output.is_empty() {
@let region_name = region.name(); div id=(region_name) class={ "region-container region-" (region_name) } {
div
id=(region_name)
class={ "region region--" (region_name) }
role="region"
aria-label=[region_label.lookup(page)]
{
(output) (output)
} }
} }
@ -52,12 +55,10 @@ pub trait ThemePage {
} }
} }
/// Renderiza el contenido del `<head>` de la página. #[allow(unused_variables)]
/// fn after_render_page_body(&self, page: &mut Page) {}
/// Por defecto incluye las etiquetas básicas (`charset`, `title`, `description`, `viewport`,
/// `X-UA-Compatible`), los metadatos (`name/content`) y propiedades (`property/content`), fn render_page_head(&self, page: &mut Page) -> Markup {
/// 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"; let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no";
html! { html! {
head { head {
@ -87,115 +88,12 @@ pub trait ThemePage {
} }
} }
} }
}
/// Interfaz común que debe implementar cualquier tema de PageTop. fn error403(&self, _page: &mut Page) -> Markup {
/// html! { div { h1 { ("FORBIDDEN ACCESS") } } }
/// 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<ThemeRef> {
/// 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"))]
} }
/// Declaración ordenada de las regiones disponibles en la página. fn error404(&self, _page: &mut Page) -> Markup {
/// html! { div { h1 { ("RESOURCE NOT FOUND") } } }
/// 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")),
]
});
&REGIONS[..]
}
/// Acciones específicas del tema antes de renderizar el `<body>` 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 `<body>` de la página.
///
/// Si se sobrescribe este método, se puede volver al comportamiento base con:
/// `<Self as ThemePage>::render_body(self, page, self.page_regions())`.
#[inline]
fn render_page_body(&self, page: &mut Page) -> Markup {
<Self as ThemePage>::render_body(self, page, self.page_regions())
}
/// Acciones específicas del tema después de renderizar el `<body>` 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 `<head>` de la página.
///
/// Si se sobrescribe este método, se puede volver al comportamiento base con:
/// `<Self as ThemePage>::render_head(self, page)`.
#[inline]
fn render_page_head(&self, page: &mut Page) -> Markup {
<Self as ThemePage>::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<T: Theme> ThemePage for T {}

View file

@ -1,5 +1,5 @@
use crate::core::component::{Child, ChildOp, Children}; use crate::core::component::{Child, ChildOp, Children};
use crate::core::theme::ThemeRef; use crate::core::theme::{ThemeRef, CONTENT_REGION_NAME};
use crate::{builder_fn, AutoDefault, UniqueId}; use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock; use parking_lot::RwLock;
@ -7,81 +7,25 @@ use parking_lot::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
// Conjunto de regiones globales asociadas a un tema específico. // Regiones globales con componentes para un tema dado.
static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, ChildrenInRegions>>> = static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, ChildrenInRegions>>> =
LazyLock::new(|| RwLock::new(HashMap::new())); LazyLock::new(|| RwLock::new(HashMap::new()));
// Conjunto de regiones globales comunes a todos los temas. // Regiones globales con componentes para cualquier tema.
static COMMON_REGIONS: LazyLock<RwLock<ChildrenInRegions>> = static COMMON_REGIONS: LazyLock<RwLock<ChildrenInRegions>> =
LazyLock::new(|| RwLock::new(ChildrenInRegions::default())); LazyLock::new(|| RwLock::new(ChildrenInRegions::default()));
/// Nombre de la región de contenido por defecto (`"content"`). // Estructura interna para mantener los componentes de una región.
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)] #[derive(AutoDefault)]
pub struct ChildrenInRegions(HashMap<&'static str, Children>); pub struct ChildrenInRegions(HashMap<&'static str, Children>);
impl ChildrenInRegions { impl ChildrenInRegions {
pub fn with(region_name: &'static str, child: Child) -> Self { pub fn with(region_name: &'static str, child: Child) -> Self {
ChildrenInRegions::default().with_child_in(region_name, ChildOp::Add(child)) ChildrenInRegions::default().with_child_in_region(region_name, ChildOp::Add(child))
} }
#[builder_fn] #[builder_fn]
pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self { pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
if let Some(region) = self.0.get_mut(region_name) { if let Some(region) = self.0.get_mut(region_name) {
region.alter_child(op); region.alter_child(op);
} else { } else {
@ -104,24 +48,25 @@ impl ChildrenInRegions {
} }
} }
/// Punto de acceso para añadir componentes a regiones globales o específicas de un tema. /// Permite añadir componentes a regiones globales o regiones de temas concretos.
/// ///
/// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán /// Dada una región, según la variante seleccionada, se le podrán añadir ([`add()`](Self::add))
/// disponibles durante toda la ejecución. /// componentes que se mantendrán durante la ejecución de la aplicación.
/// ///
/// Estos componentes se renderizarán automáticamente al procesar los documentos HTML que incluyen /// Estas estructuras de componentes se renderizarán automáticamente al procesar los documentos HTML
/// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)). /// que las usan, como las páginas de contenido ([`Page`](crate::response::page::Page)), por
/// ejemplo.
pub enum InRegion { pub enum InRegion {
/// Región de contenido por defecto. /// Representa la región por defecto en la que se pueden añadir componentes.
Content, Content,
/// Región identificada por el nombre proporcionado. /// Representa la región con el nombre del argumento.
Named(&'static str), Named(&'static str),
/// Región identificada por un nombre y asociada a un tema concreto. /// Representa la región con el nombre y del tema especificado en los argumentos.
OfTheme(&'static str, ThemeRef), OfTheme(&'static str, ThemeRef),
} }
impl InRegion { impl InRegion {
/// Añade un componente a la región indicada por la variante. /// Permite añadir un componente en la región de la variante seleccionada.
/// ///
/// # Ejemplo /// # Ejemplo
/// ///
@ -143,17 +88,17 @@ impl InRegion {
InRegion::Content => { InRegion::Content => {
COMMON_REGIONS COMMON_REGIONS
.write() .write()
.alter_child_in(REGION_CONTENT, ChildOp::Add(child)); .alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(child));
} }
InRegion::Named(region_name) => { InRegion::Named(name) => {
COMMON_REGIONS COMMON_REGIONS
.write() .write()
.alter_child_in(region_name, ChildOp::Add(child)); .alter_child_in_region(name, ChildOp::Add(child));
} }
InRegion::OfTheme(region_name, theme_ref) => { InRegion::OfTheme(region_name, theme_ref) => {
let mut regions = THEME_REGIONS.write(); let mut regions = THEME_REGIONS.write();
if let Some(r) = regions.get_mut(&theme_ref.type_id()) { if let Some(r) = regions.get_mut(&theme_ref.type_id()) {
r.alter_child_in(region_name, ChildOp::Add(child)); r.alter_child_in_region(region_name, ChildOp::Add(child));
} else { } else {
regions.insert( regions.insert(
theme_ref.type_id(), theme_ref.type_id(),

View file

@ -50,11 +50,12 @@ pub struct App {
pub theme: String, pub theme: String,
/// Idioma por defecto para la aplicación. /// Idioma por defecto para la aplicación.
/// ///
/// Si no está definido o no es válido, el idioma efectivo para el renderizado se resolverá /// Si no se especifica un valor válido, normalmente se usará el idioma devuelto por la
/// según la implementación de [`LangId`](crate::locale::LangId) en este orden: primero intenta /// implementación de [`LangId`](crate::locale::LangId) para [`Context`](crate::html::Context),
/// con el establecido en [`Contextual::with_langid()`](crate::html::Contextual::with_langid); /// en el siguiente orden: primero, el idioma establecido explícitamente con
/// pero si no se ha definido explícitamente, usará el indicado en la cabecera `Accept-Language` /// [`Context::with_langid()`](crate::html::Context::with_langid); si no se ha definido, se
/// del navegador; y, si ninguno aplica, se empleará el idioma de respaldo ("en-US"). /// 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, pub language: String,
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
/// *"Starwars"*. /// *"Starwars"*.
@ -67,7 +68,7 @@ pub struct App {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
/// Sección `[Dev]` de la configuración. Forma parte de [`Settings`]. /// Sección `[Dev]` de la configuración. Forma parte de [`Settings`].
pub struct Dev { 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 /// 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 /// ruta válida, ya sea absoluta o relativa al directorio del proyecto o del binario en

View file

@ -1,84 +1,54 @@
//! HTML en código. //! HTML en código.
mod maud; mod maud;
pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, DOCTYPE}; pub use maud::{display, html, html_private, Escaper, Markup, PreEscaped, Render, DOCTYPE};
// HTML DOCUMENT ASSETS ****************************************************************************
mod assets; mod assets;
pub use assets::favicon::Favicon; pub use assets::favicon::Favicon;
pub use assets::javascript::JavaScript; pub use assets::javascript::JavaScript;
pub use assets::stylesheet::{StyleSheet, TargetMedia}; pub use assets::stylesheet::{StyleSheet, TargetMedia};
pub use assets::{Asset, Assets}; pub(crate) use assets::Assets;
// HTML DOCUMENT CONTEXT ***************************************************************************
mod context; mod context;
pub use context::{AssetsOp, Context, Contextual, ErrorParam}; pub use context::{AssetsOp, Context, ErrorParam};
// HTML ATTRIBUTES ********************************************************************************* mod opt_id;
pub use opt_id::OptionId;
mod attr_id; mod opt_name;
pub use attr_id::AttrId; pub use opt_name::OptionName;
/// **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 attr_name; mod opt_string;
pub use attr_name::AttrName; pub use opt_string::OptionString;
/// **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 attr_value; mod opt_translated;
pub use attr_value::AttrValue; pub use opt_translated::OptionTranslated;
/// **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 attr_l10n; mod opt_classes;
pub use attr_l10n::AttrL10n; pub use opt_classes::{ClassesOp, OptionClasses};
/// **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 attr_classes; mod opt_component;
pub use attr_classes::{AttrClasses, ClassesOp}; pub use opt_component::OptionComponent;
/// **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::{core, AutoDefault}; use crate::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<C: core::component::Component> = core::component::TypedSlot<C>;
/// Prepara contenido HTML para su conversión a [`Markup`]. /// Prepara contenido HTML para su conversión a [`Markup`].
/// ///
/// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML sin escapar o /// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML escapado o marcado
/// fragmentos ya procesados) para renderizarlos de forma homogénea en plantillas, sin interferir /// ya procesado) para renderizar de forma homogénea en plantillas sin interferir con el uso
/// con el uso estándar de [`Markup`]. /// estándar de [`Markup`].
/// ///
/// # Ejemplo /// # Ejemplo
/// ///
/// ```rust /// ```rust
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// ///
/// // Texto normal, se escapa automáticamente para evitar inyección de HTML. /// let fragment = PrepareMarkup::Text(String::from("Hola <b>mundo</b>"));
/// let fragment = PrepareMarkup::Escaped("Hola <b>mundo</b>".to_string());
/// assert_eq!(fragment.render().into_string(), "Hola &lt;b&gt;mundo&lt;/b&gt;"); /// assert_eq!(fragment.render().into_string(), "Hola &lt;b&gt;mundo&lt;/b&gt;");
/// ///
/// // HTML literal, se inserta directamente, sin escapado adicional. /// let raw_html = PrepareMarkup::Escaped(String::from("<b>negrita</b>"));
/// let raw_html = PrepareMarkup::Raw("<b>negrita</b>".to_string());
/// assert_eq!(raw_html.render().into_string(), "<b>negrita</b>"); /// assert_eq!(raw_html.render().into_string(), "<b>negrita</b>");
/// ///
/// // Fragmento ya preparado con la macro `html!`.
/// let prepared = PrepareMarkup::With(html! { /// let prepared = PrepareMarkup::With(html! {
/// h2 { "Título de ejemplo" } /// h2 { "Título de ejemplo" }
/// p { "Este es un párrafo con contenido dinámico." } /// p { "Este es un párrafo con contenido dinámico." }
@ -90,22 +60,14 @@ pub type OptionComponent<C: core::component::Component> = core::component::Typed
/// ``` /// ```
#[derive(AutoDefault)] #[derive(AutoDefault)]
pub enum PrepareMarkup { pub enum PrepareMarkup {
/// No se genera contenido HTML (equivale a `html! {}`). /// No se genera contenido HTML (devuelve `html! {}`).
#[default] #[default]
None, None,
/// Texto plano que se **escapará automáticamente** para que no sea interpretado como HTML. /// Texto estático que se escapará automáticamente para no ser interpretado como HTML.
/// Text(String),
/// Úsalo con textos que provengan de usuarios u otras fuentes externas para garantizar la /// Contenido sin escapado adicional, útil para HTML generado externamente.
/// seguridad contra inyección de código.
Escaped(String), 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. /// Fragmento HTML ya preparado como [`Markup`], listo para insertarse directamente.
///
/// Normalmente proviene de expresiones `html! { ... }`.
With(Markup), With(Markup),
} }
@ -114,18 +76,20 @@ impl PrepareMarkup {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
match self { match self {
PrepareMarkup::None => true, PrepareMarkup::None => true,
PrepareMarkup::Escaped(text) => text.is_empty(), PrepareMarkup::Text(text) => text.is_empty(),
PrepareMarkup::Raw(string) => string.is_empty(), PrepareMarkup::Escaped(string) => string.is_empty(),
PrepareMarkup::With(markup) => markup.is_empty(), PrepareMarkup::With(markup) => markup.is_empty(),
} }
} }
}
impl Render for PrepareMarkup {
/// Integra el renderizado fácilmente en la macro [`html!`]. /// Integra el renderizado fácilmente en la macro [`html!`].
pub fn render(&self) -> Markup { fn render(&self) -> Markup {
match self { match self {
PrepareMarkup::None => html! {}, PrepareMarkup::None => html! {},
PrepareMarkup::Escaped(text) => html! { (text) }, PrepareMarkup::Text(text) => html! { (text) },
PrepareMarkup::Raw(string) => html! { (PreEscaped(string)) }, PrepareMarkup::Escaped(string) => html! { (PreEscaped(string)) },
PrepareMarkup::With(markup) => html! { (markup) }, PrepareMarkup::With(markup) => html! { (markup) },
} }
} }

View file

@ -2,55 +2,25 @@ pub mod favicon;
pub mod javascript; pub mod javascript;
pub mod stylesheet; pub mod stylesheet;
use crate::html::{html, Context, Markup}; use crate::html::{html, Markup, Render};
use crate::{AutoDefault, Weight}; use crate::{AutoDefault, Weight};
/// Representación genérica de un script [`JavaScript`](crate::html::JavaScript) o una hoja de pub trait AssetsTrait: Render {
/// estilos [`StyleSheet`](crate::html::StyleSheet). // Devuelve el nombre del recurso, utilizado como clave única.
///
/// 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; fn name(&self) -> &str;
/// Devuelve el peso del recurso, usado para ordenar el renderizado de menor a mayor peso. // Devuelve el peso del recurso, durante el renderizado se procesan de menor a mayor peso.
fn weight(&self) -> Weight; 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)] #[derive(AutoDefault)]
pub struct Assets<T>(Vec<T>); pub(crate) struct Assets<T>(Vec<T>);
impl<T: Asset> Assets<T> { impl<T: AssetsTrait> Assets<T> {
/// 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 { pub fn new() -> Self {
Self(Vec::new()) Assets::<T>(Vec::<T>::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 { pub fn add(&mut self, asset: T) -> bool {
match self.0.iter().position(|x| x.name() == asset.name()) { match self.0.iter().position(|x| x.name() == asset.name()) {
Some(index) => { Some(index) => {
@ -69,9 +39,6 @@ impl<T: Asset> Assets<T> {
} }
} }
/// Elimina un recurso por nombre.
///
/// Devuelve `true` si el recurso existía y fue eliminado.
pub fn remove(&mut self, name: impl AsRef<str>) -> bool { pub fn remove(&mut self, name: impl AsRef<str>) -> bool {
if let Some(index) = self.0.iter().position(|x| x.name() == name.as_ref()) { if let Some(index) = self.0.iter().position(|x| x.name() == name.as_ref()) {
self.0.remove(index); self.0.remove(index);
@ -80,13 +47,16 @@ impl<T: Asset> Assets<T> {
false false
} }
} }
}
pub fn render(&self, cx: &mut Context) -> Markup { impl<T: AssetsTrait> Render for Assets<T> {
fn render(&self) -> Markup {
let mut assets = self.0.iter().collect::<Vec<_>>(); let mut assets = self.0.iter().collect::<Vec<_>>();
assets.sort_by_key(|a| a.weight()); assets.sort_by_key(|a| a.weight());
html! { html! {
@for a in assets { @for a in assets {
(a.render(cx)) (a.render())
} }
} }
} }

View file

@ -1,4 +1,4 @@
use crate::html::{html, Context, Markup}; use crate::html::{html, Markup, Render};
use crate::AutoDefault; use crate::AutoDefault;
/// Un **Favicon** es un recurso gráfico que usa el navegador como icono asociado al sitio. /// 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<String>, icon_color: Option<String>,
) -> Self { ) -> Self {
let icon_type = match icon_source.rfind('.') { let icon_type = match icon_source.rfind('.') {
Some(i) => match icon_source[i..].to_string().to_lowercase().as_str() { Some(i) => match icon_source[i..].to_owned().to_lowercase().as_str() {
".avif" => Some("image/avif"), ".avif" => Some("image/avif"),
".gif" => Some("image/gif"), ".gif" => Some("image/gif"),
".ico" => Some("image/x-icon"), ".ico" => Some("image/x-icon"),
@ -151,12 +151,10 @@ impl Favicon {
}); });
self self
} }
}
/// Renderiza el **Favicon** completo con todas las etiquetas declaradas. impl Render for Favicon {
/// fn render(&self) -> Markup {
/// 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! { html! {
@for item in &self.0 { @for item in &self.0 {
(item) (item)

View file

@ -1,45 +1,35 @@
use crate::html::assets::Asset; use crate::html::assets::AssetsTrait;
use crate::html::{html, Context, Markup, PreEscaped}; use crate::html::{html, Markup, Render};
use crate::{join, join_pair, AutoDefault, Weight}; use crate::{join, join_pair, AutoDefault, Weight};
// Define el origen del recurso JavaScript y cómo debe cargarse en el navegador. // 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 // Los distintos modos de carga permiten optimizar el rendimiento y controlar el comportamiento del
// script en relación con el análisis del documento HTML y la ejecución del resto de scripts. // script.
// //
// - [`From`] Carga estándar con la etiqueta `<script src="...">`. // - [`From`] Carga el script de forma estándar con la etiqueta `<script src="...">`.
// - [`Defer`] Igual que [`From`], pero con el atributo `defer`, descarga en paralelo y se // - [`Defer`] Igual que [`From`], pero con el atributo `defer`.
// ejecuta tras el análisis del documento HTML, respetando el orden de // - [`Async`] Igual que [`From`], pero con el atributo `async`.
// aparición.
// - [`Async`] Igual que [`From`], pero con el atributo `async`, descarga en paralelo y se
// ejecuta en cuanto esté listo, **sin garantizar** el orden relativo respecto a
// otros scripts.
// - [`Inline`] Inserta el código directamente en la etiqueta `<script>`. // - [`Inline`] Inserta el código directamente en la etiqueta `<script>`.
// - [`OnLoad`] Inserta el código JavaScript y lo ejecuta tras el evento `DOMContentLoaded`. // - [`OnLoad`] Inserta el código JavaScript y lo ejecuta tras el evento `DOMContentLoaded`.
// - [`OnLoadAsync`] Igual que [`OnLoad`], pero con manejador asíncrono (`async`), útil si dentro
// del código JavaScript se utiliza `await`.
#[derive(AutoDefault)] #[derive(AutoDefault)]
enum Source { enum Source {
#[default] #[default]
From(String), From(String),
Defer(String), Defer(String),
Async(String), Async(String),
// `name`, `closure(Context) -> String`. Inline(String, String),
Inline(String, Box<dyn Fn(&mut Context) -> String + Send + Sync>), OnLoad(String, String),
// `name`, `closure(Context) -> String` (se ejecuta tras `DOMContentLoaded`).
OnLoad(String, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
// `name`, `closure(Context) -> String` (manejador `async` tras `DOMContentLoaded`).
OnLoadAsync(String, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
} }
/// Define un recurso **JavaScript** para incluir en un documento HTML. /// Define un recurso **JavaScript** para incluir en un documento HTML.
/// ///
/// Este tipo permite añadir scripts externos o embebidos con distintas estrategias de carga /// Este tipo permite añadir *scripts* externos o embebidos con distintas estrategias de carga
/// (`defer`, `async`, *inline*, etc.) y [pesos](crate::Weight) para controlar el orden de inserción /// (`defer`, `async`, *inline*, etc.) y [pesos](crate::Weight) para controlar el orden de inserción
/// en el documento. /// en el documento.
/// ///
/// > **Nota** /// > **Nota**
/// > Los archivos de los scripts deben estar disponibles en el servidor web de la aplicación. /// > Los archivos de los *scripts* deben estar disponibles en el servidor web de la aplicación.
/// > Pueden servirse usando [`static_files_service!`](crate::static_files_service). /// > Pueden servirse usando [`static_files_service!`](crate::static_files_service).
/// ///
/// # Ejemplo /// # Ejemplo
@ -47,37 +37,23 @@ enum Source {
/// ```rust /// ```rust
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// ///
/// // Script externo con carga diferida, versión de caché y prioridad en el renderizado. /// // Script externo con carga diferida, versión para control de caché y prioriza el renderizado.
/// let script = JavaScript::defer("/assets/js/app.js") /// let script = JavaScript::defer("/assets/js/app.js")
/// .with_version("1.2.3") /// .with_version("1.2.3")
/// .with_weight(-10); /// .with_weight(-10);
/// ///
/// // Script embebido que se ejecuta tras la carga del documento. /// // Script embebido que se ejecuta tras la carga del documento.
/// let script = JavaScript::on_load("init_tooltips", |_| r#" /// let script = JavaScript::on_load("init_tooltips", r#"
/// const tooltips = document.querySelectorAll('[data-tooltip]'); /// const tooltips = document.querySelectorAll('[data-tooltip]');
/// for (const el of tooltips) { /// for (const el of tooltips) {
/// el.addEventListener('mouseenter', showTooltip); /// el.addEventListener('mouseenter', showTooltip);
/// } /// }
/// "#.to_string()); /// "#);
///
/// // Script embebido con manejador asíncrono (`async`) que puede usar `await`.
/// let mut cx = Context::new(None).with_param("user_id", 7u32);
///
/// let js = JavaScript::on_load_async("hydrate", |cx| {
/// // Ejemplo: lectura de un parámetro del contexto para inyectarlo en el código.
/// let uid: u32 = cx.param_or_default("user_id");
/// format!(r#"
/// const USER_ID = {};
/// await Promise.resolve(USER_ID);
/// // Aquí se podría hidratar la interfaz o cargar módulos dinámicos:
/// // await import('/assets/js/hydrate.js');
/// "#, uid)
/// });
/// ``` /// ```
#[rustfmt::skip] #[rustfmt::skip]
#[derive(AutoDefault)] #[derive(AutoDefault)]
pub struct JavaScript { pub struct JavaScript {
source : Source, // Fuente y estrategia de carga del script. source : Source, // Fuente y modo de carga del script.
version: String, // Versión del recurso para la caché del navegador. version: String, // Versión del recurso para la caché del navegador.
weight : Weight, // Peso que determina el orden. weight : Weight, // Peso que determina el orden.
} }
@ -94,11 +70,11 @@ impl JavaScript {
} }
} }
/// Crea un **script externo** con el atributo `defer`, que se descarga en paralelo y se ejecuta /// Crea un **script externo** con el atributo `defer`, que se carga en segundo plano y se
/// tras analizar completamente el documento HTML, **respetando el orden** de inserción. /// ejecuta tras analizar completamente el documento HTML.
/// ///
/// Equivale a `<script src="..." defer>`. Suele ser la opción recomendada para scripts no /// Equivale a `<script src="..." defer>`. Útil para mantener el orden de ejecución y evitar
/// críticos. /// bloquear el análisis del documento HTML.
pub fn defer(path: impl Into<String>) -> Self { pub fn defer(path: impl Into<String>) -> Self {
JavaScript { JavaScript {
source: Source::Defer(path.into()), source: Source::Defer(path.into()),
@ -106,10 +82,11 @@ impl JavaScript {
} }
} }
/// Crea un **script externo** con el atributo `async`, que se descarga en paralelo y se ejecuta /// Crea un **script externo** con el atributo `async`, que se carga y ejecuta de forma
/// tan pronto como esté disponible. /// asíncrona tan pronto como esté disponible.
/// ///
/// Equivale a `<script src="..." async>`. **No garantiza** el orden relativo con otros scripts. /// Equivale a `<script src="..." async>`. La ejecución puede producirse fuera de orden respecto
/// a otros *scripts*.
pub fn asynchronous(path: impl Into<String>) -> Self { pub fn asynchronous(path: impl Into<String>) -> Self {
JavaScript { JavaScript {
source: Source::Async(path.into()), source: Source::Async(path.into()),
@ -120,68 +97,37 @@ impl JavaScript {
/// Crea un **script embebido** directamente en el documento HTML. /// Crea un **script embebido** directamente en el documento HTML.
/// ///
/// Equivale a `<script>...</script>`. El parámetro `name` se usa como identificador interno del /// Equivale a `<script>...</script>`. El parámetro `name` se usa como identificador interno del
/// script. /// *script*.
/// pub fn inline(name: impl Into<String>, script: impl Into<String>) -> Self {
/// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
pub fn inline<F>(name: impl Into<String>, f: F) -> Self
where
F: Fn(&mut Context) -> String + Send + Sync + 'static,
{
JavaScript { JavaScript {
source: Source::Inline(name.into(), Box::new(f)), source: Source::Inline(name.into(), script.into()),
..Default::default() ..Default::default()
} }
} }
/// Crea un **script embebido** que se ejecuta cuando **el DOM está listo**. /// Crea un **script embebido** que se ejecuta automáticamente al terminar de cargarse el
/// documento HTML.
/// ///
/// El código se envuelve en un `addEventListener('DOMContentLoaded',function(){...})` que lo /// El código se envuelve automáticamente en un `addEventListener('DOMContentLoaded', ...)`. El
/// ejecuta tras analizar el documento HTML, **no** espera imágenes ni otros recursos externos. /// parámetro `name` se usa como identificador interno del *script*.
/// Útil para inicializaciones que no dependen de `await`. El parámetro `name` se usa como pub fn on_load(name: impl Into<String>, script: impl Into<String>) -> Self {
/// 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<F>(name: impl Into<String>, f: F) -> Self
where
F: Fn(&mut Context) -> String + Send + Sync + 'static,
{
JavaScript { JavaScript {
source: Source::OnLoad(name.into(), Box::new(f)), source: Source::OnLoad(name.into(), script.into()),
..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<F>(name: impl Into<String>, f: F) -> Self
where
F: Fn(&mut Context) -> String + Send + Sync + 'static,
{
JavaScript {
source: Source::OnLoadAsync(name.into(), Box::new(f)),
..Default::default() ..Default::default()
} }
} }
// JavaScript BUILDER ************************************************************************** // 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<String>) -> Self { pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = version.into(); self.version = version.into();
self 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 /// Los recursos se renderizan de menor a mayor peso. Por defecto es `0`, que respeta el orden
/// de creación. /// de creación.
@ -191,10 +137,8 @@ impl JavaScript {
} }
} }
impl Asset for JavaScript { impl AssetsTrait for JavaScript {
/// Devuelve el nombre del recurso, utilizado como clave única. // Para *scripts* externos es la ruta; para *scripts* embebidos, un identificador.
///
/// Para scripts externos es la ruta del recurso; para scripts embebidos, un identificador.
fn name(&self) -> &str { fn name(&self) -> &str {
match &self.source { match &self.source {
Source::From(path) => path, Source::From(path) => path,
@ -202,15 +146,16 @@ impl Asset for JavaScript {
Source::Async(path) => path, Source::Async(path) => path,
Source::Inline(name, _) => name, Source::Inline(name, _) => name,
Source::OnLoad(name, _) => name, Source::OnLoad(name, _) => name,
Source::OnLoadAsync(name, _) => name,
} }
} }
fn weight(&self) -> Weight { fn weight(&self) -> Weight {
self.weight self.weight
} }
}
fn render(&self, cx: &mut Context) -> Markup { impl Render for JavaScript {
fn render(&self) -> Markup {
match &self.source { match &self.source {
Source::From(path) => html! { Source::From(path) => html! {
script src=(join_pair!(path, "?v=", self.version.as_str())) {}; script src=(join_pair!(path, "?v=", self.version.as_str())) {};
@ -221,15 +166,12 @@ impl Asset for JavaScript {
Source::Async(path) => html! { Source::Async(path) => html! {
script src=(join_pair!(path, "?v=", self.version.as_str())) async {}; script src=(join_pair!(path, "?v=", self.version.as_str())) async {};
}, },
Source::Inline(_, f) => html! { Source::Inline(_, code) => html! {
script { (PreEscaped((f)(cx))) }; script { (code) };
}, },
Source::OnLoad(_, f) => html! { script { (PreEscaped(join!( Source::OnLoad(_, code) => html! { (join!(
"document.addEventListener(\"DOMContentLoaded\",function(){", (f)(cx), "});" "document.addEventListener('DOMContentLoaded',function(){", code, "});"
))) } }, )) },
Source::OnLoadAsync(_, f) => html! { script { (PreEscaped(join!(
"document.addEventListener(\"DOMContentLoaded\",async()=>{", (f)(cx), "});"
))) } },
} }
} }
} }

View file

@ -1,5 +1,5 @@
use crate::html::assets::Asset; use crate::html::assets::AssetsTrait;
use crate::html::{html, Context, Markup, PreEscaped}; use crate::html::{html, Markup, PreEscaped, Render};
use crate::{join_pair, AutoDefault, Weight}; use crate::{join_pair, AutoDefault, Weight};
// Define el origen del recurso CSS y cómo se incluye en el documento. // Define el origen del recurso CSS y cómo se incluye en el documento.
@ -14,8 +14,7 @@ use crate::{join_pair, AutoDefault, Weight};
enum Source { enum Source {
#[default] #[default]
From(String), From(String),
// `name`, `closure(Context) -> String`. Inline(String, String),
Inline(String, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
} }
/// Define el medio objetivo para la hoja de estilos. /// Define el medio objetivo para la hoja de estilos.
@ -35,7 +34,7 @@ pub enum TargetMedia {
Speech, Speech,
} }
/// Devuelve el valor para el atributo `media` (`Some(...)`) o `None` para `Default`. /// Devuelve el texto asociado al punto de interrupción usado por Bootstrap.
#[rustfmt::skip] #[rustfmt::skip]
impl TargetMedia { impl TargetMedia {
fn as_str_opt(&self) -> Option<&str> { fn as_str_opt(&self) -> Option<&str> {
@ -70,12 +69,12 @@ impl TargetMedia {
/// .with_weight(-10); /// .with_weight(-10);
/// ///
/// // Crea una hoja de estilos embebida en el documento HTML. /// // 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 { /// body {
/// background-color: #f5f5f5; /// background-color: #f5f5f5;
/// font-family: 'Segoe UI', sans-serif; /// font-family: 'Segoe UI', sans-serif;
/// } /// }
/// "#.to_string()); /// "#);
/// ``` /// ```
#[rustfmt::skip] #[rustfmt::skip]
#[derive(AutoDefault)] #[derive(AutoDefault)]
@ -101,14 +100,9 @@ impl StyleSheet {
/// ///
/// Equivale a `<style>...</style>`. El parámetro `name` se usa como identificador interno del /// Equivale a `<style>...</style>`. El parámetro `name` se usa como identificador interno del
/// recurso. /// recurso.
/// pub fn inline(name: impl Into<String>, styles: impl Into<String>) -> Self {
/// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
pub fn inline<F>(name: impl Into<String>, f: F) -> Self
where
F: Fn(&mut Context) -> String + Send + Sync + 'static,
{
StyleSheet { StyleSheet {
source: Source::Inline(name.into(), Box::new(f)), source: Source::Inline(name.into(), styles.into()),
..Default::default() ..Default::default()
} }
} }
@ -139,19 +133,17 @@ impl StyleSheet {
/// Según el argumento `media`: /// Según el argumento `media`:
/// ///
/// - `TargetMedia::Default` - Se aplica en todos los casos (medio por defecto). /// - `TargetMedia::Default` - Se aplica en todos los casos (medio por defecto).
/// - `TargetMedia::Print` - Se aplica cuando el documento se imprime. /// - `TargetMedia::Print` - Se aplican cuando el documento se imprime.
/// - `TargetMedia::Screen` - Se aplica en pantallas. /// - `TargetMedia::Screen` - Se aplican en pantallas.
/// - `TargetMedia::Speech` - Se aplica en dispositivos que convierten el texto a voz. /// - `TargetMedia::Speech` - Se aplican en dispositivos que convierten el texto a voz.
pub fn for_media(mut self, media: TargetMedia) -> Self { pub fn for_media(mut self, media: TargetMedia) -> Self {
self.media = media; self.media = media;
self self
} }
} }
impl Asset for StyleSheet { impl AssetsTrait for StyleSheet {
/// Devuelve el nombre del recurso, utilizado como clave única. // Para hojas de estilos externas es la ruta; para las embebidas, un identificador.
///
/// Para hojas de estilos externas es la ruta del recurso; para las embebidas, un identificador.
fn name(&self) -> &str { fn name(&self) -> &str {
match &self.source { match &self.source {
Source::From(path) => path, Source::From(path) => path,
@ -162,8 +154,10 @@ impl Asset for StyleSheet {
fn weight(&self) -> Weight { fn weight(&self) -> Weight {
self.weight self.weight
} }
}
fn render(&self, cx: &mut Context) -> Markup { impl Render for StyleSheet {
fn render(&self) -> Markup {
match &self.source { match &self.source {
Source::From(path) => html! { Source::From(path) => html! {
link link
@ -171,8 +165,8 @@ impl Asset for StyleSheet {
href=(join_pair!(path, "?v=", self.version.as_str())) href=(join_pair!(path, "?v=", self.version.as_str()))
media=[self.media.as_str_opt()]; media=[self.media.as_str_opt()];
}, },
Source::Inline(_, f) => html! { Source::Inline(_, code) => html! {
style { (PreEscaped((f)(cx))) }; style { (PreEscaped(code)) };
}, },
} }
} }

View file

@ -1,63 +0,0 @@
use crate::{builder_fn, AutoDefault};
/// Identificador normalizado para el atributo `id` o similar de HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl AttrId {
/// Crea un nuevo `AttrId` normalizando el valor.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
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<String> {
self.0
}
/// `true` si no hay valor.
pub fn is_empty(&self) -> bool {
self.0.is_none()
}
}

View file

@ -1,68 +0,0 @@
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<String> {
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)
}
}

View file

@ -1,63 +0,0 @@
use crate::{builder_fn, AutoDefault};
/// Nombre normalizado para el atributo `name` o similar de HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl AttrName {
/// Crea un nuevo `AttrName` normalizando el valor.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
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<String> {
self.0
}
/// `true` si no hay valor.
pub fn is_empty(&self) -> bool {
self.0.is_none()
}
}

View file

@ -1,65 +0,0 @@
use crate::{builder_fn, AutoDefault};
/// Cadena normalizada para renderizar en atributos HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl AttrValue {
/// Crea un nuevo `AttrValue` normalizando el valor.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
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<String> {
self.0
}
/// `true` si no hay valor.
pub fn is_empty(&self) -> bool {
self.0.is_none()
}
}

View file

@ -7,10 +7,13 @@ use crate::locale::{LangId, LangMatch, LanguageIdentifier, DEFAULT_LANGID, FALLB
use crate::service::HttpRequest; use crate::service::HttpRequest;
use crate::{builder_fn, join}; use crate::{builder_fn, join};
use std::any::Any;
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error;
use std::str::FromStr;
/// Operaciones para modificar el contexto ([`Context`]) de un documento. use std::fmt;
/// Operaciones para modificar el contexto ([`Context`]) del documento.
pub enum AssetsOp { pub enum AssetsOp {
// Favicon. // Favicon.
/// Define el *favicon* del documento. Sobrescribe cualquier valor anterior. /// Define el *favicon* del documento. Sobrescribe cualquier valor anterior.
@ -25,138 +28,38 @@ pub enum AssetsOp {
RemoveStyleSheet(&'static str), RemoveStyleSheet(&'static str),
// JavaScripts. // JavaScripts.
/// Añade un script JavaScript al documento. /// Añade un *script* JavaScript al documento.
AddJavaScript(JavaScript), AddJavaScript(JavaScript),
/// Elimina un script por su ruta o identificador. /// Elimina un *script* por su ruta o identificador.
RemoveJavaScript(&'static str), RemoveJavaScript(&'static str),
} }
/// Errores de acceso a parámetros dinámicos del contexto. /// Errores de lectura o conversión de parámetros almacenados en el 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)] #[derive(Debug)]
pub enum ErrorParam { pub enum ErrorParam {
/// El parámetro solicitado no existe.
NotFound, NotFound,
TypeMismatch { /// El valor del parámetro no pudo convertirse al tipo requerido.
key: &'static str, ParseError(String),
expected: &'static str,
saved: &'static str,
},
} }
/// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. impl fmt::Display for ErrorParam {
/// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
/// `Contextual` extiende [`LangId`] y define los métodos para: match self {
/// ErrorParam::NotFound => write!(f, "Parameter not found"),
/// - Establecer el **idioma** del documento. ErrorParam::ParseError(e) => write!(f, "Parse error: {e}"),
/// - 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<C: Contextual>(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<HttpRequest>) -> 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<T: 'static>(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<T: 'static>(&self, key: &'static str) -> Option<&T>;
/// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`).
fn param_or_default<T: Default + Clone + 'static>(&self, key: &'static str) -> T {
self.param::<T>(key).cloned().unwrap_or_default()
} }
/// Devuelve el parámetro clonado o un **valor por defecto** si no existe.
fn param_or<T: Clone + 'static>(&self, key: &'static str, default: T) -> T {
self.param::<T>(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: Clone + 'static, F: FnOnce() -> T>(&self, key: &'static str, f: F) -> T {
self.param::<T>(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<StyleSheet>;
/// Devuelve los scripts JavaScript de los recursos del contexto.
fn javascripts(&self) -> &Assets<JavaScript>;
// Contextual HELPERS **************************************************************************
/// Genera un identificador único por tipo (`<tipo>-<n>`) 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<T>(&mut self, id: Option<String>) -> String;
} }
/// Implementa un **contexto de renderizado** para un documento HTML. impl Error for ErrorParam {}
/// Representa el contexto de un documento HTML.
/// ///
/// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto, /// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de
/// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo /// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]),
/// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos /// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como parámetros de contexto
/// tipados** con nuevos métodos. /// definidos en tiempo de ejecución.
/// ///
/// # Ejemplos /// # Ejemplos
/// ///
@ -193,7 +96,7 @@ pub trait Contextual: LangId {
/// assert_eq!(active_theme.short_name(), "aliner"); /// assert_eq!(active_theme.short_name(), "aliner");
/// ///
/// // Recupera el parámetro a su tipo original. /// // Recupera el parámetro a su tipo original.
/// let id: i32 = *cx.get_param::<i32>("usuario_id").unwrap(); /// let id: i32 = cx.get_param("usuario_id").unwrap();
/// assert_eq!(id, 42); /// assert_eq!(id, 42);
/// ///
/// // Genera un identificador para un componente de tipo `Menu`. /// // Genera un identificador para un componente de tipo `Menu`.
@ -211,16 +114,10 @@ pub struct Context {
favicon : Option<Favicon>, // Favicon, si se ha definido. favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS. stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts: Assets<JavaScript>, // Scripts JavaScript. javascripts: Assets<JavaScript>, // Scripts JavaScript.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución. params : HashMap<&'static str, String>, // Parámetros definidos en tiempo de ejecución.
id_counter : usize, // Contador para generar identificadores únicos. id_counter : usize, // Contador para generar identificadores únicos.
} }
impl Default for Context {
fn default() -> Self {
Context::new(None)
}
}
impl Context { impl Context {
/// Crea un nuevo contexto asociado a una solicitud HTTP. /// Crea un nuevo contexto asociado a una solicitud HTTP.
/// ///
@ -250,199 +147,40 @@ impl Context {
favicon : None, favicon : None,
stylesheets: Assets::<StyleSheet>::new(), stylesheets: Assets::<StyleSheet>::new(),
javascripts: Assets::<JavaScript>::new(), javascripts: Assets::<JavaScript>::new(),
params : HashMap::default(), params : HashMap::<&str, String>::new(),
id_counter : 0, id_counter : 0,
} }
} }
// Context RENDER ****************************************************************************** // Context BUILDER *****************************************************************************
/// 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<StyleSheet>::default() en self.
let javascripts = mem_take(&mut self.javascripts); // Assets<JavaScript>::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::<String>("usuario_id").is_err());
/// ```
pub fn get_param<T: 'static>(&self, key: &'static str) -> Result<&T, ErrorParam> {
let (any, type_name) = self.params.get(key).ok_or(ErrorParam::NotFound)?;
any.downcast_ref::<T>()
.ok_or_else(|| ErrorParam::TypeMismatch {
key,
expected: TypeInfo::FullName.of::<T>(),
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::<i32>("contador").is_err()); // ya no está
///
/// // Error de tipo:
/// assert!(cx.take_param::<i32>("titulo").is_err());
/// ```
pub fn take_param<T: 'static>(&mut self, key: &'static str) -> Result<T, ErrorParam> {
let (boxed, saved) = self.params.remove(key).ok_or(ErrorParam::NotFound)?;
boxed
.downcast::<T>()
.map(|b| *b)
.map_err(|_| ErrorParam::TypeMismatch {
key,
expected: TypeInfo::FullName.of::<T>(),
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] #[builder_fn]
fn with_request(mut self, request: Option<HttpRequest>) -> Self { pub fn with_langid(mut self, language: &impl LangId) -> Self {
self.request = request;
self
}
#[builder_fn]
fn with_langid(mut self, language: &impl LangId) -> Self {
self.langid = language.langid(); self.langid = language.langid();
self self
} }
/// Asigna el tema para renderizar el documento. /// Modifica el tema que se usará para renderizar el documento.
/// ///
/// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica /// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica
/// ninguno entonces usará el tema por defecto. /// ninguno entonces usará el tema por defecto.
#[builder_fn] #[builder_fn]
fn with_theme(mut self, theme_name: &'static str) -> Self { pub fn with_theme(mut self, theme_name: &'static str) -> Self {
self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME); self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME);
self self
} }
/// Modifica la composición para renderizar el documento.
#[builder_fn] #[builder_fn]
fn with_layout(mut self, layout_name: &'static str) -> Self { pub fn with_layout(mut self, layout_name: &'static str) -> Self {
self.layout = layout_name; self.layout = layout_name;
self self
} }
/// Añade o modifica un parámetro dinámico del contexto. /// Define los recursos del contexto usando [`AssetsOp`].
///
/// 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] #[builder_fn]
fn with_param<T: 'static>(mut self, key: &'static str, value: T) -> Self { pub fn with_assets(mut self, op: AssetsOp) -> Self {
let type_name = TypeInfo::FullName.of::<T>();
self.params.insert(key, (Box::new(value), type_name));
self
}
#[builder_fn]
fn with_assets(mut self, op: AssetsOp) -> Self {
match op { match op {
// Favicon. // Favicon.
AssetsOp::SetFavicon(favicon) => { AssetsOp::SetFavicon(favicon) => {
@ -471,74 +209,69 @@ impl Contextual for Context {
self self
} }
// Contextual GETTERS ************************************************************************** // Context GETTERS *****************************************************************************
fn request(&self) -> Option<&HttpRequest> { /// Devuelve una referencia a la solicitud HTTP asociada, si existe.
pub fn request(&self) -> Option<&HttpRequest> {
self.request.as_ref() self.request.as_ref()
} }
fn theme(&self) -> ThemeRef { /// Devuelve el tema que se usará para renderizar el documento.
pub fn theme(&self) -> ThemeRef {
self.theme self.theme
} }
fn layout(&self) -> &str { /// Devuelve la composición para renderizar el documento. Por defecto es `"default"`.
pub fn layout(&self) -> &str {
self.layout self.layout
} }
/// Recupera un parámetro como [`Option`], simplificando el acceso. // Context RENDER ******************************************************************************
///
/// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información /// Renderiza los recursos del contexto.
/// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor pub fn render_assets(&self) -> Markup {
/// guardado no coincide con el tipo solicitado. html! {
/// @if let Some(favicon) = &self.favicon {
/// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo (favicon)
/// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo. }
/// (self.stylesheets)
/// # Ejemplo (self.javascripts)
/// }
/// ```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::<String>("username").map(|s| s.as_str()), Some("Alice"));
///
/// // Devuelve None si no existe o si el tipo no coincide.
/// assert!(cx.param::<i32>("username").is_none());
/// assert!(cx.param::<String>("missing").is_none());
///
/// // Acceso con valor por defecto.
/// let user = cx.param::<String>("missing")
/// .cloned()
/// .unwrap_or_else(|| "visitor".to_string());
/// assert_eq!(user, "visitor");
/// ```
fn param<T: 'static>(&self, key: &'static str) -> Option<&T> {
self.get_param::<T>(key).ok()
} }
fn favicon(&self) -> Option<&Favicon> { // Context PARAMS ******************************************************************************
self.favicon.as_ref()
/// Añade o modifica un parámetro del contexto almacenando el valor como [`String`].
#[builder_fn]
pub fn with_param<T: ToString>(mut self, key: &'static str, value: T) -> Self {
self.params.insert(key, value.to_string());
self
} }
fn stylesheets(&self) -> &Assets<StyleSheet> { /// Recupera un parámetro del contexto convertido al tipo especificado.
&self.stylesheets ///
/// Devuelve un error si el parámetro no existe ([`ErrorParam::NotFound`]) o la conversión falla
/// ([`ErrorParam::ParseError`]).
pub fn get_param<T: FromStr>(&self, key: &'static str) -> Result<T, ErrorParam> {
self.params
.get(key)
.ok_or(ErrorParam::NotFound)
.and_then(|v| T::from_str(v).map_err(|_| ErrorParam::ParseError(v.clone())))
} }
fn javascripts(&self) -> &Assets<JavaScript> { /// Elimina un parámetro del contexto. Devuelve `true` si existía y se eliminó.
&self.javascripts pub fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
} }
// Contextual HELPERS ************************************************************************** // Context EXTRAS ******************************************************************************
/// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona /// Genera un identificador único si no se proporciona uno explícito.
/// un `id` explícito.
/// ///
/// Si no se proporciona un `id`, se genera un identificador único en la forma `<tipo>-<número>` /// Si no se proporciona un `id`, se genera un identificador único en la forma `<tipo>-<número>`
/// donde `<tipo>` es el nombre corto del tipo en minúsculas (sin espacios) y `<número>` es un /// donde `<tipo>` es el nombre corto del tipo en minúsculas (sin espacios) y `<número>` es un
/// contador interno incremental. /// contador interno incremental.
fn required_id<T>(&mut self, id: Option<String>) -> String { pub fn required_id<T>(&mut self, id: Option<String>) -> String {
if let Some(id) = id { if let Some(id) = id {
id id
} else { } else {
@ -548,7 +281,7 @@ impl Contextual for Context {
.replace(' ', "_") .replace(' ', "_")
.to_lowercase(); .to_lowercase();
let prefix = if prefix.is_empty() { let prefix = if prefix.is_empty() {
"prefix".to_string() "prefix".to_owned()
} else { } else {
prefix prefix
}; };
@ -557,3 +290,21 @@ impl Contextual for 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
}
}

View file

@ -69,6 +69,23 @@ impl fmt::Write for Escaper<'_> {
/// `.render()` or `.render_to()`. Since the default definitions of /// `.render()` or `.render_to()`. Since the default definitions of
/// these methods call each other, not doing this will result in /// these methods call each other, not doing this will result in
/// infinite recursion. /// 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 { pub trait Render {
/// Renders `self` as a block of `Markup`. /// Renders `self` as a block of `Markup`.
fn render(&self) -> Markup { fn render(&self) -> Markup {
@ -221,10 +238,6 @@ impl Markup {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.0.is_empty() self.0.is_empty()
} }
pub fn as_str(&self) -> &str {
self.0.as_str()
}
} }
impl<T: Into<String>> PreEscaped<T> { impl<T: Into<String>> PreEscaped<T> {

View file

@ -1,6 +1,6 @@
use crate::{builder_fn, AutoDefault}; use crate::{builder_fn, AutoDefault};
/// Operaciones disponibles sobre la lista de clases en [`AttrClasses`]. /// Operaciones disponibles sobre la lista de clases en [`OptionClasses`].
pub enum ClassesOp { pub enum ClassesOp {
/// Añade al final (si no existe). /// Añade al final (si no existe).
Add, Add,
@ -25,7 +25,6 @@ pub enum ClassesOp {
/// ///
/// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS. /// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS.
/// - No se permiten clases duplicadas. /// - No se permiten clases duplicadas.
/// - Las clases se convierten a minúsculas.
/// - Las clases vacías se ignoran. /// - Las clases vacías se ignoran.
/// ///
/// # Ejemplo /// # Ejemplo
@ -33,26 +32,26 @@ pub enum ClassesOp {
/// ```rust /// ```rust
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// ///
/// let classes = AttrClasses::new("Btn btn-primary") /// let classes = OptionClasses::new("btn btn-primary")
/// .with_value(ClassesOp::Add, "Active") /// .with_value(ClassesOp::Add, "active")
/// .with_value(ClassesOp::Remove, "btn-primary"); /// .with_value(ClassesOp::Remove, "btn-primary");
/// ///
/// assert_eq!(classes.get(), Some("btn active".to_string())); /// assert_eq!(classes.get(), Some(String::from("btn active")));
/// assert!(classes.contains("active")); /// assert!(classes.contains("active"));
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug)] #[derive(AutoDefault, Clone, Debug)]
pub struct AttrClasses(Vec<String>); pub struct OptionClasses(Vec<String>);
impl AttrClasses { impl OptionClasses {
pub fn new(classes: impl AsRef<str>) -> Self { pub fn new(classes: impl AsRef<str>) -> Self {
AttrClasses::default().with_value(ClassesOp::Prepend, classes) OptionClasses::default().with_value(ClassesOp::Prepend, classes)
} }
// AttrClasses BUILDER ************************************************************************* // OptionClasses BUILDER ***********************************************************************
#[builder_fn] #[builder_fn]
pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self { pub fn with_value(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
let classes = classes.as_ref().to_ascii_lowercase(); let classes: &str = classes.as_ref();
let classes: Vec<&str> = classes.split_ascii_whitespace().collect(); let classes: Vec<&str> = classes.split_ascii_whitespace().collect();
if classes.is_empty() { if classes.is_empty() {
@ -114,9 +113,9 @@ impl AttrClasses {
} }
} }
// AttrClasses GETTERS ************************************************************************* // OptionClasses GETTERS ***********************************************************************
/// Devuelve la cadena de clases, si existe. /// Devuele la cadena de clases, si existe.
pub fn get(&self) -> Option<String> { pub fn get(&self) -> Option<String> {
if self.0.is_empty() { if self.0.is_empty() {
None None

68
src/html/opt_component.rs Normal file
View file

@ -0,0 +1,68 @@
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<Typed<C>>` 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<C: Component>(Option<Typed<C>>);
impl<C: Component> Default for OptionComponent<C> {
fn default() -> Self {
OptionComponent(None)
}
}
impl<C: Component> OptionComponent<C> {
/// 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<C>) -> 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<Typed<C>> {
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! {}
}
}
}

58
src/html/opt_id.rs Normal file
View file

@ -0,0 +1,58 @@
use crate::{builder_fn, AutoDefault};
/// Identificador normalizado para el atributo `id` o similar de HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl OptionId {
/// Crea un nuevo [`OptionId`].
///
/// El valor se normaliza automáticamente.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
if let Some(value) = &self.0 {
if !value.is_empty() {
return Some(value.to_owned());
}
}
None
}
}

58
src/html/opt_name.rs Normal file
View file

@ -0,0 +1,58 @@
use crate::{builder_fn, AutoDefault};
/// Nombre normalizado para el atributo `name` o similar de HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl OptionName {
/// Crea un nuevo [`OptionName`].
///
/// El valor se normaliza automáticamente.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
if let Some(value) = &self.0 {
if !value.is_empty() {
return Some(value.to_owned());
}
}
None
}
}

57
src/html/opt_string.rs Normal file
View file

@ -0,0 +1,57 @@
use crate::{builder_fn, AutoDefault};
/// Cadena normalizada para renderizar en atributos HTML.
///
/// Este tipo encapsula `Option<String>` 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<String>);
impl OptionString {
/// Crea un nuevo [`OptionString`].
///
/// El valor se normaliza automáticamente.
pub fn new(value: impl AsRef<str>) -> 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<str>) -> 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<String> {
if let Some(value) = &self.0 {
if !value.is_empty() {
return Some(value.to_owned());
}
}
None
}
}

View file

@ -0,0 +1,65 @@
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<String> {
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)
}
}

View file

@ -15,8 +15,8 @@
<br> <br>
</div> </div>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la `PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript. 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 Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo: 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, * **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables. configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs * **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 * **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad. componentes sin comprometer su funcionalidad.
# Guía rápida # 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 ```rust,no_run
use pagetop::prelude::*; 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 Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. 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 ```rust,no_run
use pagetop::prelude::*; use pagetop::prelude::*;
@ -60,8 +60,8 @@ impl Extension for HelloWorld {
} }
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> { async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request) Page::new(Some(request))
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } })) .with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render() .render()
} }
@ -77,11 +77,11 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági
# 🧩 Gestión de Dependencias # 🧩 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. proyecto en Rust.
Sin embargo, es fundamental que cada extensión declare explícitamente sus 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. estructurar e inicializar la aplicación de forma modular.
*/ */
@ -138,7 +138,7 @@ pub type Weight = i8;
// API ********************************************************************************************* // API *********************************************************************************************
// Macros y funciones útiles. // Funciones y macros útiles.
pub mod util; pub mod util;
// Carga las opciones de configuración. // Carga las opciones de configuración.
pub mod config; pub mod config;

View file

@ -1,6 +1,6 @@
//! Localización (L10n). //! 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/) //! 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. //! para integrar los recursos de traducción directamente en el binario de la aplicación.
//! //!
@ -13,7 +13,7 @@
//! //!
//! # Recursos Fluent //! # 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) //! [Identificador de Idioma Unicode](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier)
//! válido. Podríamos tener una estructura como esta: //! válido. Podríamos tener una estructura como esta:
//! //!
@ -34,7 +34,7 @@
//! └── main.ftl //! └── 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 //! ```text
//! hello-world = Hello world! //! hello-world = Hello world!
@ -53,7 +53,7 @@
//! Y su archivo equivalente para español en `src/locale/es-ES/main.ftl`: //! Y su archivo equivalente para español en `src/locale/es-ES/main.ftl`:
//! //!
//! ```text //! ```text
//! hello-world = ¡Hola, mundo! //! hello-world = Hola mundo!
//! hello-user = ¡Hola, {$userName}! //! hello-user = ¡Hola, {$userName}!
//! shared-photos = //! shared-photos =
//! {$userName} {$photoCount -> //! {$userName} {$photoCount ->
@ -81,13 +81,13 @@
//! include_locales!(LOCALES_SAMPLE); //! 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 //! ```rust,ignore
//! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones"); //! 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`]. //! traducir textos con [`L10n`].
use crate::html::{Markup, PreEscaped}; use crate::html::{Markup, PreEscaped};
@ -129,7 +129,7 @@ pub(crate) static FALLBACK_LANGID: LazyLock<LanguageIdentifier> =
// Identificador de idioma **por defecto** para la aplicación. // Identificador de idioma **por defecto** para la aplicación.
// //
// Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador // Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador
// de idioma no es válido o no está disponible, se usa [`FALLBACK_LANGID`]. // de idioma no es válido o no está disponible entonces resuelve como [`FALLBACK_LANGID`].
pub(crate) static DEFAULT_LANGID: LazyLock<Option<&LanguageIdentifier>> = pub(crate) static DEFAULT_LANGID: LazyLock<Option<&LanguageIdentifier>> =
LazyLock::new(|| LangMatch::resolve(&global::SETTINGS.app.language).as_option()); LazyLock::new(|| LangMatch::resolve(&global::SETTINGS.app.language).as_option());
@ -141,10 +141,10 @@ pub trait LangId {
fn langid(&self) -> &'static LanguageIdentifier; fn langid(&self) -> &'static LanguageIdentifier;
} }
/// Operaciones con los idiomas soportados por PageTop. /// Operaciones con los idiomas soportados por `PageTop`.
/// ///
/// Utiliza [`LangMatch`] para transformar un identificador de idioma en un [`LanguageIdentifier`] /// Utiliza [`LangMatch`] para transformar un identificador de idioma en un [`LanguageIdentifier`]
/// soportado por PageTop. /// soportado por `PageTop`.
/// ///
/// # Ejemplos /// # Ejemplos
/// ///
@ -155,7 +155,7 @@ pub trait LangId {
/// let lang = LangMatch::resolve("es-ES"); /// let lang = LangMatch::resolve("es-ES");
/// assert_eq!(lang.langid().to_string(), "es-ES"); /// assert_eq!(lang.langid().to_string(), "es-ES");
/// ///
/// // Coincidencia parcial (retrocede al idioma base si no hay variante regional). /// // Coincidencia parcial (con el idioma base).
/// let lang = LangMatch::resolve("es-EC"); /// let lang = LangMatch::resolve("es-EC");
/// assert_eq!(lang.langid().to_string(), "es-ES"); // Porque "es-EC" no está soportado. /// assert_eq!(lang.langid().to_string(), "es-ES"); // Porque "es-EC" no está soportado.
/// ///
@ -165,7 +165,7 @@ pub trait LangId {
/// ///
/// // Idioma no soportado. /// // Idioma no soportado.
/// let lang = LangMatch::resolve("ja-JP"); /// let lang = LangMatch::resolve("ja-JP");
/// assert_eq!(lang, LangMatch::Unsupported("ja-JP".to_string())); /// assert_eq!(lang, LangMatch::Unsupported(String::from("ja-JP")));
/// ``` /// ```
/// ///
/// Con la siguiente instrucción siempre se obtiene un [`LanguageIdentifier`] válido, ya sea porque /// Con la siguiente instrucción siempre se obtiene un [`LanguageIdentifier`] válido, ya sea porque
@ -183,11 +183,11 @@ pub trait LangId {
pub enum LangMatch { pub enum LangMatch {
/// Cuando el identificador de idioma es una cadena vacía. /// Cuando el identificador de idioma es una cadena vacía.
Unspecified, Unspecified,
/// Si encuentra un [`LanguageIdentifier`] en la lista de idiomas soportados por PageTop que /// Si encuentra un [`LanguageIdentifier`] en la lista de idiomas soportados por `PageTop` que
/// coincide exactamente con el identificador de idioma (p.ej. "es-ES"), o con el identificador /// coincide exactamente con el identificador de idioma (p.ej. "es-ES"), o con el identificador
/// del idioma base (p.ej. "es"). /// del idioma base (p.ej. "es").
Found(&'static LanguageIdentifier), Found(&'static LanguageIdentifier),
/// Si el identificador de idioma no está entre los soportados por PageTop. /// Si el identificador de idioma no está entre los soportados por `PageTop`.
Unsupported(String), Unsupported(String),
} }
@ -221,8 +221,8 @@ impl LangMatch {
} }
} }
// En caso contrario, indica que el idioma no está soportado. // En otro caso indica que el idioma no está soportado.
Self::Unsupported(language.to_string()) Self::Unsupported(String::from(language))
} }
/// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido. /// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido.
@ -241,7 +241,7 @@ impl LangMatch {
/// let lang = LangMatch::resolve("es-ES").as_option(); /// let lang = LangMatch::resolve("es-ES").as_option();
/// assert_eq!(lang.unwrap().to_string(), "es-ES"); /// assert_eq!(lang.unwrap().to_string(), "es-ES");
/// ///
/// let lang = LangMatch::resolve("ja-JP").as_option(); /// let lang = LangMatch::resolve("jp-JP").as_option();
/// assert!(lang.is_none()); /// assert!(lang.is_none());
/// ``` /// ```
#[inline] #[inline]
@ -259,8 +259,8 @@ impl LangMatch {
/// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de /// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de
/// respaldo ("en-US"). /// respaldo ("en-US").
/// ///
/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::lookup()`] /// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::using()`]
/// o [`L10n::using()`]. /// o [`L10n::to_markup()`].
impl LangId for LangMatch { impl LangId for LangMatch {
fn langid(&self) -> &'static LanguageIdentifier { fn langid(&self) -> &'static LanguageIdentifier {
match self { match self {
@ -271,10 +271,10 @@ impl LangId for LangMatch {
} }
#[macro_export] #[macro_export]
/// Incluye un conjunto de recursos **Fluent** y textos de traducción propios. /// Define un conjunto de elementos de localización y textos de traducción local.
macro_rules! include_locales { macro_rules! include_locales {
// Se desactiva la inserción de marcas de aislamiento Unicode (FSI/PDI) en los argumentos para // Se eliminan las marcas de aislamiento Unicode en los argumentos para mejorar la legibilidad y
// mejorar la legibilidad y la compatibilidad en ciertos contextos de renderizado. // la compatibilidad en ciertos contextos de renderizado.
( $LOCALES:ident $(, $core_locales:literal)? ) => { ( $LOCALES:ident $(, $core_locales:literal)? ) => {
$crate::locale::fluent_templates::static_loader! { $crate::locale::fluent_templates::static_loader! {
static $LOCALES = { static $LOCALES = {
@ -310,8 +310,8 @@ include_locales!(LOCALES_PAGETOP);
enum L10nOp { enum L10nOp {
#[default] #[default]
None, None,
Text(Cow<'static, str>), Text(String),
Translate(Cow<'static, str>), Translate(String),
} }
/// Crea instancias para traducir textos localizados. /// Crea instancias para traducir textos localizados.
@ -319,12 +319,12 @@ enum L10nOp {
/// Cada instancia puede representar: /// Cada instancia puede representar:
/// ///
/// - Un texto puro (`n()`) que no requiere traducción. /// - Un texto puro (`n()`) que no requiere traducción.
/// - Una clave para traducir un texto de las traducciones predefinidas de PageTop (`l()`). /// - Una clave para traducir un texto de las traducciones predefinidas de `PageTop` (`l()`).
/// - Una clave para traducir de un conjunto concreto de traducciones (`t()`). /// - Una clave para traducir de un conjunto concreto de traducciones (`t()`).
/// ///
/// # Ejemplo /// # Ejemplo
/// ///
/// Los argumentos dinámicos se añaden con `with_arg()` o `with_args()`. /// Los argumentos dinámicos se añaden usando `with_arg()` o `with_args()`.
/// ///
/// ```rust /// ```rust
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
@ -338,11 +338,11 @@ enum L10nOp {
/// .get(); /// .get();
/// ``` /// ```
/// ///
/// También sirve para traducciones contra un conjunto de recursos concreto. /// También para traducciones a idiomas concretos.
/// ///
/// ```rust,ignore /// ```rust,ignore
/// // Traducción con clave, conjunto de traducciones y fuente de idioma. /// // Traducción con clave, conjunto de traducciones y fuente de idioma.
/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).lookup(&LangMatch::resolve("it")); /// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).using(&LangMatch::resolve("it"));
/// ``` /// ```
#[derive(AutoDefault, Clone)] #[derive(AutoDefault, Clone)]
pub struct L10n { pub struct L10n {
@ -354,7 +354,7 @@ pub struct L10n {
impl L10n { impl L10n {
/// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción. /// **n** = *“native”*. Crea una instancia con una cadena literal sin traducción.
pub fn n(text: impl Into<Cow<'static, str>>) -> Self { pub fn n(text: impl Into<String>) -> Self {
L10n { L10n {
op: L10nOp::Text(text.into()), op: L10nOp::Text(text.into()),
..Default::default() ..Default::default()
@ -363,7 +363,7 @@ impl L10n {
/// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de /// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de
/// traducciones predefinidas. /// traducciones predefinidas.
pub fn l(key: impl Into<Cow<'static, str>>) -> Self { pub fn l(key: impl Into<String>) -> Self {
L10n { L10n {
op: L10nOp::Translate(key.into()), op: L10nOp::Translate(key.into()),
..Default::default() ..Default::default()
@ -372,7 +372,7 @@ impl L10n {
/// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de /// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de
/// traducciones específico. /// traducciones específico.
pub fn t(key: impl Into<Cow<'static, str>>, locales: &'static Locales) -> Self { pub fn t(key: impl Into<String>, locales: &'static Locales) -> Self {
L10n { L10n {
op: L10nOp::Translate(key.into()), op: L10nOp::Translate(key.into()),
locales, locales,
@ -399,8 +399,7 @@ impl L10n {
self self
} }
/// Resuelve la traducción usando el idioma por defecto o, si no procede, el de respaldo de la /// Resuelve la traducción usando el idioma por defecto o de respaldo de la aplicación.
/// aplicación.
/// ///
/// Devuelve `None` si no aplica o no encuentra una traducción válida. /// Devuelve `None` si no aplica o no encuentra una traducción válida.
/// ///
@ -412,7 +411,7 @@ impl L10n {
/// let text = L10n::l("greeting").with_arg("name", "Manuel").get(); /// let text = L10n::l("greeting").with_arg("name", "Manuel").get();
/// ``` /// ```
pub fn get(&self) -> Option<String> { pub fn get(&self) -> Option<String> {
self.lookup(&LangMatch::default()) self.using(&LangMatch::default())
} }
/// Resuelve la traducción usando la fuente de idioma proporcionada. /// Resuelve la traducción usando la fuente de idioma proporcionada.
@ -433,27 +432,20 @@ impl L10n {
/// } /// }
/// ///
/// let r = ResourceLang; /// let r = ResourceLang;
/// let text = L10n::l("greeting").with_arg("name", "Usuario").lookup(&r); /// let text = L10n::l("greeting").with_arg("name", "Usuario").using(&r);
/// ``` /// ```
pub fn lookup(&self, language: &impl LangId) -> Option<String> { pub fn using(&self, language: &impl LangId) -> Option<String> {
match &self.op { match &self.op {
L10nOp::None => None, L10nOp::None => None,
L10nOp::Text(text) => Some(text.clone().into_owned()), L10nOp::Text(text) => Some(text.to_owned()),
L10nOp::Translate(key) => { L10nOp::Translate(key) => self.locales.try_lookup_with_args(
if self.args.is_empty() {
self.locales.try_lookup(language.langid(), key.as_ref())
} else {
self.locales.try_lookup_with_args(
language.langid(), language.langid(),
key.as_ref(), key,
&self &self.args.iter().fold(HashMap::new(), |mut arg, (k, v)| {
.args arg.insert(Cow::Owned(k.clone()), v.to_owned().into());
.iter() arg
.map(|(k, v)| (Cow::Owned(k.clone()), v.clone().into())) }),
.collect::<HashMap<_, _>>(), ),
)
}
}
} }
} }
@ -466,16 +458,10 @@ impl L10n {
/// ```rust /// ```rust
/// use pagetop::prelude::*; /// use pagetop::prelude::*;
/// ///
/// let html = L10n::l("welcome.message").using(&LangMatch::resolve("es")); /// let html = L10n::l("welcome.message").to_markup(&LangMatch::resolve("es"));
/// ``` /// ```
pub fn using(&self, language: &impl LangId) -> Markup {
PreEscaped(self.lookup(language).unwrap_or_default())
}
/// **Obsoleto desde la versión 0.4.0**: usar [`using()`](Self::using) en su lugar.
#[deprecated(since = "0.4.0", note = "Use `using()` instead")]
pub fn to_markup(&self, language: &impl LangId) -> Markup { pub fn to_markup(&self, language: &impl LangId) -> Markup {
self.using(language) PreEscaped(self.using(language).unwrap_or_default())
} }
} }

View file

@ -1,13 +0,0 @@
# Basic theme, intro layout.
intro_pagetop_label = PageTop version on Crates.io
intro_release_label = Release date
intro_license_label = License
intro_text1 = PageTop is <strong>an opinionated Rust web development framework</strong> designed to build modular, extensible, and configurable web solutions.
intro_text2 = PageTop brings back the essence of the classic web, renders on the server (SSR) and uses <em>HTML-first</em> components, CSS and JavaScript, <strong>with the performance and security of Rust</strong>.
intro_code = Code
intro_have_fun = Coding is creating
# PoweredBy component.
poweredby_pagetop = Powered by { $pagetop_link }

View file

@ -1,9 +1,2 @@
# Regions. content = Content
region_header = Header
region_content = Content
region_footer = Footer
error403_notice = FORBIDDEN ACCESS
error404_notice = RESOURCE NOT FOUND
pagetop_logo = PageTop Logo pagetop_logo = PageTop Logo

View file

@ -1,16 +1,21 @@
welcome_extension_name = Default Homepage welcome_extension_name = Default homepage
welcome_extension_description = Displays a default homepage when none is configured. welcome_extension_description = Displays a landing page when none is configured.
welcome_page = Welcome page welcome_page = Welcome Page
welcome_title = Hello, world! welcome_title = Hello world!
welcome_aria = Say hello to your { $app } installation
welcome_intro = Discover⚡{ $app } welcome_intro = Discover⚡{ $app }
welcome_powered = A web solution powered by <strong>PageTop</strong> welcome_powered = A web solution powered by <strong>PageTop!</strong>
welcome_status_title = Status welcome_text1 = If you can read this page, it means that the <strong>PageTop</strong> server is running correctly but has not yet been fully configured. This usually means the site is either experiencing temporary issues or is undergoing routine maintenance.
welcome_status_1 = If you can see this page, it means the <strong>PageTop</strong> server is running correctly, but the application is not fully configured. This may be due to routine maintenance or a temporary issue. welcome_text2 = If the issue persists, please <strong>contact your system administrator</strong> for assistance.
welcome_status_2 = If the issue persists, please <strong>contact the system administrator</strong>.
welcome_support_title = Support welcome_about = About
welcome_support_1 = To report issues with the <strong>PageTop</strong> framework, use <a href="https://git.cillero.es/manuelcillero/pagetop/issues" target="_blank" rel="noreferrer">SoloGit</a>. Remember, before opening a new issue, review the existing ones to avoid duplicates. welcome_pagetop = <strong>PageTop</strong> is a <a href="https://www.rust-lang.org" target="_blank">Rust</a>-based web development framework for building modular, extensible, and configurable web solutions.
welcome_support_2 = For issues specific to the application (<strong>{ $app }</strong>), please use its official repository or support channel.
welcome_issues1 = To report issues related to the <strong>PageTop</strong> framework, please use <a href="https://git.cillero.es/manuelcillero/pagetop/issues" target="_blank">SoloGit</a>. Before opening a new issue, check existing reports to avoid duplicates.
welcome_issues2 = For issues related specifically to <strong>{ $app }</strong>, please refer to its official repository or support channel, rather than directly to <strong>PageTop</strong>.
welcome_code = Code
welcome_have_fun = Coding is creating

View file

@ -1,13 +0,0 @@
# Basic theme, intro layout.
intro_pagetop_label = Versión de PageTop en Crates.io
intro_release_label = Lanzamiento
intro_license_label = Licencia
intro_text1 = PageTop es un <strong>entorno de desarrollo web basado en Rust</strong>, pensado para construir soluciones web modulares, extensibles y configurables.
intro_text2 = PageTop reivindica la esencia de la web clásica, renderiza en el servidor (SSR) utilizando componentes <em>HTML-first</em>, CSS y JavaScript, <strong>con el rendimiento y la seguridad de Rust</strong>.
intro_code = Código
intro_have_fun = Programar es crear
# PoweredBy component.
poweredby_pagetop = Funciona con { $pagetop_link }

View file

@ -1,9 +1,2 @@
# Regions. content = Contenido
region_header = Cabecera
region_content = Contenido
region_footer = Pie de página
error403_notice = ACCESO NO PERMITIDO
error404_notice = RECURSO NO ENCONTRADO
pagetop_logo = Logotipo de PageTop pagetop_logo = Logotipo de PageTop

View file

@ -1,16 +1,21 @@
welcome_extension_name = Página de inicio predeterminada welcome_extension_name = Página de inicio predeterminada
welcome_extension_description = Muestra una página de inicio predeterminada cuando no hay ninguna configurada. welcome_extension_description = Muestra una página de inicio predeterminada cuando no hay ninguna configurada.
welcome_page = Página de bienvenida welcome_page = Página de Bienvenida
welcome_title = ¡Hola, mundo! welcome_title = ¡Hola mundo!
welcome_aria = Saluda a tu instalación { $app }
welcome_intro = Descubre⚡{ $app } welcome_intro = Descubre⚡{ $app }
welcome_powered = Una solución web creada con <strong>PageTop</strong> welcome_powered = Una solución web creada con <strong>PageTop!</strong>
welcome_status_title = Estado welcome_text1 = Si puedes leer esta página, significa que el servidor de <strong>PageTop</strong> funciona correctamente, pero aún no ha sido completamente configurado. Esto suele indicar que el sitio está experimentando problemas temporales o está pasando por un mantenimiento de rutina.
welcome_status_1 = Si puedes ver esta página, es porque el servidor de <strong>PageTop</strong> está funcionando correctamente, pero la aplicación no está completamente configurada. Esto puede deberse a tareas de mantenimiento o a una incidencia temporal. welcome_text2 = Si el problema persiste, por favor <strong>contacta con el administrador del sistema</strong> para recibir asistencia técnica.
welcome_status_2 = Si el problema persiste, por favor, <strong>contacta con el administrador del sistema</strong>.
welcome_support_title = Soporte welcome_about = Acerca de
welcome_support_1 = Para comunicar incidencias del propio entorno <strong>PageTop</strong>, utiliza <a href="https://git.cillero.es/manuelcillero/pagetop/issues" target="_blank" rel="noreferrer">SoloGit</a>. Recuerda, antes de abrir una nueva incidencia, revisa las existentes para evitar duplicados. welcome_pagetop = <strong>PageTop</strong> es un entorno de desarrollo web basado en <a href="https://www.rust-lang.org/es" target="_blank">Rust</a>, diseñado para crear soluciones web modulares, extensibles y configurables.
welcome_support_2 = Para fallos específicos de la aplicación (<strong>{ $app }</strong>), utiliza su repositorio oficial o su canal de soporte.
welcome_issues1 = Para comunicar cualquier problema con <strong>PageTop</strong>, utiliza <a href="https://git.cillero.es/manuelcillero/pagetop/issues" target="_blank">SoloGit</a>. Antes de informar de una incidencia, revisa los informes ya existentes para evitar duplicados.
welcome_issues2 = Si se trata de fallos específicos de <strong>{ $app }</strong>, por favor acude a su repositorio oficial o canal de soporte, y no al de <strong>PageTop</strong> directamente.
welcome_code = Código
welcome_have_fun = Programar es crear

View file

@ -1,4 +1,4 @@
//! *Prelude* de PageTop. //! *Prelude* de `PageTop`.
// RE-EXPORTED. // RE-EXPORTED.

View file

@ -4,16 +4,13 @@ pub use error::ErrorPage;
pub use actix_web::Result as ResultPage; pub use actix_web::Result as ResultPage;
use crate::base::action; use crate::base::action;
use crate::builder_fn;
use crate::core::component::{Child, ChildOp, Component}; use crate::core::component::{Child, ChildOp, Component};
use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT}; use crate::core::theme::{ChildrenInRegions, ThemeRef, CONTENT_REGION_NAME};
use crate::html::{html, Markup, DOCTYPE}; use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated};
use crate::html::{AssetsOp, Context, Contextual};
use crate::html::{AttrClasses, ClassesOp};
use crate::html::{AttrId, AttrL10n};
use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier}; use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier};
use crate::service::HttpRequest; use crate::service::HttpRequest;
use crate::{builder_fn, AutoDefault};
/// Representa una página HTML completa lista para renderizar. /// Representa una página HTML completa lista para renderizar.
/// ///
@ -21,33 +18,32 @@ use crate::{builder_fn, AutoDefault};
/// regiones donde disponer los componentes, atributos de `<body>` y otros aspectos del contexto de /// regiones donde disponer los componentes, atributos de `<body>` y otros aspectos del contexto de
/// renderizado. /// renderizado.
#[rustfmt::skip] #[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Page { pub struct Page {
title : AttrL10n, title : OptionTranslated,
description : AttrL10n, description : OptionTranslated,
metadata : Vec<(&'static str, &'static str)>, metadata : Vec<(&'static str, &'static str)>,
properties : Vec<(&'static str, &'static str)>, properties : Vec<(&'static str, &'static str)>,
body_id : AttrId,
body_classes: AttrClasses,
context : Context, context : Context,
body_id : OptionId,
body_classes: OptionClasses,
regions : ChildrenInRegions, regions : ChildrenInRegions,
} }
impl Page { impl Page {
/// Crea una nueva instancia de página. /// Crea una nueva instancia de página.
/// ///
/// La solicitud HTTP se guardará en el contexto de renderizado de la página para poder ser /// Si se proporciona la solicitud HTTP, se guardará en el contexto de renderizado de la página
/// recuperada por los componentes si es necesario. /// para poder ser recuperada por los componentes si es necesario.
#[rustfmt::skip] #[rustfmt::skip]
pub fn new(request: HttpRequest) -> Self { pub fn new(request: Option<HttpRequest>) -> Self {
Page { Page {
title : AttrL10n::default(), title : OptionTranslated::default(),
description : AttrL10n::default(), description : OptionTranslated::default(),
metadata : Vec::default(), metadata : Vec::default(),
properties : Vec::default(), properties : Vec::default(),
body_id : AttrId::default(), context : Context::new(request),
body_classes: AttrClasses::default(), body_id : OptionId::default(),
context : Context::new(Some(request)), body_classes: OptionClasses::default(),
regions : ChildrenInRegions::default(), regions : ChildrenInRegions::default(),
} }
} }
@ -82,6 +78,34 @@ impl Page {
self self
} }
/// Modifica la fuente de idioma de la página ([`Context::with_langid()`]).
#[builder_fn]
pub fn with_langid(mut self, language: &impl LangId) -> Self {
self.context.alter_langid(language);
self
}
/// Modifica el tema que se usará para renderizar la página ([`Context::with_theme()`]).
#[builder_fn]
pub fn with_theme(mut self, theme_name: &'static str) -> Self {
self.context.alter_theme(theme_name);
self
}
/// Modifica la composición para renderizar la página ([`Context::with_layout()`]).
#[builder_fn]
pub fn with_layout(mut self, layout_name: &'static str) -> Self {
self.context.alter_layout(layout_name);
self
}
/// Define los recursos de la página usando [`AssetsOp`].
#[builder_fn]
pub fn with_assets(mut self, op: AssetsOp) -> Self {
self.context.alter_assets(op);
self
}
/// Establece el atributo `id` del elemento `<body>`. /// Establece el atributo `id` del elemento `<body>`.
#[builder_fn] #[builder_fn]
pub fn with_body_id(mut self, id: impl AsRef<str>) -> Self { pub fn with_body_id(mut self, id: impl AsRef<str>) -> Self {
@ -89,65 +113,35 @@ impl Page {
self self
} }
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`AttrClasses`]. /// Modifica las clases CSS del elemento `<body>` con una operación sobre [`OptionClasses`].
#[builder_fn] #[builder_fn]
pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self { pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.body_classes.alter_value(op, classes); self.body_classes.alter_value(op, classes);
self self
} }
/// **Obsoleto desde la versión 0.4.0**: usar [`add_component()`](Self::add_component) en su
/// lugar.
#[deprecated(since = "0.4.0", note = "Use `add_component()` instead")]
pub fn with_component(self, component: impl Component) -> Self {
self.add_component(component)
}
/// **Obsoleto desde la versión 0.4.0**: usar [`add_component_in()`](Self::add_component_in) en
/// su lugar.
#[deprecated(since = "0.4.0", note = "Use `add_component_in()` instead")]
pub fn with_component_in(self, region_name: &'static str, component: impl Component) -> Self {
self.add_component_in(region_name, component)
}
/// Añade un componente a la región de contenido por defecto. /// Añade un componente a la región de contenido por defecto.
pub fn add_component(mut self, component: impl Component) -> Self { pub fn with_component(mut self, component: impl Component) -> Self {
self.regions self.regions
.alter_child_in(REGION_CONTENT, ChildOp::Add(Child::with(component))); .alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(Child::with(component)));
self self
} }
/// Añade un componente en una región (`region_name`) de la página. /// Añade un componente en una región (`region_name`) de la página.
pub fn add_component_in( pub fn with_component_in(
mut self, mut self,
region_name: &'static str, region_name: &'static str,
component: impl Component, component: impl Component,
) -> Self { ) -> Self {
self.regions self.regions
.alter_child_in(region_name, ChildOp::Add(Child::with(component))); .alter_child_in_region(region_name, ChildOp::Add(Child::with(component)));
self
}
/// **Obsoleto desde la versión 0.4.0**: usar [`with_child_in()`](Self::with_child_in) en su
/// lugar.
#[deprecated(since = "0.4.0", note = "Use `with_child_in()` instead")]
pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
self.alter_child_in(region_name, op);
self
}
/// **Obsoleto desde la versión 0.4.0**: usar [`alter_child_in()`](Self::alter_child_in) en su
/// lugar.
#[deprecated(since = "0.4.0", note = "Use `alter_child_in()` instead")]
pub fn alter_child_in_region(&mut self, region_name: &'static str, op: ChildOp) -> &mut Self {
self.alter_child_in(region_name, op);
self self
} }
/// Opera con [`ChildOp`] en una región (`region_name`) de la página. /// Opera con [`ChildOp`] en una región (`region_name`) de la página.
#[builder_fn] #[builder_fn]
pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self { pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
self.regions.alter_child_in(region_name, op); self.regions.alter_child_in_region(region_name, op);
self self
} }
@ -155,12 +149,12 @@ impl Page {
/// Devuelve el título traducido para el idioma de la página, si existe. /// Devuelve el título traducido para el idioma de la página, si existe.
pub fn title(&mut self) -> Option<String> { pub fn title(&mut self) -> Option<String> {
self.title.lookup(&self.context) self.title.using(&self.context)
} }
/// Devuelve la descripción traducida para el idioma de la página, si existe. /// Devuelve la descripción traducida para el idioma de la página, si existe.
pub fn description(&mut self) -> Option<String> { pub fn description(&mut self) -> Option<String> {
self.description.lookup(&self.context) self.description.using(&self.context)
} }
/// Devuelve la lista de metadatos `<meta name=...>`. /// Devuelve la lista de metadatos `<meta name=...>`.
@ -173,28 +167,39 @@ impl Page {
&self.properties &self.properties
} }
/// Devuelve la solicitud HTTP asociada.
pub fn request(&self) -> Option<&HttpRequest> {
self.context.request()
}
/// Devuelve el identificador de idioma asociado.
pub fn langid(&self) -> &LanguageIdentifier {
self.context.langid()
}
/// Devuelve el tema que se usará para renderizar la página.
pub fn theme(&self) -> ThemeRef {
self.context.theme()
}
/// Devuelve la composición para renderizar la página. Por defecto es `"default"`.
pub fn layout(&self) -> &str {
self.context.layout()
}
/// Devuelve el identificador del elemento `<body>`. /// Devuelve el identificador del elemento `<body>`.
pub fn body_id(&self) -> &AttrId { pub fn body_id(&self) -> &OptionId {
&self.body_id &self.body_id
} }
/// Devuelve las clases CSS del elemento `<body>`. /// Devuelve las clases CSS del elemento `<body>`.
pub fn body_classes(&self) -> &AttrClasses { pub fn body_classes(&self) -> &OptionClasses {
&self.body_classes &self.body_classes
} }
/// Devuelve una referencia mutable al [`Context`] de la página.
///
/// El [`Context`] actúa como intermediario para muchos métodos de `Page` (idioma, tema,
/// *layout*, recursos, solicitud HTTP, etc.). Resulta especialmente útil cuando un componente
/// o un tema necesita recibir el contexto como parámetro.
pub fn context(&mut self) -> &mut Context {
&mut self.context
}
// Page RENDER ********************************************************************************* // Page RENDER *********************************************************************************
/// Renderiza los componentes de una región (`region_name`) de la página. /// Renderiza los componentes de una región (`regiona_name`) de la página.
pub fn render_region(&mut self, region_name: &'static str) -> Markup { pub fn render_region(&mut self, region_name: &'static str) -> Markup {
self.regions self.regions
.merge_all_components(self.context.theme(), region_name) .merge_all_components(self.context.theme(), region_name)
@ -202,7 +207,7 @@ impl Page {
} }
/// Renderiza los recursos de la página. /// Renderiza los recursos de la página.
pub fn render_assets(&mut self) -> Markup { pub fn render_assets(&self) -> Markup {
self.context.render_assets() self.context.render_assets()
} }
@ -245,85 +250,3 @@ impl Page {
}) })
} }
} }
impl LangId for Page {
fn langid(&self) -> &'static LanguageIdentifier {
self.context.langid()
}
}
impl Contextual for Page {
// Contextual BUILDER **************************************************************************
#[builder_fn]
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.context.alter_request(request);
self
}
#[builder_fn]
fn with_langid(mut self, language: &impl LangId) -> Self {
self.context.alter_langid(language);
self
}
#[builder_fn]
fn with_theme(mut self, theme_name: &'static str) -> Self {
self.context.alter_theme(theme_name);
self
}
#[builder_fn]
fn with_layout(mut self, layout_name: &'static str) -> Self {
self.context.alter_layout(layout_name);
self
}
#[builder_fn]
fn with_param<T: 'static>(mut self, key: &'static str, value: T) -> Self {
self.context.alter_param(key, value);
self
}
#[builder_fn]
fn with_assets(mut self, op: AssetsOp) -> Self {
self.context.alter_assets(op);
self
}
// Contextual GETTERS **************************************************************************
fn request(&self) -> Option<&HttpRequest> {
self.context.request()
}
fn theme(&self) -> ThemeRef {
self.context.theme()
}
fn layout(&self) -> &str {
self.context.layout()
}
fn param<T: 'static>(&self, key: &'static str) -> Option<&T> {
self.context.param(key)
}
fn favicon(&self) -> Option<&Favicon> {
self.context.favicon()
}
fn stylesheets(&self) -> &Assets<StyleSheet> {
self.context.stylesheets()
}
fn javascripts(&self) -> &Assets<JavaScript> {
self.context.javascripts()
}
// Contextual HELPERS **************************************************************************
fn required_id<T>(&mut self, id: Option<String>) -> String {
self.context.required_id::<T>(id)
}
}

View file

@ -1,5 +1,4 @@
use crate::base::component::Html; use crate::base::component::Html;
use crate::html::Contextual;
use crate::locale::L10n; use crate::locale::L10n;
use crate::response::ResponseError; use crate::response::ResponseError;
use crate::service::http::{header::ContentType, StatusCode}; use crate::service::http::{header::ContentType, StatusCode};
@ -7,7 +6,7 @@ use crate::service::{HttpRequest, HttpResponse};
use super::Page; use super::Page;
use std::fmt::{self, Display}; use std::fmt;
#[derive(Debug)] #[derive(Debug)]
pub enum ErrorPage { pub enum ErrorPage {
@ -20,7 +19,7 @@ pub enum ErrorPage {
Timeout(HttpRequest), Timeout(HttpRequest),
} }
impl Display for ErrorPage { impl fmt::Display for ErrorPage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
// Error 304. // Error 304.
@ -29,12 +28,12 @@ impl Display for ErrorPage {
ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"), ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"),
// Error 403. // Error 403.
ErrorPage::AccessDenied(request) => { ErrorPage::AccessDenied(request) => {
let mut error_page = Page::new(request.clone()); let mut error_page = Page::new(Some(request.clone()));
let error403 = error_page.theme().error403(&mut error_page); let error403 = error_page.theme().error403(&mut error_page);
if let Ok(page) = error_page if let Ok(page) = error_page
.with_title(L10n::n("Error FORBIDDEN")) .with_title(L10n::n("Error FORBIDDEN"))
.with_layout("error") .with_layout("error")
.add_component(Html::with(move |_| error403.clone())) .with_component(Html::with(move |_| error403.clone()))
.render() .render()
{ {
write!(f, "{}", page.into_string()) write!(f, "{}", page.into_string())
@ -44,12 +43,12 @@ impl Display for ErrorPage {
} }
// Error 404. // Error 404.
ErrorPage::NotFound(request) => { ErrorPage::NotFound(request) => {
let mut error_page = Page::new(request.clone()); let mut error_page = Page::new(Some(request.clone()));
let error404 = error_page.theme().error404(&mut error_page); let error404 = error_page.theme().error404(&mut error_page);
if let Ok(page) = error_page if let Ok(page) = error_page
.with_title(L10n::n("Error RESOURCE NOT FOUND")) .with_title(L10n::n("Error RESOURCE NOT FOUND"))
.with_layout("error") .with_layout("error")
.add_component(Html::with(move |_| error404.clone())) .with_component(Html::with(move |_| error404.clone()))
.render() .render()
{ {
write!(f, "{}", page.into_string()) write!(f, "{}", page.into_string())

View file

@ -17,6 +17,31 @@ pub use actix_web::test;
/// **Obsoleto desde la versión 0.3.0**: usar [`static_files_service!`](crate::static_files_service) /// **Obsoleto desde la versión 0.3.0**: usar [`static_files_service!`](crate::static_files_service)
/// en su lugar. /// en su lugar.
///
/// Incluye en código un conjunto de recursos previamente preparado con `build.rs`.
///
/// # Formas de uso
///
/// * `include_files!(media)` - Para incluir un conjunto de recursos llamado `media`. Normalmente se
/// usará esta forma.
///
/// * `include_files!(BLOG => media)` - También se puede asignar el conjunto de recursos a una
/// variable global; p.ej. `BLOG`.
///
/// # Argumentos
///
/// * `$bundle` Nombre del conjunto de recursos generado por `build.rs` (consultar
/// [`pagetop_build`](https://docs.rs/pagetop-build)).
/// * `$STATIC` Asigna el conjunto de recursos a una variable global de tipo
/// [`StaticResources`](crate::StaticResources).
///
/// # Ejemplos
///
/// ```rust,ignore
/// include_files!(assets); // Uso habitual.
///
/// include_files!(STATIC_ASSETS => assets);
/// ```
#[deprecated(since = "0.3.0", note = "Use `static_files_service!` instead")] #[deprecated(since = "0.3.0", note = "Use `static_files_service!` instead")]
#[macro_export] #[macro_export]
macro_rules! include_files { macro_rules! include_files {
@ -44,6 +69,48 @@ macro_rules! include_files {
/// **Obsoleto desde la versión 0.3.0**: usar [`static_files_service!`](crate::static_files_service) /// **Obsoleto desde la versión 0.3.0**: usar [`static_files_service!`](crate::static_files_service)
/// en su lugar. /// en su lugar.
///
/// Configura un servicio web para publicar los recursos embebidos con [`include_files!`].
///
/// El código expandido de la macro decide durante el arranque de la aplicación si debe servir los
/// archivos de los recursos embebidos o directamente desde el sistema de ficheros, si se ha
/// indicado una ruta válida a un directorio de recursos.
///
/// # Argumentos
///
/// * `$scfg` Instancia de [`ServiceConfig`](crate::service::web::ServiceConfig) donde aplicar la
/// configuración del servicio web.
/// * `$bundle` Nombre del conjunto de recursos incluido con [`include_files!`].
/// * `$route` Ruta URL de origen desde la que se servirán los archivos.
/// * `[ $root, $relative ]` *(opcional)* Directorio raíz y ruta relativa para construir la ruta
/// absoluta donde buscar los archivos en el sistema de ficheros (ver
/// [`absolute_dir()`](crate::util::absolute_dir)). Si no existe, se usarán los recursos
/// embebidos.
///
/// # Ejemplos
///
/// ```rust,ignore
/// use pagetop::prelude::*;
///
/// include_files!(assets);
///
/// pub struct MyExtension;
///
/// impl Extension for MyExtension {
/// fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
/// include_files_service!(scfg, assets => "/public");
/// }
/// }
/// ```
///
/// Y para buscar los recursos en el sistema de ficheros (si existe la ruta absoluta):
///
/// ```rust,ignore
/// include_files_service!(cfg, assets => "/public", ["/var/www", "assets"]);
///
/// // También desde el directorio actual de ejecución.
/// include_files_service!(cfg, assets => "/public", ["", "static"]);
/// ```
#[deprecated(since = "0.3.0", note = "Use `static_files_service!` instead")] #[deprecated(since = "0.3.0", note = "Use `static_files_service!` instead")]
#[macro_export] #[macro_export]
macro_rules! include_files_service { macro_rules! include_files_service {

View file

@ -1,13 +1,13 @@
//! Gestión de trazas y registro de eventos de la aplicación. //! Gestión de trazas y registro de eventos de la aplicación.
//! //!
//! PageTop recopila información de diagnóstico de la aplicación de forma estructurada y basada en //! `PageTop` recopila información de diagnóstico de la aplicación de forma estructurada y basada en
//! eventos. //! eventos.
//! //!
//! En los sistemas asíncronos, interpretar los mensajes de log tradicionales suele volverse //! En los sistemas asíncronos, interpretar los mensajes de log tradicionales suele volverse
//! complicado. Las tareas individuales se multiplexan en el mismo hilo y los eventos y registros //! complicado. Las tareas individuales se multiplexan en el mismo hilo y los eventos y registros
//! asociados se entremezclan, lo que dificulta seguir la secuencia lógica. //! asociados se entremezclan, lo que dificulta seguir la secuencia lógica.
//! //!
//! PageTop usa [`tracing`](https://docs.rs/tracing) para registrar eventos estructurados y con //! `PageTop` usa [`tracing`](https://docs.rs/tracing) para registrar eventos estructurados y con
//! información adicional sobre la *temporalidad* y la *causalidad*. A diferencia de un mensaje de //! información adicional sobre la *temporalidad* y la *causalidad*. A diferencia de un mensaje de
//! log, un *span* (intervalo) tiene un momento de inicio y de fin, puede entrar y salir del flujo //! log, un *span* (intervalo) tiene un momento de inicio y de fin, puede entrar y salir del flujo
//! de ejecución y puede existir dentro de un árbol anidado de *spans* similares. Además, estos //! de ejecución y puede existir dentro de un árbol anidado de *spans* similares. Además, estos

View file

@ -1,4 +1,4 @@
//! Macros y funciones útiles. //! Funciones y macros útiles.
use crate::trace; use crate::trace;
@ -6,198 +6,6 @@ use std::env;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
// MACROS INTEGRADAS *******************************************************************************
#[doc(hidden)]
pub use paste::paste;
#[doc(hidden)]
pub use concat_string::concat_string;
pub use indoc::{concatdoc, formatdoc, indoc};
// MACROS ÚTILES ***********************************************************************************
#[macro_export]
/// Macro para construir una colección de pares clave-valor.
///
/// ```rust
/// use pagetop::hm;
/// use std::collections::HashMap;
///
/// let args:HashMap<&str, String> = hm![
/// "userName" => "Roberto",
/// "photoCount" => "3",
/// "userGender" => "male",
/// ];
/// ```
macro_rules! hm {
( $($key:expr => $value:expr),* $(,)? ) => {{
let mut a = std::collections::HashMap::new();
$(
a.insert($key.into(), $value.into());
)*
a
}};
}
/// Concatena eficientemente varios fragmentos en un [`String`].
///
/// Esta macro exporta [`concat_string!`](https://docs.rs/concat-string). Acepta cualquier número de
/// fragmentos que implementen [`AsRef<str>`] y construye un [`String`] con el tamaño óptimo, de
/// forma eficiente y evitando el uso de cadenas de formato que penalicen el rendimiento.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Concatena todos los fragmentos directamente.
/// let result = join!("Hello", " ", "World");
/// assert_eq!(result, "Hello World".to_string());
///
/// // También funciona con valores vacíos.
/// let result_with_empty = join!("Hello", "", "World");
/// assert_eq!(result_with_empty, "HelloWorld".to_string());
///
/// // Un único fragmento devuelve el mismo valor.
/// let single_result = join!("Hello");
/// assert_eq!(single_result, "Hello".to_string());
/// ```
#[macro_export]
macro_rules! join {
($($arg:tt)*) => {
$crate::util::concat_string!($($arg)*)
};
}
/// Concatena los fragmentos no vacíos en un [`Option<String>`] con un separador opcional.
///
/// Esta macro acepta cualquier número de fragmentos que implementen [`AsRef<str>`] para concatenar
/// todos los fragmentos no vacíos usando opcionalmente un separador.
///
/// Si todos los fragmentos están vacíos, devuelve [`None`].
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Concatena los fragmentos no vacíos con un espacio como separador.
/// let result_with_separator = join_opt!(["Hello", "", "World"]; " ");
/// assert_eq!(result_with_separator, Some("Hello World".to_string()));
///
/// // Concatena los fragmentos no vacíos sin un separador.
/// let result_without_separator = join_opt!(["Hello", "", "World"]);
/// assert_eq!(result_without_separator, Some("HelloWorld".to_string()));
///
/// // Devuelve `None` si todos los fragmentos están vacíos.
/// let result_empty = join_opt!(["", "", ""]);
/// assert_eq!(result_empty, None);
/// ```
#[macro_export]
macro_rules! join_opt {
([$($arg:expr),* $(,)?]) => {{
let s = $crate::util::concat_string!($($arg),*);
(!s.is_empty()).then_some(s)
}};
([$($arg:expr),* $(,)?]; $separator:expr) => {{
let s = [$($arg),*]
.iter()
.filter(|&item| !item.is_empty())
.cloned()
.collect::<Vec<_>>()
.join($separator);
(!s.is_empty()).then_some(s)
}};
}
/// Concatena dos fragmentos en un [`String`] usando un separador.
///
/// Une los dos fragmentos, que deben implementar [`AsRef<str>`], usando el separador proporcionado.
/// Si uno de ellos está vacío, devuelve directamente el otro; y si ambos están vacíos devuelve un
/// [`String`] vacío.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let first = "Hello";
/// let separator = "-";
/// let second = "World";
///
/// // Concatena los dos fragmentos cuando ambos no están vacíos.
/// let result = join_pair!(first, separator, second);
/// assert_eq!(result, "Hello-World".to_string());
///
/// // Si el primer fragmento está vacío, devuelve el segundo.
/// let result_empty_first = join_pair!("", separator, second);
/// assert_eq!(result_empty_first, "World".to_string());
///
/// // Si el segundo fragmento está vacío, devuelve el primero.
/// let result_empty_second = join_pair!(first, separator, "");
/// assert_eq!(result_empty_second, "Hello".to_string());
///
/// // Si ambos fragmentos están vacíos, devuelve una cadena vacía.
/// let result_both_empty = join_pair!("", separator, "");
/// assert_eq!(result_both_empty, "".to_string());
/// ```
#[macro_export]
macro_rules! join_pair {
($first:expr, $separator:expr, $second:expr) => {{
if $first.is_empty() {
String::from($second)
} else if $second.is_empty() {
String::from($first)
} else {
$crate::util::concat_string!($first, $separator, $second)
}
}};
}
/// Concatena varios fragmentos en un [`Option<String>`] si ninguno está vacío.
///
/// Si alguno de los fragmentos, que deben implementar [`AsRef<str>`], está vacío, devuelve
/// [`None`]. Opcionalmente se puede indicar un separador entre los fragmentos concatenados.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Concatena los fragmentos.
/// let result = join_strict!(["Hello", "World"]);
/// assert_eq!(result, Some("HelloWorld".to_string()));
///
/// // Concatena los fragmentos con un separador.
/// let result_with_separator = join_strict!(["Hello", "World"]; " ");
/// assert_eq!(result_with_separator, Some("Hello World".to_string()));
///
/// // Devuelve `None` si alguno de los fragmentos está vacío.
/// let result_with_empty = join_strict!(["Hello", "", "World"]);
/// assert_eq!(result_with_empty, None);
/// ```
#[macro_export]
macro_rules! join_strict {
([$($arg:expr),* $(,)?]) => {{
let fragments = [$($arg),*];
if fragments.iter().any(|&item| item.is_empty()) {
None
} else {
Some(fragments.concat())
}
}};
([$($arg:expr),* $(,)?]; $separator:expr) => {{
let fragments = [$($arg),*];
if fragments.iter().any(|&item| item.is_empty()) {
None
} else {
Some(fragments.join($separator))
}
}};
}
// FUNCIONES ÚTILES ******************************************************************************** // FUNCIONES ÚTILES ********************************************************************************
/// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta. /// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta.
@ -248,8 +56,8 @@ pub fn resolve_absolute_dir<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
} }
} }
/// **Obsoleto desde la versión 0.3.0**: usar [`resolve_absolute_dir()`] en su lugar. /// Devuelve la ruta absoluta a un directorio existente.
#[deprecated(since = "0.3.0", note = "Use `resolve_absolute_dir()` instead")] #[deprecated(since = "0.3.0", note = "Use [`resolve_absolute_dir`] instead")]
pub fn absolute_dir<P, Q>(root_path: P, relative_path: Q) -> io::Result<PathBuf> pub fn absolute_dir<P, Q>(root_path: P, relative_path: Q) -> io::Result<PathBuf>
where where
P: AsRef<Path>, P: AsRef<Path>,
@ -257,3 +65,191 @@ where
{ {
resolve_absolute_dir(root_path.as_ref().join(relative_path.as_ref())) resolve_absolute_dir(root_path.as_ref().join(relative_path.as_ref()))
} }
// MACROS ÚTILES ***********************************************************************************
#[doc(hidden)]
pub use paste::paste;
#[doc(hidden)]
pub use concat_string::concat_string;
#[macro_export]
/// Macro para construir una colección de pares clave-valor.
///
/// ```rust
/// use pagetop::hm;
/// use std::collections::HashMap;
///
/// let args:HashMap<&str, String> = hm![
/// "userName" => "Roberto",
/// "photoCount" => "3",
/// "userGender" => "male",
/// ];
/// ```
macro_rules! hm {
( $($key:expr => $value:expr),* $(,)? ) => {{
let mut a = std::collections::HashMap::new();
$(
a.insert($key.into(), $value.into());
)*
a
}};
}
/// Concatena eficientemente varios fragmentos en un [`String`].
///
/// Esta macro exporta [`concat_string!`](https://docs.rs/concat-string). Acepta cualquier número de
/// fragmentos que implementen [`AsRef<str>`] y construye un [`String`] con el tamaño óptimo, de
/// forma eficiente y evitando el uso de cadenas de formato que penalicen el rendimiento.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Concatena todos los fragmentos directamente.
/// let result = join!("Hello", " ", "World");
/// assert_eq!(result, String::from("Hello World"));
///
/// // También funciona con valores vacíos.
/// let result_with_empty = join!("Hello", "", "World");
/// assert_eq!(result_with_empty, String::from("HelloWorld"));
///
/// // Un único fragmento devuelve el mismo valor.
/// let single_result = join!("Hello");
/// assert_eq!(single_result, String::from("Hello"));
/// ```
#[macro_export]
macro_rules! join {
($($arg:tt)*) => {
$crate::util::concat_string!($($arg)*)
};
}
/// Concatena los fragmentos no vacíos en un [`Option<String>`] con un separador opcional.
///
/// Esta macro acepta cualquier número de fragmentos que implementen [`AsRef<str>`] para concatenar
/// todos los fragmentos no vacíos usando opcionalmente un separador.
///
/// Si todos los fragmentos están vacíos, devuelve [`None`].
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Concatena los fragmentos no vacíos con un espacio como separador.
/// let result_with_separator = join_opt!(["Hello", "", "World"]; " ");
/// assert_eq!(result_with_separator, Some(String::from("Hello World")));
///
/// // Concatena los fragmentos no vacíos sin un separador.
/// let result_without_separator = join_opt!(["Hello", "", "World"]);
/// assert_eq!(result_without_separator, Some(String::from("HelloWorld")));
///
/// // Devuelve `None` si todos los fragmentos están vacíos.
/// let result_empty = join_opt!(["", "", ""]);
/// assert_eq!(result_empty, None);
/// ```
#[macro_export]
macro_rules! join_opt {
([$($arg:expr),* $(,)?]) => {{
let s = $crate::util::concat_string!($($arg),*);
(!s.is_empty()).then_some(s)
}};
([$($arg:expr),* $(,)?]; $separator:expr) => {{
let s = [$($arg),*]
.iter()
.filter(|&item| !item.is_empty())
.cloned()
.collect::<Vec<_>>()
.join($separator);
(!s.is_empty()).then_some(s)
}};
}
/// Concatena dos fragmentos en un [`String`] usando un separador.
///
/// Une los dos fragmentos, que deben implementar [`AsRef<str>`], usando el separador proporcionado.
/// Si uno de ellos está vacío, devuelve directamente el otro; y si ambos están vacíos devuelve un
/// [`String`] vacío.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let first = "Hello";
/// let separator = "-";
/// let second = "World";
///
/// // Concatena los dos fragmentos cuando ambos no están vacíos.
/// let result = join_pair!(first, separator, second);
/// assert_eq!(result, String::from("Hello-World"));
///
/// // Si el primer fragmento está vacío, devuelve el segundo.
/// let result_empty_first = join_pair!("", separator, second);
/// assert_eq!(result_empty_first, String::from("World"));
///
/// // Si el segundo fragmento está vacío, devuelve el primero.
/// let result_empty_second = join_pair!(first, separator, "");
/// assert_eq!(result_empty_second, String::from("Hello"));
///
/// // Si ambos fragmentos están vacíos, devuelve una cadena vacía.
/// let result_both_empty = join_pair!("", separator, "");
/// assert_eq!(result_both_empty, String::from(""));
/// ```
#[macro_export]
macro_rules! join_pair {
($first:expr, $separator:expr, $second:expr) => {{
if $first.is_empty() {
String::from($second)
} else if $second.is_empty() {
String::from($first)
} else {
$crate::util::concat_string!($first, $separator, $second)
}
}};
}
/// Concatena varios fragmentos en un [`Option<String>`] si ninguno está vacío.
///
/// Si alguno de los fragmentos, que deben implementar [`AsRef<str>`], está vacío, devuelve
/// [`None`]. Opcionalmente se puede indicar un separador entre los fragmentos concatenados.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Concatena los fragmentos.
/// let result = join_strict!(["Hello", "World"]);
/// assert_eq!(result, Some(String::from("HelloWorld")));
///
/// // Concatena los fragmentos con un separador.
/// let result_with_separator = join_strict!(["Hello", "World"]; " ");
/// assert_eq!(result_with_separator, Some(String::from("Hello World")));
///
/// // Devuelve `None` si alguno de los fragmentos está vacío.
/// let result_with_empty = join_strict!(["Hello", "", "World"]);
/// assert_eq!(result_with_empty, None);
/// ```
#[macro_export]
macro_rules! join_strict {
([$($arg:expr),* $(,)?]) => {{
let fragments = [$($arg),*];
if fragments.iter().any(|&item| item.is_empty()) {
None
} else {
Some(fragments.concat())
}
}};
([$($arg:expr),* $(,)?]; $separator:expr) => {{
let fragments = [$($arg),*];
if fragments.iter().any(|&item| item.is_empty()) {
None
} else {
Some(fragments.join($separator))
}
}};
}

View file

@ -1,11 +0,0 @@
/* Page layout */
.region--footer {
padding-bottom: 2rem;
}
/* PoweredBy component */
.poweredby {
text-align: center;
}

View file

@ -1,17 +1,13 @@
:root { :root {
--bg-img: url('/img/intro-header.jpg'); --bg-img: url('/img/welcome-header.jpg');
--bg-img-set: image-set(url('/img/intro-header.avif') type('image/avif'), url('/img/intro-header.webp') type('image/webp'), var(--bg-img) type('image/jpeg')); --bg-img-set: image-set(url('/img/welcome-header.avif') type('image/avif'), url('/img/welcome-header.webp') type('image/webp'), var(--bg-img) type('image/jpeg'));
--bg-img-sm: url('/img/intro-header-sm.jpg'); --bg-img-sm: url('/img/welcome-header-sm.jpg');
--bg-img-sm-set: image-set(url('/img/intro-header-sm.avif') type('image/avif'), url('/img/intro-header-sm.webp') type('image/webp'), var(--bg-img-sm) type('image/jpeg')); --bg-img-sm-set: image-set(url('/img/welcome-header-sm.avif') type('image/avif'), url('/img/welcome-header-sm.webp') type('image/webp'), var(--bg-img-sm) type('image/jpeg'));
--bg-color: #8c5919; --bg-color: #8c5919;
--color: #1a202c; --color: #1a202c;
--color-red: #fecaca;
--color-gray: #e4e4e7; --color-gray: #e4e4e7;
--color-link: #1e4eae; --color-link: #1e4eae;
--color-block-1: #b689ff;
--color-block-2: #fecaca;
--color-block-3: #e6a9e2;
--color-block-4: #ffedca;
--color-block-5: #ffffff;
--focus-outline: 2px solid var(--color-link); --focus-outline: 2px solid var(--color-link);
--focus-outline-offset: 2px; --focus-outline-offset: 2px;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
@ -32,14 +28,9 @@ body {
font-weight: 300; font-weight: 300;
color: var(--color); color: var(--color);
line-height: 1.6; line-height: 1.6;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
} }
header,
section { section {
position: relative; position: relative;
text-align: center; text-align: center;
@ -59,17 +50,20 @@ a:hover:visited {
text-decoration-color: var(--color-link); text-decoration-color: var(--color-link);
} }
/* #content {
* Header width: 100%;
*/ display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.intro-header { #main-header {
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
width: 100%;
max-width: 80rem;
margin: 0 auto;
padding-bottom: 9rem; padding-bottom: 9rem;
max-width: 80rem;
width: 100%;
background-image: var(--bg-img-sm); background-image: var(--bg-img-sm);
background-image: var(--bg-img-sm-set); background-image: var(--bg-img-sm-set);
background-position: top center; background-position: top center;
@ -77,11 +71,11 @@ a:hover:visited {
background-size: contain; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.intro-header__body { #main-header header {
padding: 0; padding: 0;
background: none; background: none;
} }
.intro-header__title { #header-title {
margin: 0 0 0 1.5rem; margin: 0 0 0 1.5rem;
text-align: left; text-align: left;
display: flex; display: flex;
@ -95,7 +89,7 @@ a:hover:visited {
line-height: 110%; line-height: 110%;
text-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.3); text-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.3);
} }
.intro-header__title > span { #header-title > span {
background: linear-gradient(180deg, #ddff95 30%, #ffb84b 100%); background: linear-gradient(180deg, #ddff95 30%, #ffb84b 100%);
background-clip: text; background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
@ -106,44 +100,40 @@ a:hover:visited {
line-height: 110%; line-height: 110%;
text-shadow: none; text-shadow: none;
} }
.intro-header__image { #header-image {
width: 100%;
text-align: right;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
text-align: right;
width: 100%;
} }
.intro-header__monster { #header-image #monster {
margin-right: 12rem; margin-right: 12rem;
margin-top: 1rem; margin-top: 1rem;
flex-shrink: 1; flex-shrink: 1;
} }
@media (min-width: 64rem) { @media (min-width: 64rem) {
.intro-header { #main-header {
background-image: var(--bg-img); background-image: var(--bg-img);
background-image: var(--bg-img-set); background-image: var(--bg-img-set);
} }
.intro-header__title { #header-title {
padding: 1.2rem 2rem 2.6rem 2rem; padding: 1.2rem 2rem 2.6rem 2rem;
} }
.intro-header__image { #header-image {
justify-content: flex-end; justify-content: flex-end;
} }
} }
/* #main-content {
* Content
*/
.intro-content {
height: auto; height: auto;
margin-top: 1.6rem; margin-top: 1.6rem;
} }
.intro-content__body { .content-body {
box-sizing: border-box; box-sizing: border-box;
max-width: 80rem; max-width: 80rem;
} }
.intro-content__body:before, .content-body:before,
.intro-content__body:after { .content-body:after {
content: ''; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
@ -153,37 +143,38 @@ a:hover:visited {
filter: blur(2.75rem); filter: blur(2.75rem);
opacity: 0.8; opacity: 0.8;
inset: 11.75rem; inset: 11.75rem;
z-index: 0;
} }
.intro-content__body:before { .content-body:before {
top: -1rem; top: -1rem;
} }
.intro-content__body:after { .content-body:after {
bottom: -1rem; bottom: -1rem;
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.intro-content__body { .content-body {
margin-top: -9.8rem; margin-top: -9.8rem;
} }
.intro-content__body:before, .content-body:before,
.intro-content__body:after { .content-body:after {
inset: unset; inset: unset;
} }
} }
@media (min-width: 64rem) { @media (min-width: 64rem) {
.intro-content { #main-content {
margin-top: 0; margin-top: 0;
} }
.intro-content__body { .content-body {
margin-top: -5.7rem; margin-top: -5.7rem;
} }
} }
.intro-button { #poweredby-button {
width: 100%; width: 100%;
margin: 0 auto 3rem; margin: 0 auto 3rem;
z-index: 10; z-index: 10;
} }
.intro-button__link { #poweredby-link {
background: #7f1d1d; background: #7f1d1d;
background-image: linear-gradient(to bottom, rgba(255,0,0,0.8), rgba(255,255,255,0)); background-image: linear-gradient(to bottom, rgba(255,0,0,0.8), rgba(255,255,255,0));
background-position: top left, center; background-position: top left, center;
@ -196,6 +187,7 @@ a:hover:visited {
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.3; line-height: 1.3;
text-decoration: none; text-decoration: none;
text-shadow: var(--shadow);
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -203,7 +195,7 @@ a:hover:visited {
min-height: 7.6875rem; min-height: 7.6875rem;
outline: none; outline: none;
} }
.intro-button__link::before { #poweredby-link::before {
content: ''; content: '';
position: absolute; position: absolute;
top: -13.125rem; top: -13.125rem;
@ -215,7 +207,7 @@ a:hover:visited {
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
z-index: 5; z-index: 5;
} }
.intro-button__text { #poweredby-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
@ -225,24 +217,25 @@ a:hover:visited {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
text-align: left; text-align: left;
color: white; color: white;
text-shadow: 0 0.101125rem 0.2021875rem rgba(0, 0, 0, 0.25);
font-size: 1.65rem; font-size: 1.65rem;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
line-height: 130.023%; line-height: 130.023%;
letter-spacing: 0.0075rem; letter-spacing: 0.0075rem;
} }
.intro-button__text strong { #poweredby-text strong {
font-size: 2.625rem; font-size: 2.625rem;
font-weight: 600; font-weight: 600;
line-height: 130.023%; line-height: 130.023%;
letter-spacing: 0.013125rem; letter-spacing: 0.013125rem;
} }
.intro-button__link span { #poweredby-link span {
position: absolute; position: absolute;
display: block; display: block;
pointer-events: none; pointer-events: none;
} }
.intro-button__link span:nth-child(1) { #poweredby-link span:nth-child(1) {
height: 8px; height: 8px;
width: 100%; width: 100%;
top: 0; top: 0;
@ -262,7 +255,7 @@ a:hover:visited {
transform: translateX(100%); transform: translateX(100%);
} }
} }
.intro-button__link span:nth-child(2) { #poweredby-link span:nth-child(2) {
width: 8px; width: 8px;
height: 100%; height: 100%;
top: 0; top: 0;
@ -282,7 +275,7 @@ a:hover:visited {
transform: translateY(100%); transform: translateY(100%);
} }
} }
.intro-button__link span:nth-child(3) { #poweredby-link span:nth-child(3) {
height: 8px; height: 8px;
width: 100%; width: 100%;
bottom: 0; bottom: 0;
@ -302,22 +295,27 @@ a:hover:visited {
transform: translateX(-100%); transform: translateX(-100%);
} }
} }
.intro-button__link:hover span { #poweredby-link:hover {
transition: all .5s;
transform: rotate(-3deg) scale(1.1);
box-shadow: 0px 3px 5px rgba(0,0,0,.4);
}
#poweredby-link:hover span {
animation-play-state: paused; animation-play-state: paused;
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.intro-button__link { #poweredby-link {
height: 6.25rem; height: 6.25rem;
min-width: auto; min-width: auto;
border-radius: 0; border-radius: 0;
} }
.intro-button__text { #poweredby-text {
display: inline; display: inline;
padding-top: .5rem; padding-top: .5rem;
} }
} }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.intro-button { #poweredby-button {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
@ -325,13 +323,9 @@ a:hover:visited {
max-width: 29.375rem; max-width: 29.375rem;
margin-bottom: 0; margin-bottom: 0;
} }
.intro-button__link:hover {
transition: all .5s;
transform: rotate(-3deg) scale(1.1);
}
} }
.intro-text { .content-text {
z-index: 1; z-index: 1;
width: 100%; width: 100%;
display: flex; display: flex;
@ -343,16 +337,13 @@ a:hover:visited {
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
margin-top: -6rem; margin-top: -6rem;
margin-bottom: 0;
background: #fff; background: #fff;
margin-bottom: 0;
position: relative; position: relative;
padding: 2.5rem 1.063rem 0.75rem; padding: 6rem 1.063rem 0.75rem;
overflow: hidden; overflow: hidden;
} }
.intro-button + .intro-text { .content-text p {
padding-top: 6rem;
}
.intro-text p {
width: 100%; width: 100%;
line-height: 150%; line-height: 150%;
font-weight: 400; font-weight: 400;
@ -360,16 +351,14 @@ a:hover:visited {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
} }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.intro-text { .content-text {
font-size: 1.375rem; font-size: 1.375rem;
line-height: 2rem; line-height: 2rem;
}
.intro-button + .intro-text {
padding-top: 7rem; padding-top: 7rem;
} }
} }
@media (min-width: 64rem) { @media (min-width: 64rem) {
.intro-text { .content-text {
border-radius: 0.75rem; border-radius: 0.75rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
max-width: 60rem; max-width: 60rem;
@ -379,13 +368,13 @@ a:hover:visited {
} }
} }
.intro-text .block { .subcontent {
position: relative; position: relative;
} }
.intro-text .block__title { .subcontent h1 {
margin: 1em 0 .8em; margin: 1em 0 .8em;
} }
.intro-text .block__title span { .subcontent h1 span {
display: inline-block; display: inline-block;
padding: 10px 30px 14px; padding: 10px 30px 14px;
margin: 0 0 0 20px; margin: 0 0 0 20px;
@ -396,7 +385,7 @@ a:hover:visited {
border-color: orangered; border-color: orangered;
transform: rotate(-3deg) translateY(-25%); transform: rotate(-3deg) translateY(-25%);
} }
.intro-text .block__title:before { .subcontent h1:before {
content: ""; content: "";
height: 5px; height: 5px;
position: absolute; position: absolute;
@ -409,7 +398,7 @@ a:hover:visited {
transform: rotate(2deg) translateY(-50%); transform: rotate(2deg) translateY(-50%);
transform-origin: top left; transform-origin: top left;
} }
.intro-text .block__title:after { .subcontent h1:after {
content: ""; content: "";
height: 70rem; height: 70rem;
position: absolute; position: absolute;
@ -417,80 +406,55 @@ a:hover:visited {
left: -15%; left: -15%;
width: 130%; width: 130%;
z-index: -10; z-index: -10;
background: var(--color-block-1); background: var(--color-red);
transform: rotate(2deg); transform: rotate(2deg);
} }
.intro-text .block:nth-of-type(5n+1) .block__title:after {
background: var(--color-block-1);
}
.intro-text .block:nth-of-type(5n+2) .block__title:after {
background: var(--color-block-2);
}
.intro-text .block:nth-of-type(5n+3) .block__title:after {
background: var(--color-block-3);
}
.intro-text .block:nth-of-type(5n+4) .block__title:after {
background: var(--color-block-4);
}
.intro-text .block:nth-of-type(5n+5) .block__title:after {
background: var(--color-block-5);
}
/* #footer {
* Footer
*/
.intro-footer {
width: 100%; width: 100%;
background-color: black; background-color: black;
color: var(--color-gray); color: var(--color-gray);
padding-bottom: 2rem;
}
.intro-footer__body {
display: flex;
justify-content: center;
flex-direction: column;
margin: 0 auto;
padding: 0 10.625rem 2rem;
max-width: 80rem;
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 300; font-weight: 300;
line-height: 100%; line-height: 100%;
display: flex;
justify-content: center;
z-index: 10;
} }
.intro-footer__body a:visited { #footer a:visited {
color: var(--color-gray); color: var(--color-gray);
} }
.intro-footer__logo, .footer-logo {
.intro-footer__links { max-height: 12.625rem;
}
.footer-logo svg {
width: 100%;
}
.footer-logo,
.footer-links,
.footer-inner {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
.intro-footer__logo { .footer-links {
max-height: 12.625rem;
}
.intro-footer__logo svg {
width: 100%;
}
.intro-footer__links {
gap: 1.875rem; gap: 1.875rem;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 2rem; margin-top: 2rem;
} }
.footer-inner {
max-width: 80rem;
display: flex;
flex-direction: column;
padding: 0 10.625rem 2rem;
}
@media (max-width: 48rem) { @media (max-width: 48rem) {
.intro-footer__logo { .footer-logo {
display: none; display: none;
} }
} }
@media (max-width: 64rem) { @media (max-width: 64rem) {
.intro-footer__body { .footer-inner {
padding: 0 1rem 2rem; padding: 0 1rem 2rem;
} }
} }
/* PoweredBy component */
.poweredby a:visited {
color: var(--color-gray);
}

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Before After
Before After

View file

@ -17,10 +17,10 @@ async fn component_html_renders_static_markup() {
#[pagetop::test] #[pagetop::test]
async fn component_html_renders_using_context_param() { async fn component_html_renders_using_context_param() {
let mut cx = Context::new(None).with_param("username", "Alice".to_string()); let mut cx = Context::new(None).with_param("username", String::from("Alice"));
let component = Html::with(|cx| { let component = Html::with(|cx| {
let name = cx.param::<String>("username").cloned().unwrap_or_default(); let name = cx.get_param::<String>("username").unwrap_or_default();
html! { html! {
span { (name) } span { (name) }
} }
@ -35,7 +35,7 @@ async fn component_html_renders_using_context_param() {
async fn component_html_allows_replacing_render_function() { async fn component_html_allows_replacing_render_function() {
let mut component = Html::with(|_| html! { div { "Original" } }); let mut component = Html::with(|_| html! { div { "Original" } });
component.alter_fn(|_| html! { div { "Modified" } }); component.alter_html(|_| html! { div { "Modified" } });
let markup = component let markup = component
.prepare_component(&mut Context::new(None)) .prepare_component(&mut Context::new(None))

View file

@ -1,99 +0,0 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn poweredby_default_shows_only_pagetop_recognition() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::default();
let html = render_component(&p);
// Debe mostrar el bloque de reconocimiento a PageTop.
assert!(html.as_str().contains("poweredby__pagetop"));
// Y NO debe mostrar el bloque de copyright.
assert!(!html.as_str().contains("poweredby__copyright"));
}
#[pagetop::test]
async fn poweredby_new_includes_current_year_and_app_name() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::new();
let html = render_component(&p);
let year = Utc::now().format("%Y").to_string();
assert!(
html.as_str().contains(&year),
"HTML should include the current year"
);
// El nombre de la app proviene de `global::SETTINGS.app.name`.
let app_name = &global::SETTINGS.app.name;
assert!(
html.as_str().contains(app_name),
"HTML should include the application name"
);
// Debe existir el span de copyright.
assert!(html.as_str().contains("poweredby__copyright"));
}
#[pagetop::test]
async fn poweredby_with_copyright_overrides_text() {
let _app = service::test::init_service(Application::new().test()).await;
let custom = "2001 © FooBar Inc.";
let p = PoweredBy::default().with_copyright(Some(custom));
let html = render_component(&p);
assert!(html.as_str().contains(custom));
assert!(html.as_str().contains("poweredby__copyright"));
}
#[pagetop::test]
async fn poweredby_with_copyright_none_hides_text() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::new().with_copyright(None::<String>);
let html = render_component(&p);
assert!(!html.as_str().contains("poweredby__copyright"));
// El reconocimiento a PageTop siempre debe aparecer.
assert!(html.as_str().contains("poweredby__pagetop"));
}
#[pagetop::test]
async fn poweredby_link_points_to_crates_io() {
let _app = service::test::init_service(Application::new().test()).await;
let p = PoweredBy::default();
let html = render_component(&p);
assert!(
html.as_str().contains("https://pagetop.cillero.es"),
"Link should point to pagetop.cillero.es"
);
}
#[pagetop::test]
async fn poweredby_getter_reflects_internal_state() {
let _app = service::test::init_service(Application::new().test()).await;
// Por defecto no hay copyright.
let p0 = PoweredBy::default();
assert_eq!(p0.copyright(), None);
// Y `new()` lo inicializa con año + nombre de app.
let p1 = PoweredBy::new();
let c1 = p1.copyright().expect("Expected copyright to exis");
assert!(c1.contains(&Utc::now().format("%Y").to_string()));
assert!(c1.contains(&global::SETTINGS.app.name));
}
// HELPERS *****************************************************************************************
fn render_component<C: Component>(c: &C) -> Markup {
let mut cx = Context::default();
let pm = c.prepare_component(&mut cx);
pm.render()
}

View file

@ -1,108 +1,17 @@
use pagetop::prelude::*; use pagetop::prelude::*;
#[pagetop::test] #[pagetop::test]
async fn prepare_markup_render_none_is_empty_string() { async fn prepare_markup_is_empty() {
assert_eq!(PrepareMarkup::None.render().as_str(), ""); let _app = service::test::init_service(Application::new().test()).await;
}
#[pagetop::test]
async fn prepare_markup_render_escaped_escapes_html_and_ampersands() {
let pm = PrepareMarkup::Escaped("<b>& \" ' </b>".to_string());
assert_eq!(pm.render().as_str(), "&lt;b&gt;&amp; &quot; ' &lt;/b&gt;");
}
#[pagetop::test]
async fn prepare_markup_render_raw_is_inserted_verbatim() {
let pm = PrepareMarkup::Raw("<b>bold</b><script>1<2</script>".to_string());
assert_eq!(pm.render().as_str(), "<b>bold</b><script>1<2</script>");
}
#[pagetop::test]
async fn prepare_markup_render_with_keeps_structure() {
let pm = PrepareMarkup::With(html! {
h2 { "Sample title" }
p { "This is a paragraph." }
});
assert_eq!(
pm.render().as_str(),
"<h2>Sample title</h2><p>This is a paragraph.</p>"
);
}
#[pagetop::test]
async fn prepare_markup_does_not_double_escape_when_wrapped_in_html_macro() {
// Escaped: dentro de `html!` no debe volver a escaparse.
let escaped = PrepareMarkup::Escaped("<i>x</i>".into());
let wrapped_escaped = html! { div { (escaped.render()) } };
assert_eq!(
wrapped_escaped.into_string(),
"<div>&lt;i&gt;x&lt;/i&gt;</div>"
);
// Raw: tampoco debe escaparse al integrarlo.
let raw = PrepareMarkup::Raw("<i>x</i>".into());
let wrapped_raw = html! { div { (raw.render()) } };
assert_eq!(wrapped_raw.into_string(), "<div><i>x</i></div>");
// With: debe incrustar el Markup tal cual.
let with = PrepareMarkup::With(html! { span.title { "ok" } });
let wrapped_with = html! { div { (with.render()) } };
assert_eq!(
wrapped_with.into_string(),
"<div><span class=\"title\">ok</span></div>"
);
}
#[pagetop::test]
async fn prepare_markup_unicode_is_preserved() {
// Texto con acentos y emojis debe conservarse (salvo el escape HTML de signos).
let esc = PrepareMarkup::Escaped("Hello, tomorrow coffee ☕ & donuts!".into());
assert_eq!(
esc.render().as_str(),
"Hello, tomorrow coffee ☕ &amp; donuts!"
);
// Raw debe pasar íntegro.
let raw = PrepareMarkup::Raw("Title — section © 2025".into());
assert_eq!(raw.render().as_str(), "Title — section © 2025");
}
#[pagetop::test]
async fn prepare_markup_is_empty_semantics() {
assert!(PrepareMarkup::None.is_empty()); assert!(PrepareMarkup::None.is_empty());
assert!(PrepareMarkup::Escaped(String::new()).is_empty()); assert!(PrepareMarkup::Text(String::from("")).is_empty());
assert!(PrepareMarkup::Escaped("".to_string()).is_empty()); assert!(!PrepareMarkup::Text(String::from("x")).is_empty());
assert!(!PrepareMarkup::Escaped("x".to_string()).is_empty());
assert!(PrepareMarkup::Raw(String::new()).is_empty()); assert!(PrepareMarkup::Escaped(String::new()).is_empty());
assert!(PrepareMarkup::Raw("".to_string()).is_empty()); assert!(!PrepareMarkup::Escaped("a".into()).is_empty());
assert!(!PrepareMarkup::Raw("a".into()).is_empty());
assert!(PrepareMarkup::With(html! {}).is_empty()); assert!(PrepareMarkup::With(html! {}).is_empty());
assert!(!PrepareMarkup::With(html! { span { "!" } }).is_empty()); assert!(!PrepareMarkup::With(html! { span { "!" } }).is_empty());
// Ojo: espacios NO deberían considerarse vacíos (comportamiento actual).
assert!(!PrepareMarkup::Escaped(" ".into()).is_empty());
assert!(!PrepareMarkup::Raw(" ".into()).is_empty());
}
#[pagetop::test]
async fn prepare_markup_equivalence_between_render_and_inline_in_html_macro() {
let cases = [
PrepareMarkup::None,
PrepareMarkup::Escaped("<b>x</b>".into()),
PrepareMarkup::Raw("<b>x</b>".into()),
PrepareMarkup::With(html! { b { "x" } }),
];
for pm in cases {
let rendered = pm.render();
let in_macro = html! { (rendered) }.into_string();
assert_eq!(
rendered.as_str(),
in_macro,
"The output of Render and (pm) inside html! must match"
);
}
} }

View file

@ -13,7 +13,7 @@ async fn translation_without_args() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let l10n = L10n::l("test-hello-world"); let l10n = L10n::l("test-hello-world");
let translation = l10n.lookup(&LangMatch::resolve("es-ES")); let translation = l10n.using(&LangMatch::resolve("es-ES"));
assert_eq!(translation, Some("¡Hola mundo!".to_string())); assert_eq!(translation, Some("¡Hola mundo!".to_string()));
} }
@ -22,7 +22,7 @@ async fn translation_with_args() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let l10n = L10n::l("test-hello-user").with_arg("userName", "Manuel"); let l10n = L10n::l("test-hello-user").with_arg("userName", "Manuel");
let translation = l10n.lookup(&LangMatch::resolve("es-ES")); let translation = l10n.using(&LangMatch::resolve("es-ES"));
assert_eq!(translation, Some("¡Hola, Manuel!".to_string())); assert_eq!(translation, Some("¡Hola, Manuel!".to_string()));
} }
@ -35,7 +35,7 @@ async fn translation_with_plural_and_select() {
("photoCount", "3"), ("photoCount", "3"),
("userGender", "male"), ("userGender", "male"),
]); ]);
let translation = l10n.lookup(&LangMatch::resolve("es-ES")).unwrap(); let translation = l10n.using(&LangMatch::resolve("es-ES")).unwrap();
assert!(translation.contains("añadido 3 nuevas fotos de él")); assert!(translation.contains("añadido 3 nuevas fotos de él"));
} }
@ -44,7 +44,7 @@ async fn check_fallback_language() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let l10n = L10n::l("test-hello-world"); let l10n = L10n::l("test-hello-world");
let translation = l10n.lookup(&LangMatch::resolve("xx-YY")); // Retrocede a "en-US". let translation = l10n.using(&LangMatch::resolve("xx-YY")); // Retrocede a "en-US".
assert_eq!(translation, Some("Hello world!".to_string())); assert_eq!(translation, Some("Hello world!".to_string()));
} }
@ -53,6 +53,6 @@ async fn check_unknown_key() {
let _app = service::test::init_service(Application::new().test()).await; let _app = service::test::init_service(Application::new().test()).await;
let l10n = L10n::l("non-existent-key"); let l10n = L10n::l("non-existent-key");
let translation = l10n.lookup(&LangMatch::resolve("en-US")); let translation = l10n.using(&LangMatch::resolve("en-US"));
assert_eq!(translation, None); assert_eq!(translation, None);
} }