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

7
Cargo.lock generated
View file

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

View file

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

View file

@ -14,8 +14,8 @@
<br>
</div>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo:
@ -24,14 +24,14 @@ según las necesidades de cada proyecto, incluyendo:
* **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs
de PageTop o de terceros.
de `PageTop` o de terceros.
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad.
# ⚡️ Guía rápida
La aplicación más sencilla de PageTop se ve así:
La aplicación más sencilla de `PageTop` se ve así:
```rust,no_run
use pagetop::prelude::*;
@ -42,10 +42,10 @@ async fn main() -> std::io::Result<()> {
}
```
Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página
de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
Para personalizar el servicio, se puede crear una extensión de `PageTop` de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
@ -59,8 +59,8 @@ impl Extension for HelloWorld {
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
Page::new(Some(request))
.with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
@ -86,15 +86,15 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
* **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**,
es la librería que permite incluir archivos estáticos en el ejecutable de las aplicaciones
PageTop para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo de
compilación.
`PageTop` para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo
de compilación.
* **[pagetop-build](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-build)**,
prepara los archivos estáticos o archivos SCSS compilados para incluirlos en el binario de las
aplicaciones PageTop durante la compilación de los ejecutables.
aplicaciones `PageTop` durante la compilación de los ejecutables.
* **[pagetop-macros](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-macros)**,
proporciona una colección de macros que mejoran la experiencia de desarrollo con PageTop.
proporciona una colección de macros que mejoran la experiencia de desarrollo con `PageTop`.
# 🧪 Pruebas
@ -116,7 +116,7 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
# 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.

View file

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

View file

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

View file

@ -113,7 +113,7 @@ impl Extension for MyExtension {
# 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.

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
[Jane Doe](https://crates.io/users/jane-doe), llamada `AutoDefault`. Estas macros eliminan la
necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de
cada proyecto PageTop.
cada proyecto `PageTop`.
# 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.

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

View file

@ -16,7 +16,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general
Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones `PageTop` para
servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación.
@ -28,13 +28,13 @@ Para ello, adapta el código de los *crates* [static-files](https://crates.io/cr
[4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por
[Alexander Korolev](https://crates.io/users/kilork).
Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar
Estas implementaciones se integran en `PageTop` para evitar que cada proyecto tenga que declarar
`static-files` manualmente como dependencia en su `Cargo.toml`.
# 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.

View file

@ -17,7 +17,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general
Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones `PageTop` para
servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación.
@ -29,7 +29,7 @@ Para ello, adapta el código de los *crates* [static-files](https://crates.io/cr
[4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por
[Alexander Korolev](https://crates.io/users/kilork).
Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar
Estas implementaciones se integran en `PageTop` para evitar que cada proyecto tenga que declarar
`static-files` manualmente como dependencia en su `Cargo.toml`.
*/

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;
use crate::core::{extension, extension::ExtensionRef};
use crate::html::Markup;
use crate::response::page::{ErrorPage, ResultPage};
use crate::service::HttpRequest;
use crate::{global, locale, service, trace};
use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle};
@ -17,7 +14,7 @@ use substring::Substring;
use std::io::Error;
use std::sync::LazyLock;
/// Punto de entrada de una aplicación PageTop.
/// Punto de entrada de una aplicación `PageTop`.
///
/// No almacena datos, **encapsula** el inicio completo de configuración y puesta en marcha. Para
/// instanciarla se puede usar [`new()`](Application::new) o [`prepare()`](Application::prepare).
@ -84,7 +81,7 @@ impl Application {
if let Some((Width(term_width), _)) = terminal_size() {
if term_width >= 80 {
let maxlen: usize = ((term_width / 10) - 2).into();
let mut app = app_name.substring(0, maxlen).to_string();
let mut app = app_name.substring(0, maxlen).to_owned();
if app_name.len() > maxlen {
app = format!("{app}...");
}
@ -173,12 +170,6 @@ impl Application {
InitError = (),
>,
> {
service::App::new()
.configure(extension::all::configure_services)
.default_service(service::web::route().to(service_not_found))
service::App::new().configure(extension::all::configure_services)
}
}
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::*;

View file

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

View file

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

View file

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

View file

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

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::*;
///
/// 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! {
/// h1 { "Hello, " (user) }
/// }
@ -44,13 +44,11 @@ impl Component for Html {
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With(self.html(cx))
PrepareMarkup::With((self.0)(cx))
}
}
impl Html {
// Html BUILDER ********************************************************************************
/// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
///
/// El método [`prepare_component()`](crate::core::component::Component::prepare_component)
@ -68,24 +66,11 @@ impl Html {
/// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando
/// [`prepare_component()`](crate::core::component::Component::prepare_component) invoque esta
/// instancia. La nueva función también recibe una referencia al contexto ([`Context`]).
#[builder_fn]
pub fn with_fn<F>(mut self, f: F) -> Self
pub fn alter_html<F>(&mut self, f: F) -> &mut Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
self.0 = Box::new(f);
self
}
// Html GETTERS ********************************************************************************
/// Aplica la función interna de renderizado con el [`Context`] proporcionado.
///
/// Normalmente no se invoca manualmente, ya que el proceso de renderizado de los componentes lo
/// invoca automáticamente durante la construcción de la página. Puede usarse, no obstante, para
/// sobrescribir [`prepare_component()`](crate::core::component::Component::prepare_component)
/// y alterar el comportamiento del componente.
pub fn html(&self, cx: &mut Context) -> Markup {
(self.0)(cx)
}
}

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;
pub use welcome::Welcome;

View file

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

View file

@ -1,4 +1,4 @@
//! Temas básicos soportados por PageTop.
//! Temas básicos soportados por `PageTop`.
mod 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::*;
/// 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;
impl Extension for Basic {
@ -37,152 +12,11 @@ impl Extension for Basic {
}
impl Theme for Basic {
fn render_page_body(&self, page: &mut Page) -> Markup {
match page.layout() {
"Intro" => render_intro(page),
"PageTopIntro" => render_pagetop_intro(page),
_ => <Self as ThemePage>::render_body(self, page, self.page_regions()),
}
}
fn after_render_page_body(&self, page: &mut Page) {
let styles = match page.layout() {
"Intro" => "/css/intro.css",
"PageTopIntro" => "/css/intro.css",
_ => "/css/basic.css",
};
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from(styles)
.with_version(env!("CARGO_PKG_VERSION"))
.with_weight(-99),
));
}
}
fn render_intro(page: &mut Page) -> Markup {
let title = page.title().unwrap_or_default();
let intro = page.description().unwrap_or_default();
let intro_button_txt: L10n = page.param_or_default("intro_button_txt");
let intro_button_lnk: Option<&String> = page.param("intro_button_lnk");
html! {
body id=[page.body_id().get()] class=[page.body_classes().get()] {
header class="intro-header" {
section class="intro-header__body" {
h1 class="intro-header__title" {
span { (title) }
(intro)
}
}
aside class="intro-header__image" aria-hidden="true" {
div class="intro-header__monster" {
picture {
source
type="image/avif"
src="/img/monster-pagetop_250.avif"
srcset="/img/monster-pagetop_500.avif 1.5x";
source
type="image/webp"
src="/img/monster-pagetop_250.webp"
srcset="/img/monster-pagetop_500.webp 1.5x";
img
src="/img/monster-pagetop_250.png"
srcset="/img/monster-pagetop_500.png 1.5x"
alt="Monster PageTop";
}
}
}
}
main class="intro-content" {
section class="intro-content__body" {
@if intro_button_lnk.is_some() {
div class="intro-button" {
a
class="intro-button__link"
href=[intro_button_lnk]
target="_blank"
rel="noreferrer"
{
span {} span {} span {}
div class="intro-button__text" {
(intro_button_txt.using(page))
}
}
}
}
div class="intro-text" { (page.render_region("content")) }
}
}
footer class="intro-footer" {
section class="intro-footer__body" {
div class="intro-footer__logo" {
svg
viewBox="0 0 1614 1614"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label=[L10n::l("pagetop_logo").lookup(page)]
preserveAspectRatio="xMidYMid slice"
focusable="false"
{
path fill="rgb(255,255,255)" d="M 1573,357 L 1415,357 C 1400,357 1388,369 1388,383 L 1388,410 1335,410 1335,357 C 1335,167 1181,13 992,13 L 621,13 C 432,13 278,167 278,357 L 278,410 225,410 225,383 C 225,369 213,357 198,357 L 40,357 C 25,357 13,369 13,383 L 13,648 C 13,662 25,674 40,674 L 198,674 C 213,674 225,662 225,648 L 225,621 278,621 278,1256 C 278,1446 432,1600 621,1600 L 992,1600 C 1181,1600 1335,1446 1335,1256 L 1335,621 1388,621 1388,648 C 1388,662 1400,674 1415,674 L 1573,674 C 1588,674 1600,662 1600,648 L 1600,383 C 1600,369 1588,357 1573,357 L 1573,357 1573,357 Z M 66,410 L 172,410 172,621 66,621 66,410 66,410 Z M 1282,357 L 1282,488 C 1247,485 1213,477 1181,464 L 1196,437 C 1203,425 1199,409 1186,401 1174,394 1158,398 1150,411 L 1133,440 C 1105,423 1079,401 1056,376 L 1075,361 C 1087,352 1089,335 1079,324 1070,313 1054,311 1042,320 L 1023,335 C 1000,301 981,263 967,221 L 1011,196 C 1023,189 1028,172 1021,160 1013,147 997,143 984,150 L 953,168 C 945,136 941,102 940,66 L 992,66 C 1152,66 1282,197 1282,357 L 1282,357 1282,357 Z M 621,66 L 674,66 674,225 648,225 C 633,225 621,237 621,251 621,266 633,278 648,278 L 674,278 674,357 648,357 C 633,357 621,369 621,383 621,398 633,410 648,410 L 674,410 674,489 648,489 C 633,489 621,501 621,516 621,530 633,542 648,542 L 664,542 C 651,582 626,623 600,662 583,653 563,648 542,648 469,648 410,707 410,780 410,787 411,794 412,801 388,805 361,806 331,806 L 331,357 C 331,197 461,66 621,66 L 621,66 621,66 Z M 621,780 C 621,824 586,859 542,859 498,859 463,824 463,780 463,736 498,701 542,701 586,701 621,736 621,780 L 621,780 621,780 Z M 225,463 L 278,463 278,569 225,569 225,463 225,463 Z M 992,1547 L 621,1547 C 461,1547 331,1416 331,1256 L 331,859 C 367,859 400,858 431,851 454,888 495,912 542,912 615,912 674,853 674,780 674,747 662,718 642,695 675,645 706,594 720,542 L 780,542 C 795,542 807,530 807,516 807,501 795,489 780,489 L 727,489 727,410 780,410 C 795,410 807,398 807,383 807,369 795,357 780,357 L 727,357 727,278 780,278 C 795,278 807,266 807,251 807,237 795,225 780,225 L 727,225 727,66 887,66 C 889,111 895,155 905,196 L 869,217 C 856,224 852,240 859,253 864,261 873,266 882,266 887,266 891,265 895,263 L 921,248 C 937,291 958,331 983,367 L 938,403 C 926,412 925,429 934,440 939,447 947,450 954,450 960,450 966,448 971,444 L 1016,408 C 1043,438 1074,465 1108,485 L 1084,527 C 1076,539 1081,555 1093,563 1098,565 1102,566 1107,566 1116,566 1125,561 1129,553 L 1155,509 C 1194,527 1237,538 1282,541 L 1282,1256 C 1282,1416 1152,1547 992,1547 L 992,1547 992,1547 Z M 1335,463 L 1388,463 1388,569 1335,569 1335,463 1335,463 Z M 1441,410 L 1547,410 1547,621 1441,621 1441,410 1441,410 Z" {}
path fill="rgb(255,255,255)" d="M 1150,1018 L 463,1018 C 448,1018 436,1030 436,1044 L 436,1177 C 436,1348 545,1468 701,1468 L 912,1468 C 1068,1468 1177,1348 1177,1177 L 1177,1044 C 1177,1030 1165,1018 1150,1018 L 1150,1018 1150,1018 Z M 912,1071 L 1018,1071 1018,1124 912,1124 912,1071 912,1071 Z M 489,1071 L 542,1071 542,1124 489,1124 489,1071 489,1071 Z M 701,1415 L 700,1415 C 701,1385 704,1352 718,1343 731,1335 759,1341 795,1359 802,1363 811,1363 818,1359 854,1341 882,1335 895,1343 909,1352 912,1385 913,1415 L 912,1415 701,1415 701,1415 701,1415 Z M 1124,1177 C 1124,1296 1061,1384 966,1408 964,1365 958,1320 922,1298 894,1281 856,1283 807,1306 757,1283 719,1281 691,1298 655,1320 649,1365 647,1408 552,1384 489,1296 489,1177 L 569,1177 C 583,1177 595,1165 595,1150 L 595,1071 859,1071 859,1150 C 859,1165 871,1177 886,1177 L 1044,1177 C 1059,1177 1071,1165 1071,1150 L 1071,1071 1124,1071 1124,1177 1124,1177 1124,1177 Z" {}
path fill="rgb(255,255,255)" d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {}
}
}
div class="intro-footer__links" {
a href="https://crates.io/crates/pagetop" target="_blank" rel="noreferrer" { ("Crates.io") }
a href="https://docs.rs/pagetop" target="_blank" rel="noreferrer" { ("Docs.rs") }
a href="https://git.cillero.es/manuelcillero/pagetop" target="_blank" rel="noreferrer" { (L10n::l("intro_code").using(page)) }
em { (L10n::l("intro_have_fun").using(page)) }
}
}
}
}
}
}
fn render_pagetop_intro(page: &mut Page) -> Markup {
page.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx|
util::indoc!(r#"
try {
const resp = await fetch("https://crates.io/api/v1/crates/pagetop");
const data = await resp.json();
const date = new Date(data.versions[0].created_at);
const formatted = date.toLocaleDateString("LANGID", { year: "numeric", month: "2-digit", day: "2-digit" });
document.getElementById("intro-release").src = `https://img.shields.io/badge/Release%20date-${encodeURIComponent(formatted)}-blue?label=LABEL&style=for-the-badge`;
document.getElementById("intro-badges").style.display = "block";
} catch (e) {
console.error("Failed to fetch release date from crates.io:", e);
}
"#)
.replace("LANGID", cx.langid().to_string().as_str())
.replace("LABEL", L10n::l("intro_release_label").using(cx).as_str())
.to_string(),
)))
.alter_child_in("content", ChildOp::Prepend(Child::with(Html::with(|cx| html! {
p { (L10n::l("intro_text1").using(cx)) }
div id="intro-badges" style="display: none; margin-bottom: 1.1rem;" {
img
src="https://img.shields.io/crates/v/pagetop.svg?label=PageTop&style=for-the-badge"
alt=[L10n::l("intro_pagetop_label").lookup(cx)] {} (" ")
img
id="intro-release"
alt=[L10n::l("intro_release_label").lookup(cx)] {} (" ")
img
src=(format!(
"https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label={}&style=for-the-badge",
L10n::l("intro_license_label").lookup(cx).unwrap_or_default()
))
alt=[L10n::l("intro_license_label").lookup(cx)] {}
}
p { (L10n::l("intro_text2").using(cx)) }
}))));
render_intro(page)
}

View file

@ -3,7 +3,7 @@
//! Estos ajustes se obtienen de archivos [TOML](https://toml.io) como pares `clave = valor` que se
//! mapean a estructuras **fuertemente tipadas** y valores predefinidos.
//!
//! Siguiendo la metodología [Twelve-Factor App](https://12factor.net/config), PageTop separa el
//! Siguiendo la metodología [Twelve-Factor App](https://12factor.net/config), `PageTop` separa el
//! **código** de la **configuración**, lo que permite tener configuraciones diferentes para cada
//! despliegue, como *dev*, *staging* o *production*, sin modificar el código fuente.
//!
@ -13,14 +13,14 @@
//! Si tu aplicación necesita archivos de configuración, crea un directorio `config` en la raíz del
//! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación.
//!
//! PageTop carga en este orden, y siempre de forma opcional, los siguientes archivos TOML:
//! `PageTop` carga en este orden, y siempre de forma opcional, los siguientes archivos TOML:
//!
//! 1. **config/common.toml**, para ajustes comunes a todos los entornos. Este enfoque simplifica el
//! mantenimiento al centralizar los valores de configuración comunes.
//!
//! 2. **config/{rm}.toml**, donde `{rm}` es el valor de la variable de entorno `PAGETOP_RUN_MODE`:
//!
//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y PageTop intentará
//! * Si `PAGETOP_RUN_MODE` no está definida, se asume el valor `default`, y `PageTop` intentará
//! cargar *config/default.toml* si el archivo existe.
//!
//! * Útil para definir configuraciones específicas por entorno, garantizando que cada uno (p.ej.

View file

@ -117,7 +117,7 @@ impl TypeInfo {
///
/// Este *trait* se implementa automáticamente para **todos** los tipos que implementen [`Any`], de
/// modo que basta con traer [`AnyInfo`] al ámbito (`use crate::AnyInfo;`) para disponer de estos
/// métodos adicionales, o usar el [`prelude`](crate::prelude) de PageTop.
/// métodos adicionales, o usar el [`prelude`](crate::prelude) de `PageTop`.
///
/// # Ejemplo
///

View file

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

View file

@ -1,6 +1,6 @@
use crate::base::action;
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.
///
@ -11,7 +11,7 @@ pub trait ComponentRender {
fn render(&mut self, cx: &mut Context) -> Markup;
}
/// Interfaz común que debe implementar un componente renderizable en PageTop.
/// Interfaz común que debe implementar un componente renderizable en `PageTop`.
///
/// Se recomienda que los componentes deriven [`AutoDefault`](crate::AutoDefault). También deben
/// implementar explícitamente el método [`new()`](Self::new) y pueden sobrescribir los otros
@ -29,14 +29,14 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync {
TypeInfo::ShortName.of::<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`).
fn description(&self) -> Option<String> {
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
/// tiene ningún identificador (`None`).
@ -51,17 +51,12 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync {
#[allow(unused_variables)]
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
/// durante el proceso de construcción del documento. Puede sobrescribirse para generar
/// dinámicamente el contenido HTML con acceso al contexto de renderizado.
///
/// Este método debe ser capaz de preparar el renderizado del componente con los métodos del
/// propio componente y el contexto proporcionado, no debería hacerlo accediendo directamente a
/// los campos de la estructura del componente. Es una forma de garantizar que los programadores
/// podrán sobrescribir este método sin preocuparse por los detalles internos del componente.
///
/// Por defecto, devuelve [`PrepareMarkup::None`].
#[allow(unused_variables)]
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {

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.
//!
//! Cada funcionalidad adicional que quiera incorporarse a una aplicación PageTop se debe modelar
//! Cada funcionalidad adicional que quiera incorporarse a una aplicación `PageTop` se debe modelar
//! como una **extensión**. Todas comparten la misma interfaz declarada en [`Extension`].
mod definition;

View file

@ -10,7 +10,7 @@ use crate::{actions_boxed, service};
/// cualquier hilo de la ejecución sin necesidad de sincronización adicional.
pub type ExtensionRef = &'static dyn Extension;
/// Interfaz común que debe implementar cualquier extensión de PageTop.
/// Interfaz común que debe implementar cualquier extensión de `PageTop`.
///
/// Este *trait* es fácil de implementar, basta con declarar una estructura de tamaño cero para la
/// extensión y sobreescribir los métodos que sea necesario.
@ -26,7 +26,7 @@ pub type ExtensionRef = &'static dyn Extension;
/// }
/// ```
pub trait Extension: AnyInfo + Send + Sync {
/// Nombre 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
/// extensión.
@ -34,15 +34,18 @@ pub trait Extension: AnyInfo + Send + Sync {
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 {
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
/// la extensión no es un tema, este método devuelve `None` por defecto.
/// Si la extensión no es un tema, este método devuelve `None` por defecto.
///
/// En caso contrario, este método debe implementarse para devolver una referencia de sí mismo
/// como tema. Por ejemplo:
///
/// ```rust
/// use pagetop::prelude::*;
@ -63,7 +66,7 @@ pub trait Extension: AnyInfo + Send + Sync {
/// Otras extensiones que deben habilitarse **antes** de esta.
///
/// PageTop las resolverá automáticamente respetando el orden durante el arranque de la
/// `PageTop` las resolverá automáticamente respetando el orden durante el arranque de la
/// aplicación.
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![]
@ -78,7 +81,7 @@ pub trait Extension: AnyInfo + Send + Sync {
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
/// aceptar cualquier petición HTTP.
@ -101,8 +104,8 @@ pub trait Extension: AnyInfo + Send + Sync {
#[allow(unused_variables)]
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {}
/// Permite crear extensiones para deshabilitar y desinstalar recursos de otras de versiones
/// anteriores de la aplicación.
/// Permite crear extensiones para deshabilitar y desinstalar los recursos de otras extensiones
/// utilizadas en versiones anteriores de la aplicación.
///
/// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones.
fn drop_extensions(&self) -> Vec<ExtensionRef> {

View file

@ -1,24 +1,26 @@
//! API para añadir y gestionar nuevos temas.
//!
//! En PageTop un tema es la *piel* de la aplicación, decide cómo se muestra cada documento HTML,
//! En `PageTop` un tema es la *piel* de la aplicación, decide cómo se muestra cada documento HTML,
//! especialmente las páginas de contenido ([`Page`](crate::response::page::Page)), sin alterar la
//! lógica interna de sus componentes.
//!
//! Un tema **declara las regiones** (*cabecera*, *barra lateral*, *pie*, etc.) que estarán
//! disponibles para colocar contenido. Los temas son responsables últimos de los estilos,
//! tipografías, espaciados y cualquier otro detalle visual o de comportamiento (comoanimaciones,
//! scripts de interfaz, etc.).
//! *scripts* de interfaz, etc.).
//!
//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension); por
//! lo que se instancian, declaran sus dependencias y se inician igual que el resto de extensiones;
//! pero serán temas si además implementan [`theme()`](crate::core::extension::Extension::theme) y
//! [`Theme`].
//! Es una extensión más (implementando [`Extension`](crate::core::extension::Extension)). Se
//! instala, activa y declara dependencias igual que el resto de extensiones; y se señala a sí misma
//! como tema (implementando [`theme()`](crate::core::extension::Extension::theme) y [`Theme`]).
mod definition;
pub use definition::{Theme, ThemePage, ThemeRef};
pub use definition::{Theme, ThemeRef};
mod regions;
pub(crate) use regions::{ChildrenInRegions, REGION_CONTENT};
pub use regions::{InRegion, Region};
pub(crate) use regions::ChildrenInRegions;
pub use regions::InRegion;
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::theme::Region;
use crate::core::theme::CONTENT_REGION_NAME;
use crate::global;
use crate::html::{html, Markup};
use crate::locale::L10n;
use crate::response::page::Page;
use std::sync::LazyLock;
/// Referencia estática a un tema.
/// Representa una referencia a un tema.
///
/// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que
/// implementen [`Theme`] y, a su vez, [`Extension`].
/// Los temas son también extensiones. Por tanto se deben definir igual, es decir, como instancias
/// estáticas globales que implementan [`Theme`], pero también [`Extension`].
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
/// automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente no requiere
/// implementación explícita.
/// Un tema implementará [`Theme`] y los métodos que sean necesarios de [`Extension`], aunque el
/// único obligatorio es [`theme()`](Extension::theme).
///
/// Si un tema **sobrescribe** [`render_page_head()`](Theme::render_page_head) o
/// [`render_page_body()`](Theme::render_page_body), se puede volver al comportamiento por defecto
/// cuando se necesite usando FQS (*Fully Qualified Syntax*):
/// ```rust
/// use pagetop::prelude::*;
///
/// - `<Self as ThemePage>::render_body(self, page, self.page_regions())`
/// - `<Self as ThemePage>::render_head(self, page)`
pub trait ThemePage {
/// Renderiza el contenido del `<body>` de la página.
///
/// Recorre `regions` en el **orden declarado** y, para cada región con contenido, genera un
/// contenedor con `role="region"` y un `aria-label` localizado. Se asume que cada identificador
/// de región es **único** dentro de la página.
fn render_body(&self, page: &mut Page, regions: &[(Region, L10n)]) -> Markup {
/// pub struct MyTheme;
///
/// impl Extension for MyTheme {
/// fn name(&self) -> L10n { L10n::n("My theme") }
/// fn description(&self) -> L10n { L10n::n("Un tema personal") }
///
/// fn theme(&self) -> Option<ThemeRef> {
/// Some(&Self)
/// }
/// }
///
/// impl Theme for MyTheme {}
/// ```
pub trait Theme: Extension + Send + Sync {
fn regions(&self) -> Vec<(&'static str, L10n)> {
vec![(CONTENT_REGION_NAME, L10n::l("content"))]
}
#[allow(unused_variables)]
fn before_render_page_body(&self, page: &mut Page) {}
fn render_page_body(&self, page: &mut Page) -> Markup {
html! {
body id=[page.body_id().get()] class=[page.body_classes().get()] {
@for (region, region_label) in regions {
@let output = page.render_region(region.key());
@for (region_name, _) in self.regions() {
@let output = page.render_region(region_name);
@if !output.is_empty() {
@let region_name = region.name();
div
id=(region_name)
class={ "region region--" (region_name) }
role="region"
aria-label=[region_label.lookup(page)]
{
div id=(region_name) class={ "region-container region-" (region_name) } {
(output)
}
}
@ -52,12 +55,10 @@ pub trait ThemePage {
}
}
/// Renderiza el contenido del `<head>` de la página.
///
/// Por defecto incluye las etiquetas básicas (`charset`, `title`, `description`, `viewport`,
/// `X-UA-Compatible`), los metadatos (`name/content`) y propiedades (`property/content`),
/// además de los recursos CSS/JS de la página.
fn render_head(&self, page: &mut Page) -> Markup {
#[allow(unused_variables)]
fn after_render_page_body(&self, page: &mut Page) {}
fn render_page_head(&self, page: &mut Page) -> Markup {
let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no";
html! {
head {
@ -87,115 +88,12 @@ pub trait ThemePage {
}
}
}
}
/// Interfaz común que debe implementar cualquier tema de PageTop.
///
/// Un tema implementa [`Theme`] y los métodos necesarios de [`Extension`]. El único método
/// **obligatorio** de `Extension` para un tema es [`theme()`](Extension::theme).
///
/// ```rust
/// use pagetop::prelude::*;
///
/// pub struct MyTheme;
///
/// impl Extension for MyTheme {
/// fn name(&self) -> L10n {
/// L10n::n("My theme")
/// }
///
/// fn description(&self) -> L10n {
/// L10n::n("A personal theme")
/// }
///
/// fn theme(&self) -> Option<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"))]
fn error403(&self, _page: &mut Page) -> Markup {
html! { div { h1 { ("FORBIDDEN ACCESS") } } }
}
/// Declaración ordenada de las regiones disponibles en la página.
///
/// Devuelve una **lista estática** de pares `(Region, L10n)` que se usará para renderizar todas
/// las regiones que componen una página en el orden indicado .
///
/// Si un tema necesita un conjunto distinto de regiones, se puede **sobrescribir** este método
/// con los siguientes requisitos y recomendaciones:
///
/// - Los identificadores deben ser **estables** (p. ej. `"sidebar-left"`, `"content"`).
/// - La región `"content"` es **obligatoria**. Se puede usar [`Region::default()`] para
/// declararla.
/// - La etiqueta `L10n` se evalúa con el idioma activo de la página.
///
/// Por defecto devuelve:
///
/// - `"header"`: cabecera.
/// - `"content"`: contenido principal (**obligatoria**).
/// - `"footer"`: pie.
fn page_regions(&self) -> &'static [(Region, L10n)] {
static REGIONS: LazyLock<[(Region, L10n); 3]> = LazyLock::new(|| {
[
(Region::declare("header"), L10n::l("region_header")),
(Region::default(), L10n::l("region_content")),
(Region::declare("footer"), L10n::l("region_footer")),
]
});
&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)) } } }
fn error404(&self, _page: &mut Page) -> Markup {
html! { div { h1 { ("RESOURCE NOT FOUND") } } }
}
}
/// 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::theme::ThemeRef;
use crate::core::theme::{ThemeRef, CONTENT_REGION_NAME};
use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock;
@ -7,81 +7,25 @@ use parking_lot::RwLock;
use std::collections::HashMap;
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>>> =
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>> =
LazyLock::new(|| RwLock::new(ChildrenInRegions::default()));
/// Nombre de la región de contenido por defecto (`"content"`).
pub const REGION_CONTENT: &str = "content";
/// Identificador de una región de página.
///
/// Incluye una **clave estática** ([`key()`](Self::key)) que identifica la región en el tema, y un
/// **nombre normalizado** ([`name()`](Self::name)) en minúsculas para su uso en atributos HTML
/// (p.ej., clases `region__{name}`).
///
/// Se utiliza para declarar las regiones que componen una página en un tema (ver
/// [`page_regions()`](crate::core::theme::Theme::page_regions)).
pub struct Region {
key: &'static str,
name: String,
}
impl Default for Region {
#[inline]
fn default() -> Self {
Self {
key: REGION_CONTENT,
name: REGION_CONTENT.to_string(),
}
}
}
impl Region {
/// Declara una región a partir de su clave estática.
///
/// Genera además un nombre normalizado de la clave, eliminando espacios iniciales y finales,
/// convirtiendo a minúsculas y sustituyendo los espacios intermedios por guiones (`-`).
///
/// Esta clave se usará para añadir componentes a la región; por ello se recomiendan nombres
/// sencillos, limitando los caracteres a `[a-z0-9-]` (p.ej., `"sidebar"` o `"main-menu"`), cuyo
/// nombre normalizado coincidirá con la clave.
#[inline]
pub fn declare(key: &'static str) -> Self {
Self {
key,
name: key.trim().to_ascii_lowercase().replace(' ', "-"),
}
}
/// Devuelve la clave estática asignada a la región.
#[inline]
pub fn key(&self) -> &'static str {
self.key
}
/// Devuelve el nombre normalizado de la región (para atributos y búsquedas).
#[inline]
pub fn name(&self) -> &str {
&self.name
}
}
// Contenedor interno de componentes agrupados por región.
// Estructura interna para mantener los componentes de una región.
#[derive(AutoDefault)]
pub struct ChildrenInRegions(HashMap<&'static str, Children>);
impl ChildrenInRegions {
pub fn with(region_name: &'static str, child: Child) -> Self {
ChildrenInRegions::default().with_child_in(region_name, ChildOp::Add(child))
ChildrenInRegions::default().with_child_in_region(region_name, ChildOp::Add(child))
}
#[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) {
region.alter_child(op);
} 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
/// disponibles durante toda la ejecución.
/// Dada una región, según la variante seleccionada, se le podrán añadir ([`add()`](Self::add))
/// componentes que se mantendrán durante la ejecución de la aplicación.
///
/// Estos componentes se renderizarán automáticamente al procesar los documentos HTML que incluyen
/// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)).
/// Estas estructuras de componentes se renderizarán automáticamente al procesar los documentos HTML
/// que las usan, como las páginas de contenido ([`Page`](crate::response::page::Page)), por
/// ejemplo.
pub enum InRegion {
/// Región de contenido por defecto.
/// Representa la región por defecto en la que se pueden añadir componentes.
Content,
/// Región identificada por el nombre proporcionado.
/// Representa la región con el nombre del argumento.
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),
}
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
///
@ -143,17 +88,17 @@ impl InRegion {
InRegion::Content => {
COMMON_REGIONS
.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
.write()
.alter_child_in(region_name, ChildOp::Add(child));
.alter_child_in_region(name, ChildOp::Add(child));
}
InRegion::OfTheme(region_name, theme_ref) => {
let mut regions = THEME_REGIONS.write();
if let Some(r) = regions.get_mut(&theme_ref.type_id()) {
r.alter_child_in(region_name, ChildOp::Add(child));
r.alter_child_in_region(region_name, ChildOp::Add(child));
} else {
regions.insert(
theme_ref.type_id(),

View file

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

View file

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

View file

@ -2,55 +2,25 @@ pub mod favicon;
pub mod javascript;
pub mod stylesheet;
use crate::html::{html, Context, Markup};
use crate::html::{html, Markup, Render};
use crate::{AutoDefault, Weight};
/// Representación genérica de un script [`JavaScript`](crate::html::JavaScript) o una hoja de
/// estilos [`StyleSheet`](crate::html::StyleSheet).
///
/// Estos recursos se incluyen en los conjuntos de recursos ([`Assets`]) que suelen renderizarse en
/// un documento HTML.
///
/// Cada recurso se identifica por un **nombre único** ([`Asset::name()`]), usado como clave; y un
/// **peso** ([`Asset::weight()`]), que determina su orden relativo de renderizado.
pub trait Asset {
/// Devuelve el nombre del recurso, utilizado como clave única.
pub trait AssetsTrait: Render {
// Devuelve el nombre del recurso, utilizado como clave única.
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;
/// Renderiza el recurso en el contexto proporcionado.
fn render(&self, cx: &mut Context) -> Markup;
}
/// Gestión común para conjuntos de recursos como [`JavaScript`](crate::html::JavaScript) y
/// [`StyleSheet`](crate::html::StyleSheet).
///
/// Se emplea normalmente para agrupar, administrar y renderizar los recursos de un documento HTML.
/// Cada recurso se identifica por un nombre único ([`Asset::name()`]) y tiene asociado un peso
/// ([`Asset::weight()`]) que determina su orden de renderizado.
///
/// Durante el renderizado, los recursos se procesan en orden ascendente de peso. En caso de
/// igualdad, se respeta el orden de inserción.
#[derive(AutoDefault)]
pub struct Assets<T>(Vec<T>);
pub(crate) struct Assets<T>(Vec<T>);
impl<T: Asset> 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.
impl<T: AssetsTrait> Assets<T> {
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 {
match self.0.iter().position(|x| x.name() == asset.name()) {
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 {
if let Some(index) = self.0.iter().position(|x| x.name() == name.as_ref()) {
self.0.remove(index);
@ -80,13 +47,16 @@ impl<T: Asset> Assets<T> {
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<_>>();
assets.sort_by_key(|a| a.weight());
html! {
@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;
/// 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>,
) -> Self {
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"),
".gif" => Some("image/gif"),
".ico" => Some("image/x-icon"),
@ -151,12 +151,10 @@ impl Favicon {
});
self
}
}
/// Renderiza el **Favicon** completo con todas las etiquetas declaradas.
///
/// El parámetro `Context` se acepta por coherencia con el resto de *assets*, aunque en este
/// caso es ignorado.
pub fn render(&self, _cx: &mut Context) -> Markup {
impl Render for Favicon {
fn render(&self) -> Markup {
html! {
@for item in &self.0 {
(item)

View file

@ -1,45 +1,35 @@
use crate::html::assets::Asset;
use crate::html::{html, Context, Markup, PreEscaped};
use crate::html::assets::AssetsTrait;
use crate::html::{html, Markup, Render};
use crate::{join, join_pair, AutoDefault, Weight};
// Define el origen del recurso JavaScript y cómo debe cargarse en el navegador.
//
// Los distintos modos de carga permiten optimizar el rendimiento y controlar el comportamiento del
// script 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="...">`.
// - [`Defer`] Igual que [`From`], pero con el atributo `defer`, descarga en paralelo y se
// ejecuta tras el análisis del documento HTML, respetando el orden de
// 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>`.
// - [`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`.
// - [`From`] Carga el script de forma estándar con la etiqueta `<script src="...">`.
// - [`Defer`] Igual que [`From`], pero con el atributo `defer`.
// - [`Async`] Igual que [`From`], pero con el atributo `async`.
// - [`Inline`] Inserta el código directamente en la etiqueta `<script>`.
// - [`OnLoad`] Inserta el código JavaScript y lo ejecuta tras el evento `DOMContentLoaded`.
#[derive(AutoDefault)]
enum Source {
#[default]
From(String),
Defer(String),
Async(String),
// `name`, `closure(Context) -> String`.
Inline(String, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
// `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>),
Inline(String, String),
OnLoad(String, String),
}
/// 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
/// en el documento.
///
/// > **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).
///
/// # Ejemplo
@ -47,37 +37,23 @@ enum Source {
/// ```rust
/// 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")
/// .with_version("1.2.3")
/// .with_weight(-10);
///
/// // 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]');
/// for (const el of tooltips) {
/// 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]
#[derive(AutoDefault)]
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.
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
/// tras analizar completamente el documento HTML, **respetando el orden** de inserción.
/// Crea un **script externo** con el atributo `defer`, que se carga en segundo plano y se
/// ejecuta tras analizar completamente el documento HTML.
///
/// Equivale a `<script src="..." defer>`. Suele ser la opción recomendada para scripts no
/// críticos.
/// Equivale a `<script src="..." defer>`. Útil para mantener el orden de ejecución y evitar
/// bloquear el análisis del documento HTML.
pub fn defer(path: impl Into<String>) -> Self {
JavaScript {
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
/// tan pronto como esté disponible.
/// Crea un **script externo** con el atributo `async`, que se carga y ejecuta de forma
/// 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 {
JavaScript {
source: Source::Async(path.into()),
@ -120,68 +97,37 @@ impl JavaScript {
/// Crea un **script embebido** directamente en el documento HTML.
///
/// Equivale a `<script>...</script>`. El parámetro `name` se usa como identificador interno del
/// script.
///
/// 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,
{
/// *script*.
pub fn inline(name: impl Into<String>, script: impl Into<String>) -> Self {
JavaScript {
source: Source::Inline(name.into(), Box::new(f)),
source: Source::Inline(name.into(), script.into()),
..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
/// ejecuta tras analizar el documento HTML, **no** espera imágenes ni otros recursos externos.
/// Útil para inicializaciones que no dependen de `await`. El parámetro `name` se usa como
/// identificador interno del script.
///
/// Los scripts con `defer` se ejecutan antes de `DOMContentLoaded`.
///
/// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
pub fn on_load<F>(name: impl Into<String>, f: F) -> Self
where
F: Fn(&mut Context) -> String + Send + Sync + 'static,
{
/// El código se envuelve automáticamente en un `addEventListener('DOMContentLoaded', ...)`. El
/// parámetro `name` se usa como identificador interno del *script*.
pub fn on_load(name: impl Into<String>, script: impl Into<String>) -> Self {
JavaScript {
source: Source::OnLoad(name.into(), Box::new(f)),
..Default::default()
}
}
/// Crea un **script embebido** con un **manejador asíncrono**.
///
/// El código se envuelve en un `addEventListener('DOMContentLoaded',async()=>{...})`, que
/// emplea una función `async` para que el cuerpo devuelto por la función *closure* pueda usar
/// `await`. Ideal para hidratar la interfaz, cargar módulos dinámicos o realizar lecturas
/// iniciales.
///
/// La función *closure* recibirá el [`Context`] por si se necesita durante el renderizado.
pub fn on_load_async<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)),
source: Source::OnLoad(name.into(), script.into()),
..Default::default()
}
}
// JavaScript BUILDER **************************************************************************
/// Asocia una **versión** al recurso (usada para control de la caché del navegador).
/// Asocia una versión al recurso (usada para control de la caché del navegador).
///
/// Si `version` está vacío, **no** se añade ningún parámetro a la URL.
/// Si `version` está vacío, no se añade ningún parámetro a la URL.
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = version.into();
self
}
/// Modifica el **peso** del recurso.
/// Modifica el peso del recurso.
///
/// Los recursos se renderizan de menor a mayor peso. Por defecto es `0`, que respeta el orden
/// de creación.
@ -191,10 +137,8 @@ impl JavaScript {
}
}
impl Asset for JavaScript {
/// Devuelve el nombre del recurso, utilizado como clave única.
///
/// Para scripts externos es la ruta del recurso; para scripts embebidos, un identificador.
impl AssetsTrait for JavaScript {
// Para *scripts* externos es la ruta; para *scripts* embebidos, un identificador.
fn name(&self) -> &str {
match &self.source {
Source::From(path) => path,
@ -202,15 +146,16 @@ impl Asset for JavaScript {
Source::Async(path) => path,
Source::Inline(name, _) => name,
Source::OnLoad(name, _) => name,
Source::OnLoadAsync(name, _) => name,
}
}
fn weight(&self) -> Weight {
self.weight
}
}
fn render(&self, cx: &mut Context) -> Markup {
impl Render for JavaScript {
fn render(&self) -> Markup {
match &self.source {
Source::From(path) => html! {
script src=(join_pair!(path, "?v=", self.version.as_str())) {};
@ -221,15 +166,12 @@ impl Asset for JavaScript {
Source::Async(path) => html! {
script src=(join_pair!(path, "?v=", self.version.as_str())) async {};
},
Source::Inline(_, f) => html! {
script { (PreEscaped((f)(cx))) };
Source::Inline(_, code) => html! {
script { (code) };
},
Source::OnLoad(_, f) => html! { script { (PreEscaped(join!(
"document.addEventListener(\"DOMContentLoaded\",function(){", (f)(cx), "});"
))) } },
Source::OnLoadAsync(_, f) => html! { script { (PreEscaped(join!(
"document.addEventListener(\"DOMContentLoaded\",async()=>{", (f)(cx), "});"
))) } },
Source::OnLoad(_, code) => html! { (join!(
"document.addEventListener('DOMContentLoaded',function(){", code, "});"
)) },
}
}
}

View file

@ -1,5 +1,5 @@
use crate::html::assets::Asset;
use crate::html::{html, Context, Markup, PreEscaped};
use crate::html::assets::AssetsTrait;
use crate::html::{html, Markup, PreEscaped, Render};
use crate::{join_pair, AutoDefault, Weight};
// 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 {
#[default]
From(String),
// `name`, `closure(Context) -> String`.
Inline(String, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
Inline(String, String),
}
/// Define el medio objetivo para la hoja de estilos.
@ -35,7 +34,7 @@ pub enum TargetMedia {
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]
impl TargetMedia {
fn as_str_opt(&self) -> Option<&str> {
@ -70,12 +69,12 @@ impl TargetMedia {
/// .with_weight(-10);
///
/// // Crea una hoja de estilos embebida en el documento HTML.
/// let embedded = StyleSheet::inline("custom_theme", |_| r#"
/// let embedded = StyleSheet::inline("custom_theme", r#"
/// body {
/// background-color: #f5f5f5;
/// font-family: 'Segoe UI', sans-serif;
/// }
/// "#.to_string());
/// "#);
/// ```
#[rustfmt::skip]
#[derive(AutoDefault)]
@ -101,14 +100,9 @@ impl StyleSheet {
///
/// Equivale a `<style>...</style>`. El parámetro `name` se usa como identificador interno del
/// recurso.
///
/// 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,
{
pub fn inline(name: impl Into<String>, styles: impl Into<String>) -> Self {
StyleSheet {
source: Source::Inline(name.into(), Box::new(f)),
source: Source::Inline(name.into(), styles.into()),
..Default::default()
}
}
@ -139,19 +133,17 @@ impl StyleSheet {
/// Según el argumento `media`:
///
/// - `TargetMedia::Default` - Se aplica en todos los casos (medio por defecto).
/// - `TargetMedia::Print` - Se aplica cuando el documento se imprime.
/// - `TargetMedia::Screen` - Se aplica en pantallas.
/// - `TargetMedia::Speech` - Se aplica en dispositivos que convierten el texto a voz.
/// - `TargetMedia::Print` - Se aplican cuando el documento se imprime.
/// - `TargetMedia::Screen` - Se aplican en pantallas.
/// - `TargetMedia::Speech` - Se aplican en dispositivos que convierten el texto a voz.
pub fn for_media(mut self, media: TargetMedia) -> Self {
self.media = media;
self
}
}
impl Asset for StyleSheet {
/// Devuelve el nombre del recurso, utilizado como clave única.
///
/// Para hojas de estilos externas es la ruta del recurso; para las embebidas, un identificador.
impl AssetsTrait for StyleSheet {
// Para hojas de estilos externas es la ruta; para las embebidas, un identificador.
fn name(&self) -> &str {
match &self.source {
Source::From(path) => path,
@ -162,8 +154,10 @@ impl Asset for StyleSheet {
fn weight(&self) -> Weight {
self.weight
}
}
fn render(&self, cx: &mut Context) -> Markup {
impl Render for StyleSheet {
fn render(&self) -> Markup {
match &self.source {
Source::From(path) => html! {
link
@ -171,8 +165,8 @@ impl Asset for StyleSheet {
href=(join_pair!(path, "?v=", self.version.as_str()))
media=[self.media.as_str_opt()];
},
Source::Inline(_, f) => html! {
style { (PreEscaped((f)(cx))) };
Source::Inline(_, code) => html! {
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::{builder_fn, join};
use std::any::Any;
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 {
// Favicon.
/// Define el *favicon* del documento. Sobrescribe cualquier valor anterior.
@ -25,138 +28,38 @@ pub enum AssetsOp {
RemoveStyleSheet(&'static str),
// JavaScripts.
/// Añade un script JavaScript al documento.
/// Añade un *script* JavaScript al documento.
AddJavaScript(JavaScript),
/// Elimina un script por su ruta o identificador.
/// Elimina un *script* por su ruta o identificador.
RemoveJavaScript(&'static str),
}
/// Errores de acceso a parámetros dinámicos del contexto.
///
/// - [`ErrorParam::NotFound`]: la clave no existe.
/// - [`ErrorParam::TypeMismatch`]: la clave existe, pero el valor guardado no coincide con el tipo
/// solicitado. Incluye nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente
/// guardado (`saved`) para facilitar el diagnóstico.
/// Errores de lectura o conversión de parámetros almacenados en el contexto.
#[derive(Debug)]
pub enum ErrorParam {
/// El parámetro solicitado no existe.
NotFound,
TypeMismatch {
key: &'static str,
expected: &'static str,
saved: &'static str,
},
/// El valor del parámetro no pudo convertirse al tipo requerido.
ParseError(String),
}
/// Interfaz para gestionar el **contexto de renderizado** de un documento HTML.
///
/// `Contextual` extiende [`LangId`] y define los métodos para:
///
/// - Establecer el **idioma** del documento.
/// - Almacenar la **solicitud HTTP** de origen.
/// - Seleccionar **tema** y **composición** (*layout*) de renderizado.
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`].
/// - Leer y mantener **parámetros dinámicos tipados** de contexto.
/// - Generar **identificadores únicos** por tipo de componente.
///
/// Lo implementan, típicamente, estructuras que representan el contexto de renderizado, como
/// [`Context`](crate::html::Context) o [`Page`](crate::response::page::Page).
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// fn prepare_context<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()
impl fmt::Display for ErrorParam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorParam::NotFound => write!(f, "Parameter not found"),
ErrorParam::ParseError(e) => write!(f, "Parse error: {e}"),
}
}
/// 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,
/// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo
/// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos
/// tipados** con nuevos métodos.
/// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de
/// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]),
/// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como parámetros de contexto
/// definidos en tiempo de ejecución.
///
/// # Ejemplos
///
@ -193,7 +96,7 @@ pub trait Contextual: LangId {
/// assert_eq!(active_theme.short_name(), "aliner");
///
/// // 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);
///
/// // Genera un identificador para un componente de tipo `Menu`.
@ -211,16 +114,10 @@ pub struct Context {
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
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.
}
impl Default for Context {
fn default() -> Self {
Context::new(None)
}
}
impl Context {
/// Crea un nuevo contexto asociado a una solicitud HTTP.
///
@ -250,199 +147,40 @@ impl Context {
favicon : None,
stylesheets: Assets::<StyleSheet>::new(),
javascripts: Assets::<JavaScript>::new(),
params : HashMap::default(),
params : HashMap::<&str, String>::new(),
id_counter : 0,
}
}
// Context RENDER ******************************************************************************
/// Renderiza los recursos del contexto.
pub fn render_assets(&mut self) -> Markup {
use std::mem::take as mem_take;
// Extrae temporalmente los recursos.
let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self.
let stylesheets = mem_take(&mut self.stylesheets); // Assets<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 **************************************************************************
// Context BUILDER *****************************************************************************
/// Modifica la fuente de idioma del documento.
#[builder_fn]
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.request = request;
self
}
#[builder_fn]
fn with_langid(mut self, language: &impl LangId) -> Self {
pub fn with_langid(mut self, language: &impl LangId) -> Self {
self.langid = language.langid();
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
/// ninguno entonces usará el tema por defecto.
#[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
}
/// Modifica la composición para renderizar el documento.
#[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
}
/// Añade o modifica un parámetro dinámico del contexto.
///
/// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error
/// posteriores.
///
/// # Ejemplos
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let cx = Context::new(None)
/// .with_param("usuario_id", 42_i32)
/// .with_param("titulo", "Hola".to_string())
/// .with_param("flags", vec!["a", "b"]);
/// ```
/// Define los recursos del contexto usando [`AssetsOp`].
#[builder_fn]
fn with_param<T: 'static>(mut self, key: &'static str, value: T) -> 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 {
pub fn with_assets(mut self, op: AssetsOp) -> Self {
match op {
// Favicon.
AssetsOp::SetFavicon(favicon) => {
@ -471,74 +209,69 @@ impl Contextual for Context {
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()
}
fn theme(&self) -> ThemeRef {
/// Devuelve el tema que se usará para renderizar el documento.
pub fn theme(&self) -> ThemeRef {
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
}
/// Recupera un parámetro como [`Option`], simplificando el acceso.
///
/// A diferencia de [`get_param`](Self::get_param), que devuelve un [`Result`] con información
/// detallada de error, este método devuelve `None` tanto si la clave no existe como si el valor
/// guardado no coincide con el tipo solicitado.
///
/// Resulta útil en escenarios donde sólo interesa saber si el valor existe y es del tipo
/// correcto, sin necesidad de diferenciar entre error de ausencia o de tipo.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let cx = Context::new(None).with_param("username", "Alice".to_string());
///
/// // Devuelve Some(&String) si existe y coincide el tipo.
/// assert_eq!(cx.param::<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()
// Context RENDER ******************************************************************************
/// Renderiza los recursos del contexto.
pub fn render_assets(&self) -> Markup {
html! {
@if let Some(favicon) = &self.favicon {
(favicon)
}
(self.stylesheets)
(self.javascripts)
}
}
fn favicon(&self) -> Option<&Favicon> {
self.favicon.as_ref()
// Context PARAMS ******************************************************************************
/// 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> {
&self.stylesheets
/// Recupera un parámetro del contexto convertido al tipo especificado.
///
/// 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> {
&self.javascripts
/// Elimina un parámetro del contexto. Devuelve `true` si existía y se eliminó.
pub fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
}
// Contextual HELPERS **************************************************************************
// Context EXTRAS ******************************************************************************
/// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona
/// un `id` explícito.
/// Genera un identificador único si no se proporciona uno explícito.
///
/// 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
/// 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 {
id
} else {
@ -548,7 +281,7 @@ impl Contextual for Context {
.replace(' ', "_")
.to_lowercase();
let prefix = if prefix.is_empty() {
"prefix".to_string()
"prefix".to_owned()
} else {
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
/// these methods call each other, not doing this will result in
/// infinite recursion.
///
/// # Example
///
/// ```rust
/// use pagetop::prelude::*;
///
/// /// Provides a shorthand for linking to a CSS stylesheet.
/// pub struct Stylesheet(&'static str);
///
/// impl Render for Stylesheet {
/// fn render(&self) -> Markup {
/// html! {
/// link rel="stylesheet" type="text/css" href=(self.0);
/// }
/// }
/// }
/// ```
pub trait Render {
/// Renders `self` as a block of `Markup`.
fn render(&self) -> Markup {
@ -221,10 +238,6 @@ impl Markup {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<T: Into<String>> PreEscaped<T> {

View file

@ -1,6 +1,6 @@
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 {
/// Añade al final (si no existe).
Add,
@ -25,7 +25,6 @@ pub enum ClassesOp {
///
/// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS.
/// - No se permiten clases duplicadas.
/// - Las clases se convierten a minúsculas.
/// - Las clases vacías se ignoran.
///
/// # Ejemplo
@ -33,26 +32,26 @@ pub enum ClassesOp {
/// ```rust
/// use pagetop::prelude::*;
///
/// let classes = AttrClasses::new("Btn btn-primary")
/// .with_value(ClassesOp::Add, "Active")
/// let classes = OptionClasses::new("btn btn-primary")
/// .with_value(ClassesOp::Add, "active")
/// .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"));
/// ```
#[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 {
AttrClasses::default().with_value(ClassesOp::Prepend, classes)
OptionClasses::default().with_value(ClassesOp::Prepend, classes)
}
// AttrClasses BUILDER *************************************************************************
// OptionClasses BUILDER ***********************************************************************
#[builder_fn]
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();
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> {
if self.0.is_empty() {
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>
</div>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo:
@ -25,14 +25,14 @@ según las necesidades de cada proyecto, incluyendo:
* **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs
de PageTop o de terceros.
de `PageTop` o de terceros.
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad.
# Guía rápida
La aplicación más sencilla de PageTop se ve así:
La aplicación más sencilla de `PageTop` se ve así:
```rust,no_run
use pagetop::prelude::*;
@ -43,10 +43,10 @@ async fn main() -> std::io::Result<()> {
}
```
Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
Este código arranca el servidor de `PageTop`. Con la configuración por defecto, muestra una página
de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
Para personalizar el servicio, se puede crear una extensión de `PageTop` de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
@ -60,8 +60,8 @@ impl Extension for HelloWorld {
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
Page::new(Some(request))
.with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
@ -77,11 +77,11 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági
# 🧩 Gestión de Dependencias
Los proyectos que utilizan PageTop gestionan las dependencias con `cargo`, como cualquier otro
Los proyectos que utilizan `PageTop` gestionan las dependencias con `cargo`, como cualquier otro
proyecto en Rust.
Sin embargo, es fundamental que cada extensión declare explícitamente sus
[dependencias](core::extension::Extension::dependencies), si las tiene, para que PageTop pueda
[dependencias](core::extension::Extension::dependencies), si las tiene, para que `PageTop` pueda
estructurar e inicializar la aplicación de forma modular.
*/
@ -138,7 +138,7 @@ pub type Weight = i8;
// API *********************************************************************************************
// Macros y funciones útiles.
// Funciones y macros útiles.
pub mod util;
// Carga las opciones de configuración.
pub mod config;

View file

@ -1,6 +1,6 @@
//! Localización (L10n).
//!
//! PageTop utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la
//! `PageTop` utiliza las especificaciones de [Fluent](https://www.projectfluent.org/) para la
//! localización de aplicaciones, y aprovecha [fluent-templates](https://docs.rs/fluent-templates/)
//! para integrar los recursos de traducción directamente en el binario de la aplicación.
//!
@ -13,7 +13,7 @@
//!
//! # Recursos Fluent
//!
//! Por defecto, las traducciones están en el directorio `src/locale`, con subdirectorios para cada
//! Por defecto las traducciones están en el directorio `src/locale`, con subdirectorios para cada
//! [Identificador de Idioma Unicode](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier)
//! válido. Podríamos tener una estructura como esta:
//!
@ -34,7 +34,7 @@
//! └── main.ftl
//! ```
//!
//! Ejemplo de un archivo en `src/locale/en-US/main.ftl`:
//! Ejemplo de un archivo en `src/locale/en-US/main.ftl`
//!
//! ```text
//! hello-world = Hello world!
@ -53,7 +53,7 @@
//! Y su archivo equivalente para español en `src/locale/es-ES/main.ftl`:
//!
//! ```text
//! hello-world = ¡Hola, mundo!
//! hello-world = Hola mundo!
//! hello-user = ¡Hola, {$userName}!
//! shared-photos =
//! {$userName} {$photoCount ->
@ -81,13 +81,13 @@
//! include_locales!(LOCALES_SAMPLE);
//! ```
//!
//! Si están ubicados en otro directorio, se puede usar la forma:
//! Si están ubicados en otro directorio se puede usar la forma:
//!
//! ```rust,ignore
//! include_locales!(LOCALES_SAMPLE from "ruta/a/las/traducciones");
//! ```
//!
//! Y *voilà*, sólo queda operar con los idiomas soportados por PageTop usando [`LangMatch`] y
//! Y *voilà*, sólo queda operar con los idiomas soportados por `PageTop` usando [`LangMatch`] y
//! traducir textos con [`L10n`].
use crate::html::{Markup, PreEscaped};
@ -129,7 +129,7 @@ pub(crate) static FALLBACK_LANGID: LazyLock<LanguageIdentifier> =
// Identificador de idioma **por defecto** para la aplicación.
//
// Se resuelve a partir de [`global::SETTINGS.app.language`](global::SETTINGS). Si el identificador
// de idioma no es válido o no está disponible, 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>> =
LazyLock::new(|| LangMatch::resolve(&global::SETTINGS.app.language).as_option());
@ -141,10 +141,10 @@ pub trait LangId {
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`]
/// soportado por PageTop.
/// soportado por `PageTop`.
///
/// # Ejemplos
///
@ -155,7 +155,7 @@ pub trait LangId {
/// let lang = LangMatch::resolve("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");
/// assert_eq!(lang.langid().to_string(), "es-ES"); // Porque "es-EC" no está soportado.
///
@ -165,7 +165,7 @@ pub trait LangId {
///
/// // Idioma no soportado.
/// 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
@ -183,11 +183,11 @@ pub trait LangId {
pub enum LangMatch {
/// Cuando el identificador de idioma es una cadena vacía.
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
/// del idioma base (p.ej. "es").
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),
}
@ -221,8 +221,8 @@ impl LangMatch {
}
}
// En caso contrario, indica que el idioma no está soportado.
Self::Unsupported(language.to_string())
// En otro caso indica que el idioma no está soportado.
Self::Unsupported(String::from(language))
}
/// Devuelve el [`LanguageIdentifier`] si el idioma fue reconocido.
@ -241,7 +241,7 @@ impl LangMatch {
/// let lang = LangMatch::resolve("es-ES").as_option();
/// 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());
/// ```
#[inline]
@ -259,8 +259,8 @@ impl LangMatch {
/// devuelve el idioma por defecto de la aplicación y, si tampoco está disponible, el idioma de
/// respaldo ("en-US").
///
/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::lookup()`]
/// o [`L10n::using()`].
/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::using()`]
/// o [`L10n::to_markup()`].
impl LangId for LangMatch {
fn langid(&self) -> &'static LanguageIdentifier {
match self {
@ -271,10 +271,10 @@ impl LangId for LangMatch {
}
#[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 {
// Se desactiva la inserción de marcas de aislamiento Unicode (FSI/PDI) en los argumentos para
// mejorar la legibilidad y la compatibilidad en ciertos contextos de renderizado.
// Se eliminan las marcas de aislamiento Unicode en los argumentos para mejorar la legibilidad y
// la compatibilidad en ciertos contextos de renderizado.
( $LOCALES:ident $(, $core_locales:literal)? ) => {
$crate::locale::fluent_templates::static_loader! {
static $LOCALES = {
@ -310,8 +310,8 @@ include_locales!(LOCALES_PAGETOP);
enum L10nOp {
#[default]
None,
Text(Cow<'static, str>),
Translate(Cow<'static, str>),
Text(String),
Translate(String),
}
/// Crea instancias para traducir textos localizados.
@ -319,12 +319,12 @@ enum L10nOp {
/// Cada instancia puede representar:
///
/// - Un texto puro (`n()`) que no requiere traducción.
/// - Una clave para traducir un texto de las traducciones predefinidas de PageTop (`l()`).
/// - Una clave para traducir un texto de las traducciones predefinidas de `PageTop` (`l()`).
/// - Una clave para traducir de un conjunto concreto de traducciones (`t()`).
///
/// # 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
/// use pagetop::prelude::*;
@ -338,11 +338,11 @@ enum L10nOp {
/// .get();
/// ```
///
/// También sirve para traducciones contra un conjunto de recursos concreto.
/// También para traducciones a idiomas concretos.
///
/// ```rust,ignore
/// // 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)]
pub struct L10n {
@ -354,7 +354,7 @@ pub struct L10n {
impl L10n {
/// **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 {
op: L10nOp::Text(text.into()),
..Default::default()
@ -363,7 +363,7 @@ impl L10n {
/// **l** = *“lookup”*. Crea una instancia para traducir usando una clave del conjunto de
/// traducciones predefinidas.
pub fn l(key: impl Into<Cow<'static, str>>) -> Self {
pub fn l(key: impl Into<String>) -> Self {
L10n {
op: L10nOp::Translate(key.into()),
..Default::default()
@ -372,7 +372,7 @@ impl L10n {
/// **t** = *“translate”*. Crea una instancia para traducir usando una clave de un conjunto de
/// 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 {
op: L10nOp::Translate(key.into()),
locales,
@ -399,8 +399,7 @@ impl L10n {
self
}
/// Resuelve la traducción usando el idioma por defecto o, si no procede, el de respaldo de la
/// aplicación.
/// Resuelve la traducción usando el idioma por defecto o de respaldo de la aplicación.
///
/// 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();
/// ```
pub fn get(&self) -> Option<String> {
self.lookup(&LangMatch::default())
self.using(&LangMatch::default())
}
/// Resuelve la traducción usando la fuente de idioma proporcionada.
@ -433,27 +432,20 @@ impl L10n {
/// }
///
/// 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 {
L10nOp::None => None,
L10nOp::Text(text) => Some(text.clone().into_owned()),
L10nOp::Translate(key) => {
if self.args.is_empty() {
self.locales.try_lookup(language.langid(), key.as_ref())
} else {
self.locales.try_lookup_with_args(
language.langid(),
key.as_ref(),
&self
.args
.iter()
.map(|(k, v)| (Cow::Owned(k.clone()), v.clone().into()))
.collect::<HashMap<_, _>>(),
)
}
}
L10nOp::Text(text) => Some(text.to_owned()),
L10nOp::Translate(key) => self.locales.try_lookup_with_args(
language.langid(),
key,
&self.args.iter().fold(HashMap::new(), |mut arg, (k, v)| {
arg.insert(Cow::Owned(k.clone()), v.to_owned().into());
arg
}),
),
}
}
@ -466,16 +458,10 @@ impl L10n {
/// ```rust
/// 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 {
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.
region_header = Header
region_content = Content
region_footer = Footer
error403_notice = FORBIDDEN ACCESS
error404_notice = RESOURCE NOT FOUND
content = Content
pagetop_logo = PageTop Logo

View file

@ -1,16 +1,21 @@
welcome_extension_name = Default Homepage
welcome_extension_description = Displays a default homepage when none is configured.
welcome_extension_name = Default homepage
welcome_extension_description = Displays a landing page when none is configured.
welcome_page = Welcome page
welcome_title = Hello, world!
welcome_page = Welcome Page
welcome_title = Hello world!
welcome_aria = Say hello to your { $app } installation
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_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_status_2 = If the issue persists, please <strong>contact the system administrator</strong>.
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_text2 = If the issue persists, please <strong>contact your system administrator</strong> for assistance.
welcome_support_title = Support
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_support_2 = For issues specific to the application (<strong>{ $app }</strong>), please use its official repository or support channel.
welcome_about = About
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_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.
region_header = Cabecera
region_content = Contenido
region_footer = Pie de página
error403_notice = ACCESO NO PERMITIDO
error404_notice = RECURSO NO ENCONTRADO
content = Contenido
pagetop_logo = Logotipo de PageTop

View file

@ -1,16 +1,21 @@
welcome_extension_name = Página de inicio predeterminada
welcome_extension_description = Muestra una página de inicio predeterminada cuando no hay ninguna configurada.
welcome_page = Página de bienvenida
welcome_title = ¡Hola, mundo!
welcome_page = Página de Bienvenida
welcome_title = ¡Hola mundo!
welcome_aria = Saluda a tu instalación { $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_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_status_2 = Si el problema persiste, por favor, <strong>contacta con el administrador del sistema</strong>.
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_text2 = Si el problema persiste, por favor <strong>contacta con el administrador del sistema</strong> para recibir asistencia técnica.
welcome_support_title = Soporte
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_support_2 = Para fallos específicos de la aplicación (<strong>{ $app }</strong>), utiliza su repositorio oficial o su canal de soporte.
welcome_about = Acerca de
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_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.

View file

@ -4,16 +4,13 @@ pub use error::ErrorPage;
pub use actix_web::Result as ResultPage;
use crate::base::action;
use crate::builder_fn;
use crate::core::component::{Child, ChildOp, Component};
use crate::core::theme::{ChildrenInRegions, ThemeRef, REGION_CONTENT};
use crate::html::{html, Markup, DOCTYPE};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::html::{AssetsOp, Context, Contextual};
use crate::html::{AttrClasses, ClassesOp};
use crate::html::{AttrId, AttrL10n};
use crate::core::theme::{ChildrenInRegions, ThemeRef, CONTENT_REGION_NAME};
use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE};
use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated};
use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier};
use crate::service::HttpRequest;
use crate::{builder_fn, AutoDefault};
/// 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
/// renderizado.
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Page {
title : AttrL10n,
description : AttrL10n,
title : OptionTranslated,
description : OptionTranslated,
metadata : Vec<(&'static str, &'static str)>,
properties : Vec<(&'static str, &'static str)>,
body_id : AttrId,
body_classes: AttrClasses,
context : Context,
body_id : OptionId,
body_classes: OptionClasses,
regions : ChildrenInRegions,
}
impl Page {
/// Crea una nueva instancia de página.
///
/// La solicitud HTTP se guardará en el contexto de renderizado de la página para poder ser
/// recuperada por los componentes si es necesario.
/// Si se proporciona la solicitud HTTP, se guardará en el contexto de renderizado de la página
/// para poder ser recuperada por los componentes si es necesario.
#[rustfmt::skip]
pub fn new(request: HttpRequest) -> Self {
pub fn new(request: Option<HttpRequest>) -> Self {
Page {
title : AttrL10n::default(),
description : AttrL10n::default(),
title : OptionTranslated::default(),
description : OptionTranslated::default(),
metadata : Vec::default(),
properties : Vec::default(),
body_id : AttrId::default(),
body_classes: AttrClasses::default(),
context : Context::new(Some(request)),
context : Context::new(request),
body_id : OptionId::default(),
body_classes: OptionClasses::default(),
regions : ChildrenInRegions::default(),
}
}
@ -82,6 +78,34 @@ impl Page {
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>`.
#[builder_fn]
pub fn with_body_id(mut self, id: impl AsRef<str>) -> Self {
@ -89,65 +113,35 @@ impl Page {
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]
pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.body_classes.alter_value(op, classes);
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.
pub fn add_component(mut self, component: impl Component) -> Self {
pub fn with_component(mut self, component: impl Component) -> Self {
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
}
/// 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,
region_name: &'static str,
component: impl Component,
) -> Self {
self.regions
.alter_child_in(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);
.alter_child_in_region(region_name, ChildOp::Add(Child::with(component)));
self
}
/// Opera con [`ChildOp`] en una región (`region_name`) de la página.
#[builder_fn]
pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self {
self.regions.alter_child_in(region_name, op);
pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
self.regions.alter_child_in_region(region_name, op);
self
}
@ -155,12 +149,12 @@ impl Page {
/// Devuelve el título traducido para el idioma de la página, si existe.
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.
pub fn description(&mut self) -> Option<String> {
self.description.lookup(&self.context)
self.description.using(&self.context)
}
/// Devuelve la lista de metadatos `<meta name=...>`.
@ -173,28 +167,39 @@ impl Page {
&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>`.
pub fn body_id(&self) -> &AttrId {
pub fn body_id(&self) -> &OptionId {
&self.body_id
}
/// Devuelve las clases CSS del elemento `<body>`.
pub fn body_classes(&self) -> &AttrClasses {
pub fn body_classes(&self) -> &OptionClasses {
&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 *********************************************************************************
/// 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 {
self.regions
.merge_all_components(self.context.theme(), region_name)
@ -202,7 +207,7 @@ impl Page {
}
/// Renderiza los recursos de la página.
pub fn render_assets(&mut self) -> Markup {
pub fn render_assets(&self) -> Markup {
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::html::Contextual;
use crate::locale::L10n;
use crate::response::ResponseError;
use crate::service::http::{header::ContentType, StatusCode};
@ -7,7 +6,7 @@ use crate::service::{HttpRequest, HttpResponse};
use super::Page;
use std::fmt::{self, Display};
use std::fmt;
#[derive(Debug)]
pub enum ErrorPage {
@ -20,7 +19,7 @@ pub enum ErrorPage {
Timeout(HttpRequest),
}
impl Display for ErrorPage {
impl fmt::Display for ErrorPage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// Error 304.
@ -29,12 +28,12 @@ impl Display for ErrorPage {
ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"),
// Error 403.
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);
if let Ok(page) = error_page
.with_title(L10n::n("Error FORBIDDEN"))
.with_layout("error")
.add_component(Html::with(move |_| error403.clone()))
.with_component(Html::with(move |_| error403.clone()))
.render()
{
write!(f, "{}", page.into_string())
@ -44,12 +43,12 @@ impl Display for ErrorPage {
}
// Error 404.
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);
if let Ok(page) = error_page
.with_title(L10n::n("Error RESOURCE NOT FOUND"))
.with_layout("error")
.add_component(Html::with(move |_| error404.clone()))
.with_component(Html::with(move |_| error404.clone()))
.render()
{
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)
/// 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")]
#[macro_export]
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)
/// 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")]
#[macro_export]
macro_rules! include_files_service {

View file

@ -1,13 +1,13 @@
//! 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.
//!
//! 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
//! 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
//! 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

View file

@ -1,4 +1,4 @@
//! Macros y funciones útiles.
//! Funciones y macros útiles.
use crate::trace;
@ -6,198 +6,6 @@ use std::env;
use std::io;
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 ********************************************************************************
/// 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.
#[deprecated(since = "0.3.0", note = "Use `resolve_absolute_dir()` instead")]
/// Devuelve la ruta absoluta a un directorio existente.
#[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>
where
P: AsRef<Path>,
@ -257,3 +65,191 @@ where
{
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 {
--bg-img: url('/img/intro-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-sm: url('/img/intro-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: url('/img/welcome-header.jpg');
--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/welcome-header-sm.jpg');
--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;
--color: #1a202c;
--color-red: #fecaca;
--color-gray: #e4e4e7;
--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-offset: 2px;
--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;
color: var(--color);
line-height: 1.6;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
header,
section {
position: relative;
text-align: center;
@ -59,17 +50,20 @@ a:hover:visited {
text-decoration-color: var(--color-link);
}
/*
* Header
*/
#content {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.intro-header {
#main-header {
display: flex;
flex-direction: column-reverse;
width: 100%;
max-width: 80rem;
margin: 0 auto;
padding-bottom: 9rem;
max-width: 80rem;
width: 100%;
background-image: var(--bg-img-sm);
background-image: var(--bg-img-sm-set);
background-position: top center;
@ -77,11 +71,11 @@ a:hover:visited {
background-size: contain;
background-repeat: no-repeat;
}
.intro-header__body {
#main-header header {
padding: 0;
background: none;
}
.intro-header__title {
#header-title {
margin: 0 0 0 1.5rem;
text-align: left;
display: flex;
@ -95,7 +89,7 @@ a:hover:visited {
line-height: 110%;
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-clip: text;
-webkit-background-clip: text;
@ -106,44 +100,40 @@ a:hover:visited {
line-height: 110%;
text-shadow: none;
}
.intro-header__image {
#header-image {
width: 100%;
text-align: right;
display: flex;
justify-content: flex-start;
text-align: right;
width: 100%;
}
.intro-header__monster {
#header-image #monster {
margin-right: 12rem;
margin-top: 1rem;
flex-shrink: 1;
}
@media (min-width: 64rem) {
.intro-header {
#main-header {
background-image: var(--bg-img);
background-image: var(--bg-img-set);
}
.intro-header__title {
#header-title {
padding: 1.2rem 2rem 2.6rem 2rem;
}
.intro-header__image {
#header-image {
justify-content: flex-end;
}
}
/*
* Content
*/
.intro-content {
#main-content {
height: auto;
margin-top: 1.6rem;
}
.intro-content__body {
.content-body {
box-sizing: border-box;
max-width: 80rem;
}
.intro-content__body:before,
.intro-content__body:after {
.content-body:before,
.content-body:after {
content: '';
position: absolute;
left: 0;
@ -153,37 +143,38 @@ a:hover:visited {
filter: blur(2.75rem);
opacity: 0.8;
inset: 11.75rem;
z-index: 0;
}
.intro-content__body:before {
.content-body:before {
top: -1rem;
}
.intro-content__body:after {
.content-body:after {
bottom: -1rem;
}
@media (max-width: 48rem) {
.intro-content__body {
.content-body {
margin-top: -9.8rem;
}
.intro-content__body:before,
.intro-content__body:after {
.content-body:before,
.content-body:after {
inset: unset;
}
}
@media (min-width: 64rem) {
.intro-content {
#main-content {
margin-top: 0;
}
.intro-content__body {
.content-body {
margin-top: -5.7rem;
}
}
.intro-button {
#poweredby-button {
width: 100%;
margin: 0 auto 3rem;
z-index: 10;
}
.intro-button__link {
#poweredby-link {
background: #7f1d1d;
background-image: linear-gradient(to bottom, rgba(255,0,0,0.8), rgba(255,255,255,0));
background-position: top left, center;
@ -196,6 +187,7 @@ a:hover:visited {
font-size: 1.5rem;
line-height: 1.3;
text-decoration: none;
text-shadow: var(--shadow);
transition: transform 0.3s ease-in-out;
position: relative;
overflow: hidden;
@ -203,7 +195,7 @@ a:hover:visited {
min-height: 7.6875rem;
outline: none;
}
.intro-button__link::before {
#poweredby-link::before {
content: '';
position: absolute;
top: -13.125rem;
@ -215,7 +207,7 @@ a:hover:visited {
transition: transform 0.3s ease-in-out;
z-index: 5;
}
.intro-button__text {
#poweredby-text {
display: flex;
flex-direction: column;
flex: 1;
@ -225,24 +217,25 @@ a:hover:visited {
padding: 1rem 1.5rem;
text-align: left;
color: white;
text-shadow: 0 0.101125rem 0.2021875rem rgba(0, 0, 0, 0.25);
font-size: 1.65rem;
font-style: normal;
font-weight: 600;
line-height: 130.023%;
letter-spacing: 0.0075rem;
}
.intro-button__text strong {
#poweredby-text strong {
font-size: 2.625rem;
font-weight: 600;
line-height: 130.023%;
letter-spacing: 0.013125rem;
}
.intro-button__link span {
#poweredby-link span {
position: absolute;
display: block;
pointer-events: none;
}
.intro-button__link span:nth-child(1) {
#poweredby-link span:nth-child(1) {
height: 8px;
width: 100%;
top: 0;
@ -262,7 +255,7 @@ a:hover:visited {
transform: translateX(100%);
}
}
.intro-button__link span:nth-child(2) {
#poweredby-link span:nth-child(2) {
width: 8px;
height: 100%;
top: 0;
@ -282,7 +275,7 @@ a:hover:visited {
transform: translateY(100%);
}
}
.intro-button__link span:nth-child(3) {
#poweredby-link span:nth-child(3) {
height: 8px;
width: 100%;
bottom: 0;
@ -302,22 +295,27 @@ a:hover:visited {
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;
}
@media (max-width: 48rem) {
.intro-button__link {
#poweredby-link {
height: 6.25rem;
min-width: auto;
border-radius: 0;
}
.intro-button__text {
#poweredby-text {
display: inline;
padding-top: .5rem;
}
}
@media (min-width: 48rem) {
.intro-button {
#poweredby-button {
position: absolute;
top: 0;
left: 50%;
@ -325,13 +323,9 @@ a:hover:visited {
max-width: 29.375rem;
margin-bottom: 0;
}
.intro-button__link:hover {
transition: all .5s;
transform: rotate(-3deg) scale(1.1);
}
}
.intro-text {
.content-text {
z-index: 1;
width: 100%;
display: flex;
@ -343,16 +337,13 @@ a:hover:visited {
font-weight: 400;
line-height: 1.5;
margin-top: -6rem;
margin-bottom: 0;
background: #fff;
margin-bottom: 0;
position: relative;
padding: 2.5rem 1.063rem 0.75rem;
padding: 6rem 1.063rem 0.75rem;
overflow: hidden;
}
.intro-button + .intro-text {
padding-top: 6rem;
}
.intro-text p {
.content-text p {
width: 100%;
line-height: 150%;
font-weight: 400;
@ -360,16 +351,14 @@ a:hover:visited {
margin: 0 0 1.5rem;
}
@media (min-width: 48rem) {
.intro-text {
.content-text {
font-size: 1.375rem;
line-height: 2rem;
}
.intro-button + .intro-text {
padding-top: 7rem;
}
}
@media (min-width: 64rem) {
.intro-text {
.content-text {
border-radius: 0.75rem;
box-shadow: var(--shadow);
max-width: 60rem;
@ -379,13 +368,13 @@ a:hover:visited {
}
}
.intro-text .block {
.subcontent {
position: relative;
}
.intro-text .block__title {
.subcontent h1 {
margin: 1em 0 .8em;
}
.intro-text .block__title span {
.subcontent h1 span {
display: inline-block;
padding: 10px 30px 14px;
margin: 0 0 0 20px;
@ -396,7 +385,7 @@ a:hover:visited {
border-color: orangered;
transform: rotate(-3deg) translateY(-25%);
}
.intro-text .block__title:before {
.subcontent h1:before {
content: "";
height: 5px;
position: absolute;
@ -409,7 +398,7 @@ a:hover:visited {
transform: rotate(2deg) translateY(-50%);
transform-origin: top left;
}
.intro-text .block__title:after {
.subcontent h1:after {
content: "";
height: 70rem;
position: absolute;
@ -417,80 +406,55 @@ a:hover:visited {
left: -15%;
width: 130%;
z-index: -10;
background: var(--color-block-1);
background: var(--color-red);
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
*/
.intro-footer {
#footer {
width: 100%;
background-color: black;
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-weight: 300;
line-height: 100%;
display: flex;
justify-content: center;
z-index: 10;
}
.intro-footer__body a:visited {
#footer a:visited {
color: var(--color-gray);
}
.intro-footer__logo,
.intro-footer__links {
.footer-logo {
max-height: 12.625rem;
}
.footer-logo svg {
width: 100%;
}
.footer-logo,
.footer-links,
.footer-inner {
display: flex;
justify-content: center;
width: 100%;
}
.intro-footer__logo {
max-height: 12.625rem;
}
.intro-footer__logo svg {
width: 100%;
}
.intro-footer__links {
.footer-links {
gap: 1.875rem;
flex-wrap: wrap;
margin-top: 2rem;
}
.footer-inner {
max-width: 80rem;
display: flex;
flex-direction: column;
padding: 0 10.625rem 2rem;
}
@media (max-width: 48rem) {
.intro-footer__logo {
.footer-logo {
display: none;
}
}
@media (max-width: 64rem) {
.intro-footer__body {
.footer-inner {
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]
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 name = cx.param::<String>("username").cloned().unwrap_or_default();
let name = cx.get_param::<String>("username").unwrap_or_default();
html! {
span { (name) }
}
@ -35,7 +35,7 @@ async fn component_html_renders_using_context_param() {
async fn component_html_allows_replacing_render_function() {
let mut component = Html::with(|_| html! { div { "Original" } });
component.alter_fn(|_| html! { div { "Modified" } });
component.alter_html(|_| html! { div { "Modified" } });
let markup = component
.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::*;
#[pagetop::test]
async fn prepare_markup_render_none_is_empty_string() {
assert_eq!(PrepareMarkup::None.render().as_str(), "");
}
async fn prepare_markup_is_empty() {
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::Escaped(String::new()).is_empty());
assert!(PrepareMarkup::Escaped("".to_string()).is_empty());
assert!(!PrepareMarkup::Escaped("x".to_string()).is_empty());
assert!(PrepareMarkup::Text(String::from("")).is_empty());
assert!(!PrepareMarkup::Text(String::from("x")).is_empty());
assert!(PrepareMarkup::Raw(String::new()).is_empty());
assert!(PrepareMarkup::Raw("".to_string()).is_empty());
assert!(!PrepareMarkup::Raw("a".into()).is_empty());
assert!(PrepareMarkup::Escaped(String::new()).is_empty());
assert!(!PrepareMarkup::Escaped("a".into()).is_empty());
assert!(PrepareMarkup::With(html! {}).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 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()));
}
@ -22,7 +22,7 @@ async fn translation_with_args() {
let _app = service::test::init_service(Application::new().test()).await;
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()));
}
@ -35,7 +35,7 @@ async fn translation_with_plural_and_select() {
("photoCount", "3"),
("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"));
}
@ -44,7 +44,7 @@ async fn check_fallback_language() {
let _app = service::test::init_service(Application::new().test()).await;
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()));
}
@ -53,6 +53,6 @@ async fn check_unknown_key() {
let _app = service::test::init_service(Application::new().test()).await;
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);
}