🎨 Mejora la página de bienvenida y el tema básico #6

Merged
manuelcillero merged 37 commits from advanced-welcome-page into main 2025-09-20 12:37:55 +02:00
79 changed files with 2675 additions and 1419 deletions

View file

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

7
Cargo.lock generated
View file

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

View file

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

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(Some(request))
.with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
Page::new(request)
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
@ -86,15 +86,15 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
* **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**,
es la librería que permite incluir archivos estáticos en el ejecutable de las aplicaciones
`PageTop` para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo
de compilación.
PageTop para servirlos de forma eficiente, con detección de cambios que optimizan el tiempo de
compilación.
* **[pagetop-build](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-build)**,
prepara los archivos estáticos o archivos SCSS compilados para incluirlos en el binario de las
aplicaciones `PageTop` durante la compilación de los ejecutables.
aplicaciones PageTop durante la compilación de los ejecutables.
* **[pagetop-macros](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-macros)**,
proporciona una colección de macros que mejoran la experiencia de desarrollo con `PageTop`.
proporciona una colección de macros que mejoran la experiencia de desarrollo con PageTop.
# 🧪 Pruebas
@ -116,7 +116,7 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
# 🚧 Advertencia
`PageTop` es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.

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

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

View file

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

103
src/base/component/block.rs Normal file
View file

@ -0,0 +1,103 @@
use crate::prelude::*;
/// Componente genérico que representa un bloque de contenido.
///
/// Los bloques se utilizan como contenedores de otros componentes o contenidos, con un título
/// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*).
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Block {
id : AttrId,
classes : AttrClasses,
title : L10n,
children: Children,
}
impl Component for Block {
fn new() -> Self {
Block::default()
}
fn id(&self) -> Option<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.get_param::<String>("username").unwrap_or(String::from("visitor"));
/// let user = cx.param::<String>("username").cloned().unwrap_or("visitor".to_string());
/// html! {
/// h1 { "Hello, " (user) }
/// }
@ -44,11 +44,13 @@ impl Component for Html {
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With((self.0)(cx))
PrepareMarkup::With(self.html(cx))
}
}
impl Html {
// Html BUILDER ********************************************************************************
/// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
///
/// El método [`prepare_component()`](crate::core::component::Component::prepare_component)
@ -66,11 +68,24 @@ impl Html {
/// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando
/// [`prepare_component()`](crate::core::component::Component::prepare_component) invoque esta
/// instancia. La nueva función también recibe una referencia al contexto ([`Context`]).
pub fn alter_html<F>(&mut self, f: F) -> &mut Self
#[builder_fn]
pub fn with_fn<F>(mut self, f: F) -> Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
self.0 = Box::new(f);
self
}
// Html GETTERS ********************************************************************************
/// Aplica la función interna de renderizado con el [`Context`] proporcionado.
///
/// Normalmente no se invoca manualmente, ya que el proceso de renderizado de los componentes lo
/// invoca automáticamente durante la construcción de la página. Puede usarse, no obstante, para
/// sobrescribir [`prepare_component()`](crate::core::component::Component::prepare_component)
/// y alterar el comportamiento del componente.
pub fn html(&self, cx: &mut Context) -> Markup {
(self.0)(cx)
}
}

View file

@ -0,0 +1,67 @@
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,93 +24,32 @@ impl Extension for Welcome {
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
let app = &global::SETTINGS.app.name;
Page::new(Some(request))
.with_title(L10n::l("welcome_page"))
Page::new(request)
.with_theme("Basic")
.with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css")))
.with_component(Html::with(move |cx| html! {
div id="main-header" {
header {
h1
id="header-title"
aria-label=(L10n::l("welcome_aria").with_arg("app", app).to_markup(cx))
{
span { (L10n::l("welcome_title").to_markup(cx)) }
(L10n::l("welcome_intro").with_arg("app", app).to_markup(cx))
.with_layout("PageTopIntro")
.with_title(L10n::l("welcome_title"))
.with_description(L10n::l("welcome_intro").with_arg("app", app))
.with_param("intro_button_txt", L10n::l("welcome_powered"))
.with_param("intro_button_lnk", "https://pagetop.cillero.es".to_string())
.add_component(
Block::new()
.with_title(L10n::l("welcome_status_title"))
.add_component(Html::with(move |cx| {
html! {
p { (L10n::l("welcome_status_1").using(cx)) }
p { (L10n::l("welcome_status_2").using(cx)) }
}
})),
)
.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,8 +1,33 @@
//! Es el tema básico que incluye `PageTop` por defecto.
/// Es el tema básico que incluye PageTop por defecto.
use crate::prelude::*;
/// Tema básico por defecto.
///
/// Ofrece las siguientes composiciones (*layouts*):
///
/// - **Composición predeterminada**
/// - Renderizado genérico con
/// [`ThemePage::render_body()`](crate::core::theme::ThemePage::render_body) usando las regiones
/// predefinidas en [`page_regions()`](crate::core::theme::Theme::page_regions).
///
/// - **`Intro`**
/// - Página de entrada con cabecera visual, título y descripción y un botón opcional de llamada a
/// la acción. Ideal para una página de inicio o bienvenida en el contexto de PageTop.
/// - **Regiones:** `content` (se renderiza dentro de `.intro-content__body`).
/// - **Parámetros:**
/// - `intro_button_txt` (`L10n`) Texto del botón.
/// - `intro_button_lnk` (`Option<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 {
@ -12,11 +37,152 @@ impl Extension for Basic {
}
impl Theme for Basic {
fn render_page_body(&self, page: &mut Page) -> Markup {
match page.layout() {
"Intro" => render_intro(page),
"PageTopIntro" => render_pagetop_intro(page),
_ => <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,3 +7,6 @@ mod children;
pub use children::Children;
pub use children::{Child, ChildOp};
pub use children::{Typed, TypedOp};
mod slot;
pub use slot::TypedSlot;

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

View file

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

View file

@ -0,0 +1,64 @@
use crate::builder_fn;
use crate::core::component::{Component, Typed};
use crate::html::{html, Context, Markup};
/// Contenedor para un componente [`Typed`] opcional.
///
/// Un `TypedSlot` actúa como un contenedor dentro de otro componente para incluir o no un
/// subcomponente. Internamente encapsula `Option<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 legible para el usuario.
/// Nombre localizado de la extensión legible para el usuario.
///
/// Predeterminado por el [`short_name()`](AnyInfo::short_name) del tipo asociado a la
/// extensión.
@ -34,18 +34,15 @@ pub trait Extension: AnyInfo + Send + Sync {
L10n::n(self.short_name())
}
/// Descripción corta para paneles, listados, etc.
/// Descripción corta localizada de la extensión para paneles, listados, etc.
fn description(&self) -> L10n {
L10n::default()
}
/// Los temas son extensiones que implementan [`Extension`] y también
/// [`Theme`](crate::core::theme::Theme).
/// Devuelve una referencia a esta misma extensión cuando se trata de un tema.
///
/// Si la extensión no es un tema, este método devuelve `None` por defecto.
///
/// En caso contrario, este método debe implementarse para devolver una referencia de sí mismo
/// como tema. Por ejemplo:
/// Para ello, debe implementar [`Extension`] y también [`Theme`](crate::core::theme::Theme). Si
/// la extensión no es un tema, este método devuelve `None` por defecto.
///
/// ```rust
/// use pagetop::prelude::*;
@ -66,7 +63,7 @@ pub trait Extension: AnyInfo + Send + Sync {
/// Otras extensiones que deben habilitarse **antes** de esta.
///
/// `PageTop` las resolverá automáticamente respetando el orden durante el arranque de la
/// PageTop las resolverá automáticamente respetando el orden durante el arranque de la
/// aplicación.
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![]
@ -81,7 +78,7 @@ pub trait Extension: AnyInfo + Send + Sync {
actions_boxed![]
}
/// Inicializa la extensión durante la lógica de arranque de la aplicación.
/// Inicializa la extensión durante la fase de arranque de la aplicación.
///
/// Se llama una sola vez, después de que todas las dependencias se han inicializado y antes de
/// aceptar cualquier petición HTTP.
@ -104,8 +101,8 @@ pub trait Extension: AnyInfo + Send + Sync {
#[allow(unused_variables)]
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {}
/// Permite crear extensiones para deshabilitar y desinstalar los recursos de otras extensiones
/// utilizadas en versiones anteriores de la aplicación.
/// Permite crear extensiones para deshabilitar y desinstalar recursos de otras de versiones
/// anteriores de la aplicación.
///
/// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones.
fn drop_extensions(&self) -> Vec<ExtensionRef> {

View file

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

View file

@ -1,52 +1,49 @@
use crate::core::extension::Extension;
use crate::core::theme::CONTENT_REGION_NAME;
use crate::core::theme::Region;
use crate::global;
use crate::html::{html, Markup};
use crate::locale::L10n;
use crate::response::page::Page;
/// Representa una referencia a un tema.
use std::sync::LazyLock;
/// Referencia estática a un tema.
///
/// Los temas son también extensiones. Por tanto se deben definir igual, es decir, como instancias
/// estáticas globales que implementan [`Theme`], pero también [`Extension`].
/// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que
/// implementen [`Theme`] y, a su vez, [`Extension`].
pub type ThemeRef = &'static dyn Theme;
/// Interfaz común que debe implementar cualquier tema de `PageTop`.
/// Métodos predefinidos de renderizado para las páginas de un tema.
///
/// Un tema implementará [`Theme`] y los métodos que sean necesarios de [`Extension`], aunque el
/// único obligatorio es [`theme()`](Extension::theme).
/// Contiene las implementaciones base de las **secciones** `<head>` y `<body>`. Se implementa
/// automáticamente para cualquier tipo que implemente [`Theme`], por lo que normalmente no requiere
/// implementación explícita.
///
/// ```rust
/// use pagetop::prelude::*;
/// Si un tema **sobrescribe** [`render_page_head()`](Theme::render_page_head) o
/// [`render_page_body()`](Theme::render_page_body), se puede volver al comportamiento por defecto
/// cuando se necesite usando FQS (*Fully Qualified Syntax*):
///
/// pub struct MyTheme;
/// - `<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.
///
/// 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 {
/// Recorre `regions` en el **orden declarado** y, para cada región con contenido, genera un
/// contenedor con `role="region"` y un `aria-label` localizado. Se asume que cada identificador
/// de región es **único** dentro de la página.
fn render_body(&self, page: &mut Page, regions: &[(Region, L10n)]) -> Markup {
html! {
body id=[page.body_id().get()] class=[page.body_classes().get()] {
@for (region_name, _) in self.regions() {
@let output = page.render_region(region_name);
@for (region, region_label) in regions {
@let output = page.render_region(region.key());
@if !output.is_empty() {
div id=(region_name) class={ "region-container region-" (region_name) } {
@let region_name = region.name();
div
id=(region_name)
class={ "region region--" (region_name) }
role="region"
aria-label=[region_label.lookup(page)]
{
(output)
}
}
@ -55,10 +52,12 @@ pub trait Theme: Extension + Send + Sync {
}
}
#[allow(unused_variables)]
fn after_render_page_body(&self, page: &mut Page) {}
fn render_page_head(&self, page: &mut Page) -> Markup {
/// Renderiza el contenido del `<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 {
let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no";
html! {
head {
@ -88,12 +87,115 @@ pub trait Theme: Extension + Send + Sync {
}
}
}
fn error403(&self, _page: &mut Page) -> Markup {
html! { div { h1 { ("FORBIDDEN ACCESS") } } }
}
fn error404(&self, _page: &mut Page) -> Markup {
html! { div { h1 { ("RESOURCE NOT FOUND") } } }
/// 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"))]
}
/// 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)) } } }
}
}
/// 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, CONTENT_REGION_NAME};
use crate::core::theme::ThemeRef;
use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock;
@ -7,25 +7,81 @@ use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::LazyLock;
// Regiones globales con componentes para un tema dado.
// Conjunto de regiones globales asociadas a un tema específico.
static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, ChildrenInRegions>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
// Regiones globales con componentes para cualquier tema.
// Conjunto de regiones globales comunes a todos los temas.
static COMMON_REGIONS: LazyLock<RwLock<ChildrenInRegions>> =
LazyLock::new(|| RwLock::new(ChildrenInRegions::default()));
// Estructura interna para mantener los componentes de una región.
/// Nombre de la región de contenido por defecto (`"content"`).
pub const REGION_CONTENT: &str = "content";
/// Identificador de una región de página.
///
/// Incluye una **clave estática** ([`key()`](Self::key)) que identifica la región en el tema, y un
/// **nombre normalizado** ([`name()`](Self::name)) en minúsculas para su uso en atributos HTML
/// (p.ej., clases `region__{name}`).
///
/// Se utiliza para declarar las regiones que componen una página en un tema (ver
/// [`page_regions()`](crate::core::theme::Theme::page_regions)).
pub struct Region {
key: &'static str,
name: String,
}
impl Default for Region {
#[inline]
fn default() -> Self {
Self {
key: REGION_CONTENT,
name: REGION_CONTENT.to_string(),
}
}
}
impl Region {
/// Declara una región a partir de su clave estática.
///
/// Genera además un nombre normalizado de la clave, eliminando espacios iniciales y finales,
/// convirtiendo a minúsculas y sustituyendo los espacios intermedios por guiones (`-`).
///
/// Esta clave se usará para añadir componentes a la región; por ello se recomiendan nombres
/// sencillos, limitando los caracteres a `[a-z0-9-]` (p.ej., `"sidebar"` o `"main-menu"`), cuyo
/// nombre normalizado coincidirá con la clave.
#[inline]
pub fn declare(key: &'static str) -> Self {
Self {
key,
name: key.trim().to_ascii_lowercase().replace(' ', "-"),
}
}
/// Devuelve la clave estática asignada a la región.
#[inline]
pub fn key(&self) -> &'static str {
self.key
}
/// Devuelve el nombre normalizado de la región (para atributos y búsquedas).
#[inline]
pub fn name(&self) -> &str {
&self.name
}
}
// Contenedor interno de componentes agrupados por región.
#[derive(AutoDefault)]
pub struct ChildrenInRegions(HashMap<&'static str, Children>);
impl ChildrenInRegions {
pub fn with(region_name: &'static str, child: Child) -> Self {
ChildrenInRegions::default().with_child_in_region(region_name, ChildOp::Add(child))
ChildrenInRegions::default().with_child_in(region_name, ChildOp::Add(child))
}
#[builder_fn]
pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self {
if let Some(region) = self.0.get_mut(region_name) {
region.alter_child(op);
} else {
@ -48,25 +104,24 @@ impl ChildrenInRegions {
}
}
/// Permite añadir componentes a regiones globales o regiones de temas concretos.
/// Punto de acceso para añadir componentes a regiones globales o específicas de un tema.
///
/// Dada una región, según la variante seleccionada, se le podrán añadir ([`add()`](Self::add))
/// componentes que se mantendrán durante la ejecución de la aplicación.
/// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán
/// disponibles durante toda la ejecución.
///
/// Estas estructuras de componentes se renderizarán automáticamente al procesar los documentos HTML
/// que las usan, como las páginas de contenido ([`Page`](crate::response::page::Page)), por
/// ejemplo.
/// Estos componentes se renderizarán automáticamente al procesar los documentos HTML que incluyen
/// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)).
pub enum InRegion {
/// Representa la región por defecto en la que se pueden añadir componentes.
/// Región de contenido por defecto.
Content,
/// Representa la región con el nombre del argumento.
/// Región identificada por el nombre proporcionado.
Named(&'static str),
/// Representa la región con el nombre y del tema especificado en los argumentos.
/// Región identificada por un nombre y asociada a un tema concreto.
OfTheme(&'static str, ThemeRef),
}
impl InRegion {
/// Permite añadir un componente en la región de la variante seleccionada.
/// Añade un componente a la región indicada por la variante.
///
/// # Ejemplo
///
@ -88,17 +143,17 @@ impl InRegion {
InRegion::Content => {
COMMON_REGIONS
.write()
.alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(child));
.alter_child_in(REGION_CONTENT, ChildOp::Add(child));
}
InRegion::Named(name) => {
InRegion::Named(region_name) => {
COMMON_REGIONS
.write()
.alter_child_in_region(name, ChildOp::Add(child));
.alter_child_in(region_name, ChildOp::Add(child));
}
InRegion::OfTheme(region_name, theme_ref) => {
let mut regions = THEME_REGIONS.write();
if let Some(r) = regions.get_mut(&theme_ref.type_id()) {
r.alter_child_in_region(region_name, ChildOp::Add(child));
r.alter_child_in(region_name, ChildOp::Add(child));
} else {
regions.insert(
theme_ref.type_id(),

View file

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

View file

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

View file

@ -2,25 +2,55 @@ pub mod favicon;
pub mod javascript;
pub mod stylesheet;
use crate::html::{html, Markup, Render};
use crate::html::{html, Context, Markup};
use crate::{AutoDefault, Weight};
pub trait AssetsTrait: Render {
// Devuelve el nombre del recurso, utilizado como clave única.
/// Representación genérica de un script [`JavaScript`](crate::html::JavaScript) o una hoja de
/// estilos [`StyleSheet`](crate::html::StyleSheet).
///
/// Estos recursos se incluyen en los conjuntos de recursos ([`Assets`]) que suelen renderizarse en
/// un documento HTML.
///
/// Cada recurso se identifica por un **nombre único** ([`Asset::name()`]), usado como clave; y un
/// **peso** ([`Asset::weight()`]), que determina su orden relativo de renderizado.
pub trait Asset {
/// Devuelve el nombre del recurso, utilizado como clave única.
fn name(&self) -> &str;
// Devuelve el peso del recurso, durante el renderizado se procesan de menor a mayor peso.
/// Devuelve el peso del recurso, usado para ordenar el renderizado de menor a mayor peso.
fn weight(&self) -> Weight;
/// Renderiza el recurso en el contexto proporcionado.
fn render(&self, cx: &mut Context) -> Markup;
}
/// Gestión común para conjuntos de recursos como [`JavaScript`](crate::html::JavaScript) y
/// [`StyleSheet`](crate::html::StyleSheet).
///
/// Se emplea normalmente para agrupar, administrar y renderizar los recursos de un documento HTML.
/// Cada recurso se identifica por un nombre único ([`Asset::name()`]) y tiene asociado un peso
/// ([`Asset::weight()`]) que determina su orden de renderizado.
///
/// Durante el renderizado, los recursos se procesan en orden ascendente de peso. En caso de
/// igualdad, se respeta el orden de inserción.
#[derive(AutoDefault)]
pub(crate) struct Assets<T>(Vec<T>);
pub struct Assets<T>(Vec<T>);
impl<T: AssetsTrait> Assets<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.
pub fn new() -> Self {
Assets::<T>(Vec::<T>::new())
Self(Vec::new())
}
/// Inserta un recurso.
///
/// Si no existe otro con el mismo nombre, lo añade. Si ya existe y su peso era mayor, lo
/// reemplaza. Y si su peso era menor o igual, entonces no realiza ningún cambio.
///
/// Devuelve `true` si el recurso fue insertado o reemplazado.
pub fn add(&mut self, asset: T) -> bool {
match self.0.iter().position(|x| x.name() == asset.name()) {
Some(index) => {
@ -39,6 +69,9 @@ impl<T: AssetsTrait> 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);
@ -47,16 +80,13 @@ impl<T: AssetsTrait> Assets<T> {
false
}
}
}
impl<T: AssetsTrait> Render for Assets<T> {
fn render(&self) -> Markup {
pub fn render(&self, cx: &mut Context) -> Markup {
let mut assets = self.0.iter().collect::<Vec<_>>();
assets.sort_by_key(|a| a.weight());
html! {
@for a in assets {
(a.render())
(a.render(cx))
}
}
}

View file

@ -1,4 +1,4 @@
use crate::html::{html, Markup, Render};
use crate::html::{html, Context, Markup};
use crate::AutoDefault;
/// Un **Favicon** es un recurso gráfico que usa el navegador como icono asociado al sitio.
@ -129,7 +129,7 @@ impl Favicon {
icon_color: Option<String>,
) -> Self {
let icon_type = match icon_source.rfind('.') {
Some(i) => match icon_source[i..].to_owned().to_lowercase().as_str() {
Some(i) => match icon_source[i..].to_string().to_lowercase().as_str() {
".avif" => Some("image/avif"),
".gif" => Some("image/gif"),
".ico" => Some("image/x-icon"),
@ -151,10 +151,12 @@ impl Favicon {
});
self
}
}
impl Render for Favicon {
fn render(&self) -> Markup {
/// Renderiza el **Favicon** completo con todas las etiquetas declaradas.
///
/// El parámetro `Context` se acepta por coherencia con el resto de *assets*, aunque en este
/// caso es ignorado.
pub fn render(&self, _cx: &mut Context) -> Markup {
html! {
@for item in &self.0 {
(item)

View file

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

View file

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

View file

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

63
src/html/attr_id.rs Normal file
View file

@ -0,0 +1,63 @@
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()
}
}

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

@ -0,0 +1,68 @@
use crate::html::Markup;
use crate::locale::{L10n, LangId};
use crate::{builder_fn, AutoDefault};
/// Texto para [traducir](crate::locale) en atributos HTML.
///
/// Encapsula un [`L10n`] para manejar traducciones de forma segura en atributos.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Traducción por clave en las locales por defecto de PageTop.
/// let hello = AttrL10n::new(L10n::l("test-hello-world"));
///
/// // Español disponible.
/// assert_eq!(
/// hello.lookup(&LangMatch::resolve("es-ES")),
/// Some("¡Hola mundo!".to_string())
/// );
///
/// // Japonés no disponible, traduce al idioma de respaldo ("en-US").
/// assert_eq!(
/// hello.lookup(&LangMatch::resolve("ja-JP")),
/// Some("Hello world!".to_string())
/// );
///
/// // Uso típico en un atributo:
/// let title = hello.value(&LangMatch::resolve("es-ES"));
/// // Ejemplo: html! { a title=(title) { "Link" } }
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct AttrL10n(L10n);
impl AttrL10n {
/// Crea una nueva instancia `AttrL10n`.
pub fn new(value: L10n) -> Self {
AttrL10n(value)
}
// AttrL10n BUILDER ****************************************************************************
/// Establece una traducción nueva.
#[builder_fn]
pub fn with_value(mut self, value: L10n) -> Self {
self.0 = value;
self
}
// AttrL10n GETTERS ****************************************************************************
/// Devuelve la traducción para `language`, si existe.
pub fn lookup(&self, language: &impl LangId) -> Option<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)
}
}

63
src/html/attr_name.rs Normal file
View file

@ -0,0 +1,63 @@
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()
}
}

65
src/html/attr_value.rs Normal file
View file

@ -0,0 +1,65 @@
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,13 +7,10 @@ use crate::locale::{LangId, LangMatch, LanguageIdentifier, DEFAULT_LANGID, FALLB
use crate::service::HttpRequest;
use crate::{builder_fn, join};
use std::any::Any;
use std::collections::HashMap;
use std::error::Error;
use std::str::FromStr;
use std::fmt;
/// Operaciones para modificar el contexto ([`Context`]) del documento.
/// Operaciones para modificar el contexto ([`Context`]) de un documento.
pub enum AssetsOp {
// Favicon.
/// Define el *favicon* del documento. Sobrescribe cualquier valor anterior.
@ -28,38 +25,138 @@ pub enum AssetsOp {
RemoveStyleSheet(&'static str),
// JavaScripts.
/// Añade un *script* JavaScript al documento.
/// Añade un script JavaScript al documento.
AddJavaScript(JavaScript),
/// Elimina un *script* por su ruta o identificador.
/// Elimina un script por su ruta o identificador.
RemoveJavaScript(&'static str),
}
/// Errores de lectura o conversión de parámetros almacenados en el contexto.
/// Errores de acceso a parámetros dinámicos del contexto.
///
/// - [`ErrorParam::NotFound`]: la clave no existe.
/// - [`ErrorParam::TypeMismatch`]: la clave existe, pero el valor guardado no coincide con el tipo
/// solicitado. Incluye nombre de la clave (`key`), tipo esperado (`expected`) y tipo realmente
/// guardado (`saved`) para facilitar el diagnóstico.
#[derive(Debug)]
pub enum ErrorParam {
/// El parámetro solicitado no existe.
NotFound,
/// El valor del parámetro no pudo convertirse al tipo requerido.
ParseError(String),
TypeMismatch {
key: &'static str,
expected: &'static str,
saved: &'static str,
},
}
impl fmt::Display for ErrorParam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorParam::NotFound => write!(f, "Parameter not found"),
ErrorParam::ParseError(e) => write!(f, "Parse error: {e}"),
}
}
}
impl Error for ErrorParam {}
/// Representa el contexto de un documento HTML.
/// Interfaz para gestionar el **contexto de renderizado** de un documento HTML.
///
/// Se crea internamente para manejar información relevante del documento, como la solicitud HTTP de
/// origen, el idioma, tema y composición para el renderizado, los recursos *favicon* ([`Favicon`]),
/// hojas de estilo ([`StyleSheet`]) y *scripts* ([`JavaScript`]), así como parámetros de contexto
/// definidos en tiempo de ejecución.
/// `Contextual` extiende [`LangId`] y define los métodos para:
///
/// - Establecer el **idioma** del documento.
/// - Almacenar la **solicitud HTTP** de origen.
/// - Seleccionar **tema** y **composición** (*layout*) de renderizado.
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`AssetsOp`].
/// - Leer y mantener **parámetros dinámicos tipados** de contexto.
/// - Generar **identificadores únicos** por tipo de componente.
///
/// Lo implementan, típicamente, estructuras que representan el contexto de renderizado, como
/// [`Context`](crate::html::Context) o [`Page`](crate::response::page::Page).
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// fn prepare_context<C: Contextual>(cx: C) -> C {
/// cx.with_langid(&LangMatch::resolve("es-ES"))
/// .with_theme("aliner")
/// .with_layout("default")
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js")))
/// .with_param("usuario_id", 42_i32)
/// }
/// ```
pub trait Contextual: LangId {
// Contextual BUILDER **************************************************************************
/// Establece el idioma del documento.
#[builder_fn]
fn with_langid(self, language: &impl LangId) -> Self;
/// Almacena la solicitud HTTP de origen en el contexto.
#[builder_fn]
fn with_request(self, request: Option<HttpRequest>) -> Self;
/// Especifica el tema para renderizar el documento.
#[builder_fn]
fn with_theme(self, theme_name: &'static str) -> Self;
/// Especifica la composición para renderizar el documento.
#[builder_fn]
fn with_layout(self, layout_name: &'static str) -> Self;
/// Añade o modifica un parámetro dinámico del contexto.
#[builder_fn]
fn with_param<T: 'static>(self, key: &'static str, value: T) -> Self;
/// Define los recursos del contexto usando [`AssetsOp`].
#[builder_fn]
fn with_assets(self, op: AssetsOp) -> Self;
// Contextual GETTERS **************************************************************************
/// Devuelve una referencia a la solicitud HTTP asociada, si existe.
fn request(&self) -> Option<&HttpRequest>;
/// Devuelve el tema que se usará para renderizar el documento.
fn theme(&self) -> ThemeRef;
/// Devuelve la composición para renderizar el documento. Por defecto es `"default"`.
fn layout(&self) -> &str;
/// Recupera un parámetro como [`Option`].
fn param<T: 'static>(&self, key: &'static str) -> Option<&T>;
/// Devuelve el parámetro clonado o el **valor por defecto del tipo** (`T::default()`).
fn param_or_default<T: Default + Clone + 'static>(&self, key: &'static str) -> T {
self.param::<T>(key).cloned().unwrap_or_default()
}
/// Devuelve el parámetro clonado o un **valor por defecto** si no existe.
fn param_or<T: Clone + 'static>(&self, key: &'static str, default: T) -> T {
self.param::<T>(key).cloned().unwrap_or(default)
}
/// Devuelve el parámetro clonado o el **valor evaluado** por la función `f` si no existe.
fn param_or_else<T: Clone + 'static, F: FnOnce() -> T>(&self, key: &'static str, f: F) -> T {
self.param::<T>(key).cloned().unwrap_or_else(f)
}
/// Devuelve el Favicon de los recursos del contexto.
fn favicon(&self) -> Option<&Favicon>;
/// Devuelve las hojas de estilo de los recursos del contexto.
fn stylesheets(&self) -> &Assets<StyleSheet>;
/// Devuelve los scripts JavaScript de los recursos del contexto.
fn javascripts(&self) -> &Assets<JavaScript>;
// Contextual HELPERS **************************************************************************
/// Genera un identificador único por tipo (`<tipo>-<n>`) cuando no se aporta uno explícito.
///
/// Es útil para componentes u otros elementos HTML que necesitan un identificador predecible si
/// no se proporciona ninguno.
fn required_id<T>(&mut self, id: Option<String>) -> String;
}
/// Implementa un **contexto de renderizado** para un documento HTML.
///
/// Extiende [`Contextual`] con métodos para **instanciar** y configurar un nuevo contexto,
/// **renderizar los recursos** del documento (incluyendo el [`Favicon`], las hojas de estilo
/// [`StyleSheet`] y los scripts [`JavaScript`]), o extender el uso de **parámetros dinámicos
/// tipados** con nuevos métodos.
///
/// # Ejemplos
///
@ -96,7 +193,7 @@ impl Error for ErrorParam {}
/// assert_eq!(active_theme.short_name(), "aliner");
///
/// // Recupera el parámetro a su tipo original.
/// let id: i32 = cx.get_param("usuario_id").unwrap();
/// let id: i32 = *cx.get_param::<i32>("usuario_id").unwrap();
/// assert_eq!(id, 42);
///
/// // Genera un identificador para un componente de tipo `Menu`.
@ -114,10 +211,16 @@ pub struct Context {
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts: Assets<JavaScript>, // Scripts JavaScript.
params : HashMap<&'static str, String>, // Parámetros definidos en tiempo de ejecución.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución.
id_counter : usize, // Contador para generar identificadores únicos.
}
impl Default for Context {
fn default() -> Self {
Context::new(None)
}
}
impl Context {
/// Crea un nuevo contexto asociado a una solicitud HTTP.
///
@ -147,40 +250,199 @@ impl Context {
favicon : None,
stylesheets: Assets::<StyleSheet>::new(),
javascripts: Assets::<JavaScript>::new(),
params : HashMap::<&str, String>::new(),
params : HashMap::default(),
id_counter : 0,
}
}
// Context BUILDER *****************************************************************************
// Context RENDER ******************************************************************************
/// Renderiza los recursos del contexto.
pub fn render_assets(&mut self) -> Markup {
use std::mem::take as mem_take;
// Extrae temporalmente los recursos.
let favicon = mem_take(&mut self.favicon); // Deja valor por defecto (None) en self.
let stylesheets = mem_take(&mut self.stylesheets); // Assets<StyleSheet>::default() en self.
let javascripts = mem_take(&mut self.javascripts); // Assets<JavaScript>::default() en self.
// Renderiza con `&mut self` como contexto.
let markup = html! {
@if let Some(fi) = &favicon {
(fi.render(self))
}
(stylesheets.render(self))
(javascripts.render(self))
};
// Restaura los campos tal y como estaban.
self.favicon = favicon;
self.stylesheets = stylesheets;
self.javascripts = javascripts;
markup
}
// Context PARAMS ******************************************************************************
/// Recupera una *referencia tipada* al parámetro solicitado.
///
/// Devuelve:
///
/// - `Ok(&T)` si la clave existe y el tipo coincide.
/// - `Err(ErrorParam::NotFound)` si la clave no existe.
/// - `Err(ErrorParam::TypeMismatch)` si la clave existe pero el tipo no coincide.
///
/// # Ejemplos
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let cx = Context::new(None)
/// .with_param("usuario_id", 42_i32)
/// .with_param("titulo", "Hola".to_string());
///
/// let id: &i32 = cx.get_param("usuario_id").unwrap();
/// let titulo: &String = cx.get_param("titulo").unwrap();
///
/// // Error de tipo:
/// assert!(cx.get_param::<String>("usuario_id").is_err());
/// ```
pub fn get_param<T: 'static>(&self, key: &'static str) -> Result<&T, ErrorParam> {
let (any, type_name) = self.params.get(key).ok_or(ErrorParam::NotFound)?;
any.downcast_ref::<T>()
.ok_or_else(|| ErrorParam::TypeMismatch {
key,
expected: TypeInfo::FullName.of::<T>(),
saved: type_name,
})
}
/// Recupera el parámetro solicitado y lo elimina del contexto.
///
/// Devuelve:
///
/// - `Ok(T)` si la clave existía y el tipo coincide.
/// - `Err(ErrorParam::NotFound)` si la clave no existe.
/// - `Err(ErrorParam::TypeMismatch)` si el tipo no coincide.
///
/// # Ejemplos
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let mut cx = Context::new(None)
/// .with_param("contador", 7_i32)
/// .with_param("titulo", "Hola".to_string());
///
/// let n: i32 = cx.take_param("contador").unwrap();
/// assert!(cx.get_param::<i32>("contador").is_err()); // ya no está
///
/// // Error de tipo:
/// assert!(cx.take_param::<i32>("titulo").is_err());
/// ```
pub fn take_param<T: 'static>(&mut self, key: &'static str) -> Result<T, ErrorParam> {
let (boxed, saved) = self.params.remove(key).ok_or(ErrorParam::NotFound)?;
boxed
.downcast::<T>()
.map(|b| *b)
.map_err(|_| ErrorParam::TypeMismatch {
key,
expected: TypeInfo::FullName.of::<T>(),
saved,
})
}
/// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó.
///
/// Devuelve `false` en caso contrario. Usar cuando solo interesa borrar la entrada.
///
/// # Ejemplos
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let mut cx = Context::new(None).with_param("temp", 1u8);
/// assert!(cx.remove_param("temp"));
/// assert!(!cx.remove_param("temp")); // ya no existe
/// ```
pub fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
}
}
/// Permite a [`Context`](crate::html::Context) actuar como proveedor de idioma.
///
/// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad:
///
/// 1. Un idioma válido establecido explícitamente con [`Context::with_langid`].
/// 2. El idioma por defecto configurado para la aplicación.
/// 3. Un idioma válido extraído de la cabecera `Accept-Language` del navegador.
/// 4. Y si ninguna de las opciones anteriores aplica, se usa el idioma de respaldo (`"en-US"`).
///
/// Resulta útil para usar un contexto ([`Context`]) como fuente de traducción en
/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using).
impl LangId for Context {
fn langid(&self) -> &'static LanguageIdentifier {
self.langid
}
}
impl Contextual for Context {
// Contextual BUILDER **************************************************************************
/// Modifica la fuente de idioma del documento.
#[builder_fn]
pub fn with_langid(mut self, language: &impl LangId) -> Self {
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.request = request;
self
}
#[builder_fn]
fn with_langid(mut self, language: &impl LangId) -> Self {
self.langid = language.langid();
self
}
/// Modifica el tema que se usará para renderizar el documento.
/// Asigna el tema para renderizar el documento.
///
/// Localiza el tema por su [`short_name()`](crate::core::AnyInfo::short_name), y si no aplica
/// ninguno entonces usará el tema por defecto.
#[builder_fn]
pub fn with_theme(mut self, theme_name: &'static str) -> Self {
fn with_theme(mut self, theme_name: &'static str) -> Self {
self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME);
self
}
/// Modifica la composición para renderizar el documento.
#[builder_fn]
pub fn with_layout(mut self, layout_name: &'static str) -> Self {
fn with_layout(mut self, layout_name: &'static str) -> Self {
self.layout = layout_name;
self
}
/// Define los recursos del contexto usando [`AssetsOp`].
/// Añade o modifica un parámetro dinámico del contexto.
///
/// El valor se guarda conservando el *nombre del tipo* real para mejorar los mensajes de error
/// posteriores.
///
/// # Ejemplos
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let cx = Context::new(None)
/// .with_param("usuario_id", 42_i32)
/// .with_param("titulo", "Hola".to_string())
/// .with_param("flags", vec!["a", "b"]);
/// ```
#[builder_fn]
pub fn with_assets(mut self, op: AssetsOp) -> Self {
fn with_param<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 {
match op {
// Favicon.
AssetsOp::SetFavicon(favicon) => {
@ -209,69 +471,74 @@ impl Context {
self
}
// Context GETTERS *****************************************************************************
// Contextual GETTERS **************************************************************************
/// Devuelve una referencia a la solicitud HTTP asociada, si existe.
pub fn request(&self) -> Option<&HttpRequest> {
fn request(&self) -> Option<&HttpRequest> {
self.request.as_ref()
}
/// Devuelve el tema que se usará para renderizar el documento.
pub fn theme(&self) -> ThemeRef {
fn theme(&self) -> ThemeRef {
self.theme
}
/// Devuelve la composición para renderizar el documento. Por defecto es `"default"`.
pub fn layout(&self) -> &str {
fn layout(&self) -> &str {
self.layout
}
// Context RENDER ******************************************************************************
/// Renderiza los recursos del contexto.
pub fn render_assets(&self) -> Markup {
html! {
@if let Some(favicon) = &self.favicon {
(favicon)
}
(self.stylesheets)
(self.javascripts)
}
}
// Context PARAMS ******************************************************************************
/// Añade o modifica un parámetro del contexto almacenando el valor como [`String`].
#[builder_fn]
pub fn with_param<T: ToString>(mut self, key: &'static str, value: T) -> Self {
self.params.insert(key, value.to_string());
self
}
/// Recupera un parámetro del contexto convertido al tipo especificado.
/// Recupera un parámetro como [`Option`], simplificando el acceso.
///
/// Devuelve un error si el parámetro no existe ([`ErrorParam::NotFound`]) o la conversión falla
/// ([`ErrorParam::ParseError`]).
pub fn get_param<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())))
/// 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()
}
/// Elimina un parámetro del contexto. Devuelve `true` si existía y se eliminó.
pub fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
fn favicon(&self) -> Option<&Favicon> {
self.favicon.as_ref()
}
// Context EXTRAS ******************************************************************************
fn stylesheets(&self) -> &Assets<StyleSheet> {
&self.stylesheets
}
/// Genera un identificador único si no se proporciona uno explícito.
fn javascripts(&self) -> &Assets<JavaScript> {
&self.javascripts
}
// Contextual HELPERS **************************************************************************
/// Devuelve un identificador único dentro del contexto para el tipo `T`, si no se proporciona
/// un `id` explícito.
///
/// Si no se proporciona un `id`, se genera un identificador único en la forma `<tipo>-<número>`
/// donde `<tipo>` es el nombre corto del tipo en minúsculas (sin espacios) y `<número>` es un
/// contador interno incremental.
pub fn required_id<T>(&mut self, id: Option<String>) -> String {
fn required_id<T>(&mut self, id: Option<String>) -> String {
if let Some(id) = id {
id
} else {
@ -281,7 +548,7 @@ impl Context {
.replace(' ', "_")
.to_lowercase();
let prefix = if prefix.is_empty() {
"prefix".to_owned()
"prefix".to_string()
} else {
prefix
};
@ -290,21 +557,3 @@ impl Context {
}
}
}
/// Permite a [`Context`](crate::html::Context) actuar como proveedor de idioma.
///
/// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad:
///
/// 1. Un idioma válido establecido explícitamente con [`Context::with_langid`].
/// 2. El idioma por defecto configurado para la aplicación.
/// 3. Un idioma válido extraído de la cabecera `Accept-Language` del navegador.
/// 4. Y si ninguna de las opciones anteriores aplica, se usa el idioma de respaldo (`"en-US"`).
///
/// Resulta útil para usar un contexto ([`Context`]) como fuente de traducción en
/// [`L10n::using()`](crate::locale::L10n::using) o
/// [`L10n::to_markup()`](crate::locale::L10n::to_markup).
impl LangId for Context {
fn langid(&self) -> &'static LanguageIdentifier {
self.langid
}
}

View file

@ -69,23 +69,6 @@ impl fmt::Write for Escaper<'_> {
/// `.render()` or `.render_to()`. Since the default definitions of
/// these methods call each other, not doing this will result in
/// infinite recursion.
///
/// # Example
///
/// ```rust
/// use pagetop::prelude::*;
///
/// /// Provides a shorthand for linking to a CSS stylesheet.
/// pub struct Stylesheet(&'static str);
///
/// impl Render for Stylesheet {
/// fn render(&self) -> Markup {
/// html! {
/// link rel="stylesheet" type="text/css" href=(self.0);
/// }
/// }
/// }
/// ```
pub trait Render {
/// Renders `self` as a block of `Markup`.
fn render(&self) -> Markup {
@ -238,6 +221,10 @@ impl Markup {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<T: Into<String>> PreEscaped<T> {

View file

@ -1,68 +0,0 @@
use crate::builder_fn;
use crate::core::component::{Component, Typed};
use crate::html::{html, Context, Markup};
/// Contenedor de componente para incluir en otros componentes.
///
/// Este tipo encapsula `Option<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! {}
}
}
}

View file

@ -1,58 +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.
///
/// # 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
}
}

View file

@ -1,58 +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.
///
/// # 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
}
}

View file

@ -1,57 +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.
///
/// # 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

@ -1,65 +0,0 @@
use crate::html::Markup;
use crate::locale::{L10n, LangId};
use crate::{builder_fn, AutoDefault};
/// Cadena para traducir al renderizar ([`locale`](crate::locale)).
///
/// Encapsula un tipo [`L10n`] para manejar traducciones de forma segura.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// // Traducción por clave en las locales por defecto de PageTop.
/// let hello = OptionTranslated::new(L10n::l("test-hello-world"));
///
/// // Español disponible.
/// assert_eq!(
/// hello.using(&LangMatch::resolve("es-ES")),
/// Some(String::from("¡Hola mundo!"))
/// );
///
/// // Japonés no disponible, traduce al idioma de respaldo ("en-US").
/// assert_eq!(
/// hello.using(&LangMatch::resolve("ja-JP")),
/// Some(String::from("Hello world!"))
/// );
///
/// // Para incrustar en HTML escapado:
/// let markup = hello.to_markup(&LangMatch::resolve("es-ES"));
/// assert_eq!(markup.into_string(), "¡Hola mundo!");
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct OptionTranslated(L10n);
impl OptionTranslated {
/// Crea una nueva instancia [`OptionTranslated`].
pub fn new(value: L10n) -> Self {
OptionTranslated(value)
}
// OptionTranslated BUILDER ********************************************************************
/// Establece una traducción nueva.
#[builder_fn]
pub fn with_value(mut self, value: L10n) -> Self {
self.0 = value;
self
}
// OptionTranslated GETTERS ********************************************************************
/// Devuelve la traducción para `language`, si existe.
pub fn using(&self, language: &impl LangId) -> Option<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(Some(request))
.with_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
Page::new(request)
.add_component(Html::with(move |_| html! { h1 { "Hello World!" } }))
.render()
}
@ -77,11 +77,11 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági
# 🧩 Gestión de Dependencias
Los proyectos que utilizan `PageTop` gestionan las dependencias con `cargo`, como cualquier otro
Los proyectos que utilizan PageTop gestionan las dependencias con `cargo`, como cualquier otro
proyecto en Rust.
Sin embargo, es fundamental que cada extensión declare explícitamente sus
[dependencias](core::extension::Extension::dependencies), si las tiene, para que `PageTop` pueda
[dependencias](core::extension::Extension::dependencies), si las tiene, para que PageTop pueda
estructurar e inicializar la aplicación de forma modular.
*/
@ -138,7 +138,7 @@ pub type Weight = i8;
// API *********************************************************************************************
// Funciones y macros útiles.
// Macros y funciones útiles.
pub mod util;
// Carga las opciones de configuración.
pub mod config;

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 entonces resuelve como [`FALLBACK_LANGID`].
// de idioma no es válido o no está disponible, se usa [`FALLBACK_LANGID`].
pub(crate) static DEFAULT_LANGID: LazyLock<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 (con el idioma base).
/// // Coincidencia parcial (retrocede al idioma base si no hay variante regional).
/// 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(String::from("ja-JP")));
/// assert_eq!(lang, LangMatch::Unsupported("ja-JP".to_string()));
/// ```
///
/// 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 otro caso indica que el idioma no está soportado.
Self::Unsupported(String::from(language))
// En caso contrario, indica que el idioma no está soportado.
Self::Unsupported(language.to_string())
}
/// 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("jp-JP").as_option();
/// let lang = LangMatch::resolve("ja-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::using()`]
/// o [`L10n::to_markup()`].
/// Resulta útil para usar un valor de [`LangMatch`] como fuente de traducción en [`L10n::lookup()`]
/// o [`L10n::using()`].
impl LangId for LangMatch {
fn langid(&self) -> &'static LanguageIdentifier {
match self {
@ -271,10 +271,10 @@ impl LangId for LangMatch {
}
#[macro_export]
/// Define un conjunto de elementos de localización y textos de traducción local.
/// Incluye un conjunto de recursos **Fluent** y textos de traducción propios.
macro_rules! include_locales {
// Se eliminan las marcas de aislamiento Unicode en los argumentos para mejorar la legibilidad y
// la compatibilidad en ciertos contextos de renderizado.
// 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.
( $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(String),
Translate(String),
Text(Cow<'static, str>),
Translate(Cow<'static, str>),
}
/// 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 usando `with_arg()` o `with_args()`.
/// Los argumentos dinámicos se añaden con `with_arg()` o `with_args()`.
///
/// ```rust
/// use pagetop::prelude::*;
@ -338,11 +338,11 @@ enum L10nOp {
/// .get();
/// ```
///
/// También para traducciones a idiomas concretos.
/// También sirve para traducciones contra un conjunto de recursos concreto.
///
/// ```rust,ignore
/// // Traducción con clave, conjunto de traducciones y fuente de idioma.
/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).using(&LangMatch::resolve("it"));
/// let bye = L10n::t("goodbye", &LOCALES_CUSTOM).lookup(&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<String>) -> Self {
pub fn n(text: impl Into<Cow<'static, str>>) -> 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<String>) -> Self {
pub fn l(key: impl Into<Cow<'static, str>>) -> 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<String>, locales: &'static Locales) -> Self {
pub fn t(key: impl Into<Cow<'static, str>>, locales: &'static Locales) -> Self {
L10n {
op: L10nOp::Translate(key.into()),
locales,
@ -399,7 +399,8 @@ impl L10n {
self
}
/// Resuelve la traducción usando el idioma por defecto o de respaldo de la aplicación.
/// Resuelve la traducción usando el idioma por defecto o, si no procede, el de respaldo de la
/// aplicación.
///
/// Devuelve `None` si no aplica o no encuentra una traducción válida.
///
@ -411,7 +412,7 @@ impl L10n {
/// let text = L10n::l("greeting").with_arg("name", "Manuel").get();
/// ```
pub fn get(&self) -> Option<String> {
self.using(&LangMatch::default())
self.lookup(&LangMatch::default())
}
/// Resuelve la traducción usando la fuente de idioma proporcionada.
@ -432,20 +433,27 @@ impl L10n {
/// }
///
/// let r = ResourceLang;
/// let text = L10n::l("greeting").with_arg("name", "Usuario").using(&r);
/// let text = L10n::l("greeting").with_arg("name", "Usuario").lookup(&r);
/// ```
pub fn using(&self, language: &impl LangId) -> Option<String> {
pub fn lookup(&self, language: &impl LangId) -> Option<String> {
match &self.op {
L10nOp::None => None,
L10nOp::Text(text) => Some(text.to_owned()),
L10nOp::Translate(key) => self.locales.try_lookup_with_args(
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,
&self.args.iter().fold(HashMap::new(), |mut arg, (k, v)| {
arg.insert(Cow::Owned(k.clone()), v.to_owned().into());
arg
}),
),
key.as_ref(),
&self
.args
.iter()
.map(|(k, v)| (Cow::Owned(k.clone()), v.clone().into()))
.collect::<HashMap<_, _>>(),
)
}
}
}
}
@ -458,10 +466,16 @@ impl L10n {
/// ```rust
/// use pagetop::prelude::*;
///
/// let html = L10n::l("welcome.message").to_markup(&LangMatch::resolve("es"));
/// let html = L10n::l("welcome.message").using(&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 {
PreEscaped(self.using(language).unwrap_or_default())
self.using(language)
}
}

13
src/locale/en-US/base.ftl Normal file
View file

@ -0,0 +1,13 @@
# 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,2 +1,9 @@
content = Content
# Regions.
region_header = Header
region_content = Content
region_footer = Footer
error403_notice = FORBIDDEN ACCESS
error404_notice = RESOURCE NOT FOUND
pagetop_logo = PageTop Logo

View file

@ -1,21 +1,16 @@
welcome_extension_name = Default homepage
welcome_extension_description = Displays a landing page when none is configured.
welcome_extension_name = Default Homepage
welcome_extension_description = Displays a default homepage when none is configured.
welcome_page = Welcome Page
welcome_title = Hello world!
welcome_aria = Say hello to your { $app } installation
welcome_page = Welcome page
welcome_title = Hello, world!
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_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_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_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
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.

13
src/locale/es-ES/base.ftl Normal file
View file

@ -0,0 +1,13 @@
# 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,2 +1,9 @@
content = Contenido
# Regions.
region_header = Cabecera
region_content = Contenido
region_footer = Pie de página
error403_notice = ACCESO NO PERMITIDO
error404_notice = RECURSO NO ENCONTRADO
pagetop_logo = Logotipo de PageTop

View file

@ -1,21 +1,16 @@
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_aria = Saluda a tu instalación { $app }
welcome_page = Página de bienvenida
welcome_title = ¡Hola, mundo!
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_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_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_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
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.

View file

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

View file

@ -4,13 +4,16 @@ 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, CONTENT_REGION_NAME};
use crate::html::{html, AssetsOp, Context, Markup, DOCTYPE};
use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated};
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::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier};
use crate::service::HttpRequest;
use crate::{builder_fn, AutoDefault};
/// Representa una página HTML completa lista para renderizar.
///
@ -18,32 +21,33 @@ use crate::service::HttpRequest;
/// regiones donde disponer los componentes, atributos de `<body>` y otros aspectos del contexto de
/// renderizado.
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Page {
title : OptionTranslated,
description : OptionTranslated,
title : AttrL10n,
description : AttrL10n,
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.
///
/// 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.
/// 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: Option<HttpRequest>) -> Self {
pub fn new(request: HttpRequest) -> Self {
Page {
title : OptionTranslated::default(),
description : OptionTranslated::default(),
title : AttrL10n::default(),
description : AttrL10n::default(),
metadata : Vec::default(),
properties : Vec::default(),
context : Context::new(request),
body_id : OptionId::default(),
body_classes: OptionClasses::default(),
body_id : AttrId::default(),
body_classes: AttrClasses::default(),
context : Context::new(Some(request)),
regions : ChildrenInRegions::default(),
}
}
@ -78,34 +82,6 @@ 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 {
@ -113,35 +89,65 @@ impl Page {
self
}
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`OptionClasses`].
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`AttrClasses`].
#[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 with_component(mut self, component: impl Component) -> Self {
pub fn add_component(mut self, component: impl Component) -> Self {
self.regions
.alter_child_in_region(CONTENT_REGION_NAME, ChildOp::Add(Child::with(component)));
.alter_child_in(REGION_CONTENT, ChildOp::Add(Child::with(component)));
self
}
/// Añade un componente en una región (`region_name`) de la página.
pub fn with_component_in(
pub fn add_component_in(
mut self,
region_name: &'static str,
component: impl Component,
) -> Self {
self.regions
.alter_child_in_region(region_name, ChildOp::Add(Child::with(component)));
.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);
self
}
/// Opera con [`ChildOp`] en una región (`region_name`) de la página.
#[builder_fn]
pub fn with_child_in_region(mut self, region_name: &'static str, op: ChildOp) -> Self {
self.regions.alter_child_in_region(region_name, op);
pub fn with_child_in(mut self, region_name: &'static str, op: ChildOp) -> Self {
self.regions.alter_child_in(region_name, op);
self
}
@ -149,12 +155,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.using(&self.context)
self.title.lookup(&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.using(&self.context)
self.description.lookup(&self.context)
}
/// Devuelve la lista de metadatos `<meta name=...>`.
@ -167,39 +173,28 @@ 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) -> &OptionId {
pub fn body_id(&self) -> &AttrId {
&self.body_id
}
/// Devuelve las clases CSS del elemento `<body>`.
pub fn body_classes(&self) -> &OptionClasses {
pub fn body_classes(&self) -> &AttrClasses {
&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 (`regiona_name`) de la página.
/// Renderiza los componentes de una región (`region_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)
@ -207,7 +202,7 @@ impl Page {
}
/// Renderiza los recursos de la página.
pub fn render_assets(&self) -> Markup {
pub fn render_assets(&mut self) -> Markup {
self.context.render_assets()
}
@ -250,3 +245,85 @@ 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,4 +1,5 @@
use crate::base::component::Html;
use crate::html::Contextual;
use crate::locale::L10n;
use crate::response::ResponseError;
use crate::service::http::{header::ContentType, StatusCode};
@ -6,7 +7,7 @@ use crate::service::{HttpRequest, HttpResponse};
use super::Page;
use std::fmt;
use std::fmt::{self, Display};
#[derive(Debug)]
pub enum ErrorPage {
@ -19,7 +20,7 @@ pub enum ErrorPage {
Timeout(HttpRequest),
}
impl fmt::Display for ErrorPage {
impl Display for ErrorPage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// Error 304.
@ -28,12 +29,12 @@ impl fmt::Display for ErrorPage {
ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"),
// Error 403.
ErrorPage::AccessDenied(request) => {
let mut error_page = Page::new(Some(request.clone()));
let mut error_page = Page::new(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")
.with_component(Html::with(move |_| error403.clone()))
.add_component(Html::with(move |_| error403.clone()))
.render()
{
write!(f, "{}", page.into_string())
@ -43,12 +44,12 @@ impl fmt::Display for ErrorPage {
}
// Error 404.
ErrorPage::NotFound(request) => {
let mut error_page = Page::new(Some(request.clone()));
let mut error_page = Page::new(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")
.with_component(Html::with(move |_| error404.clone()))
.add_component(Html::with(move |_| error404.clone()))
.render()
{
write!(f, "{}", page.into_string())

View file

@ -17,31 +17,6 @@ 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 {
@ -69,48 +44,6 @@ 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 @@
//! Funciones y macros útiles.
//! Macros y funciones útiles.
use crate::trace;
@ -6,6 +6,198 @@ 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.
@ -56,8 +248,8 @@ pub fn resolve_absolute_dir<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
}
}
/// Devuelve la ruta absoluta a un directorio existente.
#[deprecated(since = "0.3.0", note = "Use [`resolve_absolute_dir`] instead")]
/// **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")]
pub fn absolute_dir<P, Q>(root_path: P, relative_path: Q) -> io::Result<PathBuf>
where
P: AsRef<Path>,
@ -65,191 +257,3 @@ 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))
}
}};
}

11
static/css/basic.css Normal file
View file

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

View file

@ -1,13 +1,17 @@
:root {
--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-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-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);
@ -28,9 +32,14 @@ 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;
@ -50,20 +59,17 @@ a:hover:visited {
text-decoration-color: var(--color-link);
}
#content {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/*
* Header
*/
#main-header {
.intro-header {
display: flex;
flex-direction: column-reverse;
padding-bottom: 9rem;
max-width: 80rem;
width: 100%;
max-width: 80rem;
margin: 0 auto;
padding-bottom: 9rem;
background-image: var(--bg-img-sm);
background-image: var(--bg-img-sm-set);
background-position: top center;
@ -71,11 +77,11 @@ a:hover:visited {
background-size: contain;
background-repeat: no-repeat;
}
#main-header header {
.intro-header__body {
padding: 0;
background: none;
}
#header-title {
.intro-header__title {
margin: 0 0 0 1.5rem;
text-align: left;
display: flex;
@ -89,7 +95,7 @@ a:hover:visited {
line-height: 110%;
text-shadow: 0 0.125rem 0.1875rem rgba(0, 0, 0, 0.3);
}
#header-title > span {
.intro-header__title > span {
background: linear-gradient(180deg, #ddff95 30%, #ffb84b 100%);
background-clip: text;
-webkit-background-clip: text;
@ -100,40 +106,44 @@ a:hover:visited {
line-height: 110%;
text-shadow: none;
}
#header-image {
width: 100%;
text-align: right;
.intro-header__image {
display: flex;
justify-content: flex-start;
text-align: right;
width: 100%;
}
#header-image #monster {
.intro-header__monster {
margin-right: 12rem;
margin-top: 1rem;
flex-shrink: 1;
}
@media (min-width: 64rem) {
#main-header {
.intro-header {
background-image: var(--bg-img);
background-image: var(--bg-img-set);
}
#header-title {
.intro-header__title {
padding: 1.2rem 2rem 2.6rem 2rem;
}
#header-image {
.intro-header__image {
justify-content: flex-end;
}
}
#main-content {
/*
* Content
*/
.intro-content {
height: auto;
margin-top: 1.6rem;
}
.content-body {
.intro-content__body {
box-sizing: border-box;
max-width: 80rem;
}
.content-body:before,
.content-body:after {
.intro-content__body:before,
.intro-content__body:after {
content: '';
position: absolute;
left: 0;
@ -143,38 +153,37 @@ a:hover:visited {
filter: blur(2.75rem);
opacity: 0.8;
inset: 11.75rem;
z-index: 0;
}
.content-body:before {
.intro-content__body:before {
top: -1rem;
}
.content-body:after {
.intro-content__body:after {
bottom: -1rem;
}
@media (max-width: 48rem) {
.content-body {
.intro-content__body {
margin-top: -9.8rem;
}
.content-body:before,
.content-body:after {
.intro-content__body:before,
.intro-content__body:after {
inset: unset;
}
}
@media (min-width: 64rem) {
#main-content {
.intro-content {
margin-top: 0;
}
.content-body {
.intro-content__body {
margin-top: -5.7rem;
}
}
#poweredby-button {
.intro-button {
width: 100%;
margin: 0 auto 3rem;
z-index: 10;
}
#poweredby-link {
.intro-button__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;
@ -187,7 +196,6 @@ 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;
@ -195,7 +203,7 @@ a:hover:visited {
min-height: 7.6875rem;
outline: none;
}
#poweredby-link::before {
.intro-button__link::before {
content: '';
position: absolute;
top: -13.125rem;
@ -207,7 +215,7 @@ a:hover:visited {
transition: transform 0.3s ease-in-out;
z-index: 5;
}
#poweredby-text {
.intro-button__text {
display: flex;
flex-direction: column;
flex: 1;
@ -217,25 +225,24 @@ 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;
}
#poweredby-text strong {
.intro-button__text strong {
font-size: 2.625rem;
font-weight: 600;
line-height: 130.023%;
letter-spacing: 0.013125rem;
}
#poweredby-link span {
.intro-button__link span {
position: absolute;
display: block;
pointer-events: none;
}
#poweredby-link span:nth-child(1) {
.intro-button__link span:nth-child(1) {
height: 8px;
width: 100%;
top: 0;
@ -255,7 +262,7 @@ a:hover:visited {
transform: translateX(100%);
}
}
#poweredby-link span:nth-child(2) {
.intro-button__link span:nth-child(2) {
width: 8px;
height: 100%;
top: 0;
@ -275,7 +282,7 @@ a:hover:visited {
transform: translateY(100%);
}
}
#poweredby-link span:nth-child(3) {
.intro-button__link span:nth-child(3) {
height: 8px;
width: 100%;
bottom: 0;
@ -295,27 +302,22 @@ a:hover:visited {
transform: translateX(-100%);
}
}
#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 {
.intro-button__link:hover span {
animation-play-state: paused;
}
@media (max-width: 48rem) {
#poweredby-link {
.intro-button__link {
height: 6.25rem;
min-width: auto;
border-radius: 0;
}
#poweredby-text {
.intro-button__text {
display: inline;
padding-top: .5rem;
}
}
@media (min-width: 48rem) {
#poweredby-button {
.intro-button {
position: absolute;
top: 0;
left: 50%;
@ -323,9 +325,13 @@ a:hover:visited {
max-width: 29.375rem;
margin-bottom: 0;
}
.intro-button__link:hover {
transition: all .5s;
transform: rotate(-3deg) scale(1.1);
}
}
.content-text {
.intro-text {
z-index: 1;
width: 100%;
display: flex;
@ -337,13 +343,16 @@ a:hover:visited {
font-weight: 400;
line-height: 1.5;
margin-top: -6rem;
background: #fff;
margin-bottom: 0;
background: #fff;
position: relative;
padding: 6rem 1.063rem 0.75rem;
padding: 2.5rem 1.063rem 0.75rem;
overflow: hidden;
}
.content-text p {
.intro-button + .intro-text {
padding-top: 6rem;
}
.intro-text p {
width: 100%;
line-height: 150%;
font-weight: 400;
@ -351,14 +360,16 @@ a:hover:visited {
margin: 0 0 1.5rem;
}
@media (min-width: 48rem) {
.content-text {
.intro-text {
font-size: 1.375rem;
line-height: 2rem;
}
.intro-button + .intro-text {
padding-top: 7rem;
}
}
@media (min-width: 64rem) {
.content-text {
.intro-text {
border-radius: 0.75rem;
box-shadow: var(--shadow);
max-width: 60rem;
@ -368,13 +379,13 @@ a:hover:visited {
}
}
.subcontent {
.intro-text .block {
position: relative;
}
.subcontent h1 {
.intro-text .block__title {
margin: 1em 0 .8em;
}
.subcontent h1 span {
.intro-text .block__title span {
display: inline-block;
padding: 10px 30px 14px;
margin: 0 0 0 20px;
@ -385,7 +396,7 @@ a:hover:visited {
border-color: orangered;
transform: rotate(-3deg) translateY(-25%);
}
.subcontent h1:before {
.intro-text .block__title:before {
content: "";
height: 5px;
position: absolute;
@ -398,7 +409,7 @@ a:hover:visited {
transform: rotate(2deg) translateY(-50%);
transform-origin: top left;
}
.subcontent h1:after {
.intro-text .block__title:after {
content: "";
height: 70rem;
position: absolute;
@ -406,55 +417,80 @@ a:hover:visited {
left: -15%;
width: 130%;
z-index: -10;
background: var(--color-red);
background: var(--color-block-1);
transform: rotate(2deg);
}
.intro-text .block:nth-of-type(5n+1) .block__title:after {
background: var(--color-block-1);
}
.intro-text .block:nth-of-type(5n+2) .block__title:after {
background: var(--color-block-2);
}
.intro-text .block:nth-of-type(5n+3) .block__title:after {
background: var(--color-block-3);
}
.intro-text .block:nth-of-type(5n+4) .block__title:after {
background: var(--color-block-4);
}
.intro-text .block:nth-of-type(5n+5) .block__title:after {
background: var(--color-block-5);
}
#footer {
/*
* Footer
*/
.intro-footer {
width: 100%;
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;
}
#footer a:visited {
.intro-footer__body a:visited {
color: var(--color-gray);
}
.footer-logo {
max-height: 12.625rem;
}
.footer-logo svg {
width: 100%;
}
.footer-logo,
.footer-links,
.footer-inner {
.intro-footer__logo,
.intro-footer__links {
display: flex;
justify-content: center;
width: 100%;
}
.footer-links {
.intro-footer__logo {
max-height: 12.625rem;
}
.intro-footer__logo svg {
width: 100%;
}
.intro-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) {
.footer-logo {
.intro-footer__logo {
display: none;
}
}
@media (max-width: 64rem) {
.footer-inner {
.intro-footer__body {
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", String::from("Alice"));
let mut cx = Context::new(None).with_param("username", "Alice".to_string());
let component = Html::with(|cx| {
let name = cx.get_param::<String>("username").unwrap_or_default();
let name = cx.param::<String>("username").cloned().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_html(|_| html! { div { "Modified" } });
component.alter_fn(|_| html! { div { "Modified" } });
let markup = component
.prepare_component(&mut Context::new(None))

View file

@ -0,0 +1,99 @@
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,17 +1,108 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn prepare_markup_is_empty() {
let _app = service::test::init_service(Application::new().test()).await;
async fn prepare_markup_render_none_is_empty_string() {
assert_eq!(PrepareMarkup::None.render().as_str(), "");
}
#[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::Text(String::from("")).is_empty());
assert!(!PrepareMarkup::Text(String::from("x")).is_empty());
assert!(PrepareMarkup::Escaped(String::new()).is_empty());
assert!(!PrepareMarkup::Escaped("a".into()).is_empty());
assert!(PrepareMarkup::Escaped("".to_string()).is_empty());
assert!(!PrepareMarkup::Escaped("x".to_string()).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::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.using(&LangMatch::resolve("es-ES"));
let translation = l10n.lookup(&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.using(&LangMatch::resolve("es-ES"));
let translation = l10n.lookup(&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.using(&LangMatch::resolve("es-ES")).unwrap();
let translation = l10n.lookup(&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.using(&LangMatch::resolve("xx-YY")); // Retrocede a "en-US".
let translation = l10n.lookup(&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.using(&LangMatch::resolve("en-US"));
let translation = l10n.lookup(&LangMatch::resolve("en-US"));
assert_eq!(translation, None);
}