WIP: Preparando liberación de la versión 0.5 de PageTop #10

Draft
manuelcillero wants to merge 48 commits from preparing-release-zero-five into main
141 changed files with 5814 additions and 2968 deletions

130
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,130 @@
# Guía de contribución a PageTop
Gracias por tu interés en contribuir a **PageTop** 🎉
Este documento describe **cómo participar en el desarrollo del proyecto**, el flujo de trabajo y las
normas que permitan garantizar un historial limpio, trazable y sostenible a largo plazo.
Por favor, léelo completo antes de abrir una *issue* o una *pull request*.
## 1. Repositorios
PageTop mantiene **un único repositorio oficial**:
* **Repositorio oficial:** https://git.cillero.es/manuelcillero/pagetop
* **Repositorio espejo:** https://github.com/manuelcillero/pagetop
> ⚠️ **Importante**
> Aunque GitHub permite abrir *issues* y *pull requests*, **la integración del código se realiza
> únicamente en el repositorio oficial**. GitHub actúa como repositorio espejo que se sincroniza
> automáticamente para reflejar el mismo estado.
## 2. Issues (incidencias, propuestas, preguntas)
Antes de abrir una *issue* **en GitHub**:
* comprueba que no exista ya una similar,
* describe claramente el problema o propuesta,
* incluye pasos de reproducción si se trata de un *bug*,
* indica versión, entorno y contexto cuando sea relevante.
Las *issues* se usan para:
* informar de errores,
* propuestas de mejora,
* discusión técnica previa a cambios relevantes.
## 3. Pull Requests (PRs)
### 3.1 Dónde abrirlas
Las *pull requests* se abren **en GitHub**, normalmente contra la rama `main`. GitHub es el punto de
entrada recomendado para contribuciones externas.
### 3.2 Reglas generales para PRs
* Cada PR debe abordar **un único objetivo claro**.
* Mantén el alcance lo más acotado posible.
* Incluye descripción clara del cambio.
* Si el PR corrige una *issue*, enlázala explícitamente.
* Asegúrate de que el código compila y pasa las pruebas.
### 3.3 Revisión y aceptación
Todas las PRs son **revisadas manualmente** y pueden recibir comentarios o solicitudes de cambios.
Las PRs aceptadas se integran en el repositorio oficial, nunca directamente en GitHub, preservando
siempre la **autoría original** del contribuidor.
### 3.4. Cierre de Pull Requests y sincronización
Una vez que el cambio ha sido integrado en el repositorio oficial:
* La PR en GitHub se **cierra manualmente**.
* Se añade un **mensaje estándar de cierre** indicando que el cambio ha sido integrado.
* El repositorio de GitHub **se sincroniza automáticamente** como espejo.
## 4. Estilo de código y calidad
* Sigue el estilo existente del proyecto.
* Mantén los comentarios claros y precisos.
* La documentación es parte del código: actualízala cuando sea necesario.
* Cambios públicos o estructurales deben ir acompañados de documentación.
## 5. Commits
PageTop usa la especificación **gitmoji** para los mensajes de *commit*. El formato recomendado es:
```text
<propósito> [(ámbito opcional):] <mensaje>
«LÍNEA EN BLANCO»
Cuerpo opcional
«LÍNEA EN BLANCO»
Nota(s) al pie opcional(es) para referencias, incidencias o cambios incompatibles
```
Ejemplos (no más de 50 caracteres en la primera línea, y no más de 80 en el resto):
* `📝 Actualiza la guía de contribución`
* `♻️ (locale): Refactoriza sistema de localización`
* Un mensaje completo:
```
🎨 (bootsier): Mejora la asignación de clases
- Simplifica la generación de clases CSS para componentes Bootstrap.
- Elimina duplicidades en enums de estilos y centraliza la lógica de composición
para reducir errores y facilitar mantenimiento.
- Alinea los nombres de variantes con la documentación pública.
Refs: PR #123
```
El emoji puede usarse en formato Unicode o como *shortcode*, por ejemplo `:sparkles:` en vez de ✨.
Consulta la especificación oficial en https://gitmoji.dev/specification
Durante la integración, los *commits* pueden ajustarse para adaptarse al historial del proyecto.
Un *commit* debe representar una unidad lógica de cambio. Usa mensajes claros y descriptivos.
## 6. Comunicación y respeto
PageTop sigue un enfoque profesional y colaborativo:
* Sé respetuoso en revisiones y discusiones.
* Acepta sugerencias técnicas como parte del proceso.
* Recuerda que todas las contribuciones son revisadas con el objetivo de mejorar el proyecto.
Si tienes dudas sobre el proceso, abre una *issue* de tipo pregunta para tratar la cuestión en
comunidad.
---
Gracias por contribuir a **PageTop** 🚀 Cada aportación contribuye a mejorar el proyecto.

47
Cargo.lock generated
View file

@ -982,6 +982,17 @@ dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "getter-methods"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43d815f896a3c730f0d76b8348a1700dc8d8fd6c377e4590d531bdd646574d8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "ghash"
version = "0.5.1"
@ -1073,9 +1084,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hkdf"
@ -1279,19 +1290,22 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
[[package]]
name = "indexmap"
version = "2.11.4"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
"hashbrown 0.16.0",
"hashbrown 0.16.1",
]
[[package]]
name = "indoc"
version = "2.0.6"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
@ -1554,19 +1568,19 @@ dependencies = [
"actix-web",
"chrono",
"colored",
"concat-string",
"config",
"figlet-rs",
"fluent-templates",
"indoc",
"getter-methods",
"indexmap",
"itoa",
"pagetop-aliner",
"pagetop-bootsier",
"pagetop-build",
"pagetop-macros",
"pagetop-minimal",
"pagetop-statics",
"parking_lot",
"pastey",
"serde",
"serde_json",
"substring",
@ -1614,6 +1628,15 @@ dependencies = [
"syn",
]
[[package]]
name = "pagetop-minimal"
version = "0.0.10"
dependencies = [
"concat-string",
"indoc",
"pastey",
]
[[package]]
name = "pagetop-statics"
version = "0.1.2"
@ -1651,9 +1674,9 @@ dependencies = [
[[package]]
name = "pastey"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
checksum = "57d6c094ee800037dff99e02cab0eaf3142826586742a270ab3d7a62656bd27a"
[[package]]
name = "path-matchers"

View file

@ -17,13 +17,12 @@ authors.workspace = true
[dependencies]
chrono = "0.4"
colored = "3.0"
concat-string = "1.0"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "0.1"
indoc = "2.0"
getter-methods = "2.0"
itoa = "1.0"
indexmap = "2.12"
parking_lot = "0.12"
paste = { package = "pastey", version = "0.1" }
substring = "1.4"
terminal_size = "0.4"
@ -42,6 +41,7 @@ actix-web-files = { package = "actix-files", version = "0.6" }
serde.workspace = true
pagetop-macros.workspace = true
pagetop-minimal.workspace = true
pagetop-statics.workspace = true
[features]
@ -64,6 +64,7 @@ members = [
# Helpers
"helpers/pagetop-build",
"helpers/pagetop-macros",
"helpers/pagetop-minimal",
"helpers/pagetop-statics",
# Extensions
"extensions/pagetop-aliner",
@ -82,6 +83,7 @@ serde = { version = "1.0", features = ["derive"] }
# Helpers
pagetop-build = { version = "0.3", path = "helpers/pagetop-build" }
pagetop-macros = { version = "0.2", path = "helpers/pagetop-macros" }
pagetop-minimal = { version = "0.0", path = "helpers/pagetop-minimal" }
pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" }
# Extensions
pagetop-aliner = { version = "0.0", path = "extensions/pagetop-aliner" }

156
MAINTAINERS.md Normal file
View file

@ -0,0 +1,156 @@
# MAINTAINERS.md
## Guía para mantenedores de PageTop
Este documento describe **el flujo técnico interno de revisión e integración de contribuciones** en
**PageTop**.
Está dirigido a **mantenedores del proyecto** y **no forma parte de la guía de contribución para
usuarios externos**. Su objetivo es servir como **referencia operativa**, garantizando coherencia,
trazabilidad y preservación de la autoría en un entorno con repositorios espejo.
## 1. Repositorios y principios
PageTop mantiene **un único repositorio oficial**:
* **Repositorio oficial:** https://git.cillero.es/manuelcillero/pagetop
* **Repositorio espejo:** https://github.com/manuelcillero/pagetop
### Principios clave
* El repositorio oficial **es la única fuente de verdad** del historial.
* **Nunca se realizan *merges* en GitHub**.
* Toda integración definitiva se realiza en el repositorio oficial.
* La autoría original debe preservarse siempre.
## 2. Configuración local recomendada
El remoto `github` debe configurarse únicamente para operaciones de lectura (*fetch*), con la URL de
*push* deshabilitada para evitar publicaciones accidentales en el repositorio espejo.
Estado esperado de `git remote -v`:
```text
origin git@git.cillero.es:manuelcillero/pagetop.git (fetch)
origin git@git.cillero.es:manuelcillero/pagetop.git (push)
github git@github.com:manuelcillero/pagetop.git (fetch)
github DISABLED (push)
```
Convenciones usadas en este documento:
* `origin` -> Repositorio oficial
* `github` -> Repositorio espejo
## 3. Recepción y revisión de Pull Requests
Las PRs externas llegan por GitHub, normalmente contra la rama `main`.
Se asume que el repositorio local está configurado para recuperar PRs de GitHub como referencias
remotas (`refs/pull/<N>/head`):
```bash
git fetch github --prune
git checkout -b pr-123 github/pr/123
```
Antes de integrar:
* Revisar el código manualmente.
* Verificar formato, análisis y pruebas:
```bash
cargo fmt
cargo clippy
cargo test
```
* Comprobar impacto en documentación.
* Evaluar coherencia con la arquitectura y el estilo del proyecto.
Los cambios adicionales se solicitan o se aplican explicando claramente el motivo.
## 4. Estrategia de integración
La integración **se realiza siempre en el repositorio oficial** (`origin`).
### 4.1 Estrategia por defecto: *rebase* + *fast-forward*
Esta es la **estrategia estándar y recomendada** en PageTop. Ventajas:
* conserva los commits originales,
* preserva la autoría real de cada cambio,
* mantiene un historial lineal y trazable,
* facilita auditoría y depuración.
Procedimiento típico:
```bash
git checkout pr-123
git rebase main
# Resolver conflictos si los hay
git checkout main
git merge --ff-only pr-123
```
Si `merge --ff-only` falla, **no se debe continuar**, indica divergencias que deben resolverse antes
de integrar la PR.
### 4.2 Estrategia excepcional: *Squash*
Sólo debe usarse cuando esté justificado:
* la PR contiene múltiples commits de prueba o ruido,
* el historial aportado no es significativo,
* el cambio es pequeño y autocontenido.
En este caso, se debe **preservar explícitamente la autoría**:
```bash
git checkout main
git merge --squash pr-123
git commit --author="Nombre Apellido <email@ejemplo.com>"
```
### 4.3. Publicación en el repositorio oficial
```bash
git push origin main
```
Este *push* representa la **integración definitiva** del cambio en la rama `main`.
## 5. Cierre de la PR y sincronización
Tras integrar el cambio en el repositorio oficial, se cierra manualmente la PR en GitHub con un
mensaje estándar:
```text
Gracias por tu contribución.
Este cambio ha sido integrado en el repositorio oficial en `main` (`<hash>`).
GitHub actúa como repositorio espejo sincronizado.
```
## 6. Principios de mantenimiento
* Priorizar **claridad y trazabilidad** frente a rapidez.
* Mantener un historial legible y significativo.
* Documentar cambios estructurales o públicos.
* Tratar las contribuciones externas con respeto y transparencia.
---
Este documento puede evolucionar con el proyecto.
No se trata de imponer rigidez, sino de **capturar el conocimiento operativo real** de PageTop como
guía práctica para el mantenimiento.

View file

@ -29,7 +29,7 @@ según las necesidades de cada proyecto, incluyendo:
componentes sin comprometer su funcionalidad.
# ⚡️ Guía rápida
## ⚡️ Guía rápida
La aplicación más sencilla de PageTop se ve así:
@ -74,7 +74,7 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági
(`/`) mostrando el texto "Hello world!" dentro de un elemento HTML `<h1>`.
# 📂 Repositorio
## 📂 Proyecto
El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos:
@ -82,21 +82,25 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
fuente de la librería principal. Reúne algunos de los *crates* más estables y populares del
ecosistema Rust para proporcionar APIs y recursos para la creación avanzada de soluciones web.
## Auxiliares
* **[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.
### Auxiliares
* **[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.
* **[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 procedurales que mejoran la experiencia de desarrollo con
PageTop.
## Extensiones
* **[pagetop-minimal](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-minimal)**,
ofrece macros declarativas esenciales para optimizar tareas comunes como la composición de
texto, la concatenación de cadenas y el manejo de colecciones clave-valor.
* **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**,
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.
### Extensiones
* **[pagetop-aliner](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-aliner)**,
es un tema para demos y pruebas que muestra esquemáticamente la composición de las páginas HTML.
@ -106,7 +110,7 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
componentes flexibles.
# 🧪 Pruebas
## 🧪 Pruebas
Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de Cargo** declarados en
`.cargo/config.toml`. Basta con ejecutarlos desde la raíz del proyecto:
@ -123,14 +127,14 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
> Si quieres **activar** las trazas del registro de eventos entonces usa simplemente `cargo test`.
# 🚧 Advertencia
## 🚧 Advertencia
**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**.
# 📜 Licencia
## 📜 Licencia
El código está disponible bajo una doble licencia:
@ -144,7 +148,28 @@ Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el es
el ecosistema Rust.
# ✨ Contribuir
## ✨ Contribuir
PageTop mantiene **un único repositorio oficial**:
* **Repositorio oficial:** https://git.cillero.es/manuelcillero/pagetop
* **Repositorio espejo:** https://github.com/manuelcillero/pagetop
El repositorio de GitHub actúa como espejo y punto de entrada para:
* dar mayor visibilidad al proyecto,
* facilitar la participación de la comunidad,
* centralizar *issues* y *pull requests* externas.
Aunque GitHub permite abrir *pull requests*, **la integración del código se realiza únicamente en el
repositorio oficial**. El repositorio de GitHub se sincroniza posteriormente para reflejar el mismo
estado.
En todos los casos, se respeta la **autoría original** de las contribuciones integradas, tanto en el
historial como en la documentación asociada al cambio.
Para conocer el proceso completo de participación, revisión e integración de cambios, consulta el
archivo [`CONTRIBUTING.md`](CONTRIBUTING.md).
Cualquier contribución para añadir al proyecto se considerará automáticamente bajo la doble licencia
indicada arriba (MIT o Apache v2.0), sin términos o condiciones adicionales, tal y como permite la

View file

@ -14,7 +14,11 @@ async fn hello_name(
) -> ResultPage<Markup, ErrorPage> {
let name = path.into_inner();
Page::new(request)
.add_child(Html::with(move |_| html! { h1 { "Hello " (name) "!" } }))
.add_child(Html::with(move |_| {
html! {
h1 style="text-align: center;" { "Hello " (name) "!" }
}
}))
.render()
}

View file

@ -10,7 +10,11 @@ impl Extension for HelloWorld {
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.add_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.add_child(Html::with(|_| {
html! {
h1 style="text-align: center;" { "Hello World!" }
}
}))
.render()
}

View file

@ -10,22 +10,16 @@ impl Extension for SuperMenu {
}
fn initialize(&self) {
let home_path = |cx: &Context| match cx.langid().language.as_str() {
"en" => "/en",
_ => "/",
};
let navbar_menu = Navbar::brand_left(navbar::Brand::new().with_path(Some(home_path)))
let navbar_menu = Navbar::brand_left(navbar::Brand::new())
.with_expand(BreakPoint::LG)
.add_item(navbar::Item::nav(
Nav::new()
.add_item(nav::Item::link(
L10n::l("sample_menus_item_link"),
home_path,
))
.add_item(nav::Item::link(L10n::l("sample_menus_item_link"), |cx| {
cx.route("/")
}))
.add_item(nav::Item::link_blank(
L10n::l("sample_menus_item_blank"),
|_| "https://docs.rs/pagetop",
|_| "https://docs.rs/pagetop".into(),
))
.add_item(nav::Item::dropdown(
Dropdown::new()
@ -33,28 +27,28 @@ impl Extension for SuperMenu {
.add_item(dropdown::Item::header(L10n::l("sample_menus_dev_header")))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_dev_getting_started"),
|_| "/dev/getting-started",
|cx| cx.route("/dev/getting-started"),
))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_dev_guides"),
|_| "/dev/guides",
|cx| cx.route("/dev/guides"),
))
.add_item(dropdown::Item::link_blank(
L10n::l("sample_menus_dev_forum"),
|_| "https://forum.example.dev",
|_| "https://forum.example.dev".into(),
))
.add_item(dropdown::Item::divider())
.add_item(dropdown::Item::header(L10n::l("sample_menus_sdk_header")))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_sdk_rust"),
|_| "/dev/sdks/rust",
|cx| cx.route("/dev/sdks/rust"),
))
.add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |_| {
"/dev/sdks/js"
.add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |cx| {
cx.route("/dev/sdks/js")
}))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_sdk_python"),
|_| "/dev/sdks/python",
|cx| cx.route("/dev/sdks/python"),
))
.add_item(dropdown::Item::divider())
.add_item(dropdown::Item::header(L10n::l(
@ -62,22 +56,22 @@ impl Extension for SuperMenu {
)))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_plugin_auth"),
|_| "/dev/sdks/rust/plugins/auth",
|cx| cx.route("/dev/sdks/rust/plugins/auth"),
))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_plugin_cache"),
|_| "/dev/sdks/rust/plugins/cache",
|cx| cx.route("/dev/sdks/rust/plugins/cache"),
))
.add_item(dropdown::Item::divider())
.add_item(dropdown::Item::label(L10n::l("sample_menus_item_label")))
.add_item(dropdown::Item::link_disabled(
L10n::l("sample_menus_item_disabled"),
|_| "#",
|cx| cx.route("#"),
)),
))
.add_item(nav::Item::link_disabled(
L10n::l("sample_menus_item_disabled"),
|_| "#",
|cx| cx.route("#"),
)),
))
.add_item(navbar::Item::nav(
@ -88,14 +82,14 @@ impl Extension for SuperMenu {
)
.add_item(nav::Item::link(
L10n::l("sample_menus_item_sign_up"),
|_| "/auth/sign-up",
|cx| cx.route("/auth/sign-up"),
))
.add_item(nav::Item::link(L10n::l("sample_menus_item_login"), |_| {
"/auth/login"
.add_item(nav::Item::link(L10n::l("sample_menus_item_login"), |cx| {
cx.route("/auth/login")
})),
));
InRegion::Named("header").add(Child::with(
InRegion::Global(&DefaultRegion::Header).add(Child::with(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.add_child(navbar_menu),

View file

@ -12,14 +12,14 @@
<br>
</div>
## Sobre PageTop
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
# ⚡️ Guía rápida
## ⚡️ Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
@ -80,14 +80,14 @@ async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
```
# 🚧 Advertencia
## 🚧 Advertencia
**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**.
# 📜 Licencia
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -104,12 +104,25 @@ impl Extension for Aliner {
}
impl Theme for Aliner {
fn after_render_page_body(&self, page: &mut Page) {
page.alter_param("include_basic_assets", true)
.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/aliner/css/styles.css")
.with_version(env!("CARGO_PKG_VERSION"))
.with_weight(-90),
));
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/basic.css")
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))
.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/aliner/css/styles.css")
.with_version(env!("CARGO_PKG_VERSION"))
.with_weight(-99),
))
.alter_child_in(
&DefaultRegion::Footer,
ChildOp::AddIfEmpty(Child::with(PoweredBy::new())),
);
}
}

View file

@ -12,14 +12,14 @@
<br>
</div>
## Sobre PageTop
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
# ⚡️ Guía rápida
## ⚡️ Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
@ -80,14 +80,14 @@ async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
```
# 🚧 Advertencia
## 🚧 Advertencia
**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**.
# 📜 Licencia
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -102,6 +102,34 @@ pub mod prelude {
pub use crate::theme::*;
}
/// Plantillas que Bootsier añade.
#[derive(AutoDefault)]
pub enum BootsierTemplate {
/// Plantilla predeterminada de Bootsier.
#[default]
Standard,
}
impl Template for BootsierTemplate {
fn render(&'static self, cx: &mut Context) -> Markup {
match self {
Self::Standard => theme::Container::new()
.with_classes(ClassesOp::Add, "container-wrapper")
.with_width(theme::container::Width::FluidMax(
config::SETTINGS.bootsier.max_width,
))
.add_child(Html::with(|cx| {
html! {
(DefaultRegion::Header.render(cx))
(DefaultRegion::Content.render(cx))
(DefaultRegion::Footer.render(cx))
}
})),
}
.render(cx)
}
}
/// Implementa el tema.
pub struct Bootsier;
@ -117,7 +145,12 @@ impl Extension for Bootsier {
}
impl Theme for Bootsier {
fn after_render_page_body(&self, page: &mut Page) {
#[inline]
fn default_template(&self) -> TemplateRef {
&BootsierTemplate::Standard
}
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/bootsier/bs/bootstrap.min.css")
.with_version(BOOTSTRAP_VERSION)
@ -127,6 +160,10 @@ impl Theme for Bootsier {
JavaScript::defer("/bootsier/js/bootstrap.bundle.min.js")
.with_version(BOOTSTRAP_VERSION)
.with_weight(-90),
));
))
.alter_child_in(
&DefaultRegion::Footer,
ChildOp::AddIfEmpty(Child::with(PoweredBy::new())),
);
}
}

View file

@ -1,5 +1,5 @@
e404-description = Oops! Page Not Found
e404-message = The page you are looking for may have been removed, had its name changed, or is temporarily unavailable.
e500-description = Oops! Unexpected Error
e500-message = We're having an issue. Please report this error to an administrator.
back-homepage = Back to homepage
e404_description = Oops! Page Not Found
e404_message = The page you are looking for may have been removed, had its name changed, or is temporarily unavailable.
e500_description = Oops! Unexpected Error
e500_message = We're having an issue. Please report this error to an administrator.
back_homepage = Back to homepage

View file

@ -1,8 +1,11 @@
# Dropdown
dropdown_toggle = Toggle Dropdown
# Offcanvas
offcanvas_close = Close
# form::Input
input_required = This field is required
# Navbar
toggle = Toggle navigation
# Offcanvas
offcanvas_close = Close

View file

@ -1,9 +1,9 @@
header = Header
nav_branding = Navigation branding region
nav_main = Main navigation region
nav_additional = Additional navigation region (eg search form, social icons, etc)
breadcrumb = Breadcrumb
content = Main content
sidebar_first = Sidebar first
sidebar_second = Sidebar second
footer = Footer
region_header = Header
region_nav_branding = Navigation branding region
region_nav_main = Main navigation region
region_nav_additional = Additional navigation region (eg search form, social icons, etc)
region_breadcrumb = Breadcrumb
region_content = Main content
region_sidebar_first = Sidebar first
region_sidebar_second = Sidebar second
region_footer = Footer

View file

@ -1,5 +1,5 @@
e404-description = ¡Vaya! Página No Encontrada
e404-message = La página que está buscando puede haber sido eliminada, cambiada de nombre o no está disponible temporalmente.
e500-description = ¡Vaya! Error Inesperado
e500-message = Está ocurriendo una incidencia. Por favor, informe de este error a un administrador.
back-homepage = Volver al inicio
e404_description = ¡Vaya! Página No Encontrada
e404_message = La página que está buscando puede haber sido eliminada, cambiada de nombre o no está disponible temporalmente.
e500_description = ¡Vaya! Error Inesperado
e500_message = Está ocurriendo una incidencia. Por favor, informe de este error a un administrador.
back_homepage = Volver al inicio

View file

@ -1,8 +1,11 @@
# Dropdown
dropdown_toggle = Mostrar/ocultar menú
# Offcanvas
offcanvas_close = Cerrar
# form::Input
input_required = Este campo es obligatorio
# Navbar
toggle = Mostrar/ocultar navegación
# Offcanvas
offcanvas_close = Cerrar

View file

@ -1,9 +1,9 @@
header = Cabecera
nav_branding = Navegación y marca
nav_main = Navegación principal
nav_additional = Navegación adicional (p.e. formulario de búsqueda, iconos sociales, etc.)
breadcrumb = Ruta de posicionamiento
content = Contenido principal
sidebar_first = Barra lateral primera
sidebar_second = Barra lateral segunda
footer = Pie
region_header = Cabecera
region_nav_branding = Navegación y marca
region_nav_main = Navegación principal
region_nav_additional = Navegación adicional (p.e. formulario de búsqueda, iconos sociales, etc.)
region_breadcrumb = Ruta de posicionamiento
region_content = Contenido principal
region_sidebar_first = Barra lateral primera
region_sidebar_second = Barra lateral segunda
region_footer = Pie

View file

@ -19,6 +19,11 @@ pub mod dropdown;
#[doc(inline)]
pub use dropdown::Dropdown;
// Form.
pub mod form;
#[doc(inline)]
pub use form::Form;
// Image.
pub mod image;
#[doc(inline)]

View file

@ -22,7 +22,7 @@ impl BorderColor {
const BORDER: &str = "border";
const BORDER_PREFIX: &str = "border-";
// Devuelve el sufijo de la clase `border-*`, o `None` si no define ninguna clase.
/// Devuelve el sufijo de la clase `border-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -35,7 +35,7 @@ impl BorderColor {
}
}
// Añade la clase `border-*` a la cadena de clases.
/// Añade la clase `border-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
@ -64,7 +64,6 @@ impl BorderColor {
/// assert_eq!(BorderColor::Black.to_class(), "border-black");
/// assert_eq!(BorderColor::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {

View file

@ -19,7 +19,7 @@ pub enum BreakPoint {
}
impl BreakPoint {
// Devuelve la identificación del punto de ruptura.
/// Devuelve la identificación del punto de ruptura.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
@ -33,11 +33,11 @@ impl BreakPoint {
}
}
// Añade el punto de ruptura con un prefijo y un sufijo (opcional) separados por un guion `-` a
// la cadena de clases.
//
// - Para `None` - `prefix` o `prefix-suffix` (si `suffix` no está vacío).
// - Para `SM..XXL` - `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
/// Añade el punto de ruptura con un prefijo y un sufijo (opcional) separados por un guion `-` a
/// la cadena de clases.
///
/// - Para `None` - `prefix` o `prefix-suffix` (si `suffix` no está vacío).
/// - Para `SM..XXL` - `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str, suffix: &str) {
if prefix.is_empty() {
@ -60,30 +60,30 @@ impl BreakPoint {
}
}
// Devuelve la clase para el punto de ruptura, con un prefijo y un sufijo opcional, separados
// por un guion `-`.
//
// - Para `None` - `prefix` o `prefix-suffix` (si `suffix` no está vacío).
// - Para `SM..XXL` - `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
// - Si `prefix` está vacío devuelve `""`.
//
// # Ejemplos
//
// ```rust
// # use pagetop_bootsier::prelude::*;
// let bp = BreakPoint::MD;
// assert_eq!(bp.class_with("col", ""), "col-md");
// assert_eq!(bp.class_with("col", "6"), "col-md-6");
//
// let bp = BreakPoint::None;
// assert_eq!(bp.class_with("offcanvas", ""), "offcanvas");
// assert_eq!(bp.class_with("col", "12"), "col-12");
//
// let bp = BreakPoint::LG;
// assert_eq!(bp.class_with("", "3"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str, suffix: &str) -> String {
/// Devuelve la clase para el punto de ruptura, con un prefijo y un sufijo opcional, separados
/// por un guion `-`.
///
/// - Para `None` - `prefix` o `prefix-suffix` (si `suffix` no está vacío).
/// - Para `SM..XXL` - `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
/// - Si `prefix` está vacío devuelve `""`.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let bp = BreakPoint::MD;
/// assert_eq!(bp.class_with("col", ""), "col-md");
/// assert_eq!(bp.class_with("col", "6"), "col-md-6");
///
/// let bp = BreakPoint::None;
/// assert_eq!(bp.class_with("offcanvas", ""), "offcanvas");
/// assert_eq!(bp.class_with("col", "12"), "col-12");
///
/// let bp = BreakPoint::LG;
/// assert_eq!(bp.class_with("", "3"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str, suffix: &str) -> String {
if prefix.is_empty() {
return String::new();
}

View file

@ -23,7 +23,7 @@ impl ButtonColor {
const BTN_OUTLINE_PREFIX: &str = "btn-outline-";
const BTN_LINK: &str = "btn-link";
// Añade la clase `btn-*` a la cadena de clases.
/// Añade la clase `btn-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Self::Default = self {
@ -65,7 +65,6 @@ impl ButtonColor {
/// assert_eq!(ButtonColor::Link.to_class(), "btn-link");
/// assert_eq!(ButtonColor::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
match self {
Self::Default => String::new(),
@ -106,7 +105,7 @@ impl ButtonSize {
const BTN_SM: &str = "btn-sm";
const BTN_LG: &str = "btn-lg";
// Añade la clase de tamaño `btn-sm` o `btn-lg` a la cadena de clases.
/// Añade la clase de tamaño `btn-sm` o `btn-lg` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Self::Default = self {
@ -132,7 +131,6 @@ impl ButtonSize {
/// assert_eq!(ButtonSize::Large.to_class(), "btn-lg");
/// assert_eq!(ButtonSize::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
match self {
Self::Default => String::new(),

View file

@ -23,7 +23,7 @@ pub enum Color {
}
impl Color {
// Devuelve el nombre del color.
/// Devuelve el nombre del color.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
@ -94,7 +94,7 @@ impl Opacity {
const OPACITY: &str = "opacity";
const OPACITY_PREFIX: &str = "-opacity";
// Devuelve el sufijo para `*opacity-*`, o `None` si no define ninguna clase.
/// Devuelve el sufijo para `*opacity-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -109,8 +109,8 @@ impl Opacity {
}
}
// Añade la opacidad a la cadena de clases usando el prefijo dado (`bg`, `border`, `text`, o
// vacío para `opacity-*`).
/// Añade la opacidad a la cadena de clases usando el prefijo dado (`bg`, `border`, `text`, o
/// vacío para `opacity-*`).
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str) {
if let Some(suffix) = self.suffix() {
@ -127,20 +127,20 @@ impl Opacity {
}
}
// Devuelve la clase de opacidad con el prefijo dado (`bg`, `border`, `text`, o vacío para
// `opacity-*`).
//
// # Ejemplos
//
// ```rust
// # use pagetop_bootsier::prelude::*;
// assert_eq!(Opacity::Opaque.class_with(""), "opacity-100");
// assert_eq!(Opacity::Half.class_with("bg"), "bg-opacity-50");
// assert_eq!(Opacity::SemiTransparent.class_with("text"), "text-opacity-25");
// assert_eq!(Opacity::Default.class_with("bg"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str) -> String {
/// Devuelve la clase de opacidad con el prefijo dado (`bg`, `border`, `text`, o vacío para
/// `opacity-*`).
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(Opacity::Opaque.class_with(""), "opacity-100");
/// assert_eq!(Opacity::Half.class_with("bg"), "bg-opacity-50");
/// assert_eq!(Opacity::SemiTransparent.class_with("text"), "text-opacity-25");
/// assert_eq!(Opacity::Default.class_with("bg"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str) -> String {
if let Some(suffix) = self.suffix() {
let base_len = if prefix.is_empty() {
Self::OPACITY.len()
@ -206,7 +206,7 @@ impl ColorBg {
const BG: &str = "bg";
const BG_PREFIX: &str = "bg-";
// Devuelve el sufijo de la clase `bg-*`, o `None` si no define ninguna clase.
/// Devuelve el sufijo de la clase `bg-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -223,7 +223,7 @@ impl ColorBg {
}
}
// Añade la clase de fondo `bg-*` a la cadena de clases.
/// Añade la clase de fondo `bg-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
@ -253,7 +253,6 @@ impl ColorBg {
/// assert_eq!(ColorBg::Transparent.to_class(), "bg-transparent");
/// assert_eq!(ColorBg::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {
@ -305,7 +304,7 @@ impl ColorText {
const TEXT: &str = "text";
const TEXT_PREFIX: &str = "text-";
// Devuelve el sufijo de la clase `text-*`, o `None` si no define ninguna clase.
/// Devuelve el sufijo de la clase `text-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -322,7 +321,7 @@ impl ColorText {
}
}
// Añade la clase de texto `text-*` a la cadena de clases.
/// Añade la clase de texto `text-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
@ -352,7 +351,6 @@ impl ColorText {
/// assert_eq!(ColorText::Black.to_class(), "text-black");
/// assert_eq!(ColorText::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {

View file

@ -25,8 +25,8 @@ pub enum ScaleSize {
}
impl ScaleSize {
// Devuelve el sufijo para el tamaño (`"-0"`, `"-1"`, etc.), o `None` si no define ninguna
// clase, o `""` para el tamaño automático.
/// Devuelve el sufijo para el tamaño (`"-0"`, `"-1"`, etc.), o `None` si no define ninguna
/// clase, o `""` para el tamaño automático.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -42,7 +42,7 @@ impl ScaleSize {
}
}
// Añade el tamaño a la cadena de clases usando el prefijo dado.
/// Añade el tamaño a la cadena de clases usando el prefijo dado.
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str) {
if !prefix.is_empty() {
@ -57,18 +57,18 @@ impl ScaleSize {
}
/* Devuelve la clase del tamaño para el prefijo, o una cadena vacía si no aplica (reservado).
//
// # Ejemplo
//
// ```rust
// # use pagetop_bootsier::prelude::*;
// assert_eq!(ScaleSize::Auto.class_with("border"), "border");
// assert_eq!(ScaleSize::Zero.class_with("m"), "m-0");
// assert_eq!(ScaleSize::Three.class_with("p"), "p-3");
// assert_eq!(ScaleSize::None.class_with("border"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str) -> String {
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(ScaleSize::Auto.class_with("border"), "border");
/// assert_eq!(ScaleSize::Zero.class_with("m"), "m-0");
/// assert_eq!(ScaleSize::Three.class_with("p"), "p-3");
/// assert_eq!(ScaleSize::None.class_with("border"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str) -> String {
if !prefix.is_empty() {
if let Some(suffix) = self.suffix() {
let mut class = String::with_capacity(prefix.len() + suffix.len());

View file

@ -29,8 +29,8 @@ pub enum RoundedRadius {
impl RoundedRadius {
const ROUNDED: &str = "rounded";
// Devuelve el sufijo para `*rounded-*`, o `None` si no define ninguna clase, o `""` para el
// redondeo por defecto.
/// Devuelve el sufijo para `*rounded-*`, o `None` si no define ninguna clase, o `""` para el
/// redondeo por defecto.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -48,8 +48,8 @@ impl RoundedRadius {
}
}
// Añade el redondeo de esquinas a la cadena de clases usando el prefijo dado (`rounded-top`,
// `rounded-bottom-start`, o vacío para `rounded-*`).
/// Añade el redondeo de esquinas a la cadena de clases usando el prefijo dado (`rounded-top`,
/// `rounded-bottom-start`, o vacío para `rounded-*`).
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str) {
if let Some(suffix) = self.suffix() {
@ -65,21 +65,21 @@ impl RoundedRadius {
}
}
// Devuelve la clase para el redondeo de esquinas con el prefijo dado (`rounded-top`,
// `rounded-bottom-start`, o vacío para `rounded-*`).
//
// # Ejemplos
//
// ```rust
// # use pagetop_bootsier::prelude::*;
// assert_eq!(RoundedRadius::Scale2.class_with(""), "rounded-2");
// assert_eq!(RoundedRadius::Zero.class_with("rounded-top"), "rounded-top-0");
// assert_eq!(RoundedRadius::Scale3.class_with("rounded-top-end"), "rounded-top-end-3");
// assert_eq!(RoundedRadius::Circle.class_with(""), "rounded-circle");
// assert_eq!(RoundedRadius::None.class_with("rounded-bottom-start"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str) -> String {
/// Devuelve la clase para el redondeo de esquinas con el prefijo dado (`rounded-top`,
/// `rounded-bottom-start`, o vacío para `rounded-*`).
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(RoundedRadius::Scale2.class_with(""), "rounded-2");
/// assert_eq!(RoundedRadius::Zero.class_with("rounded-top"), "rounded-top-0");
/// assert_eq!(RoundedRadius::Scale3.class_with("rounded-top-end"), "rounded-top-end-3");
/// assert_eq!(RoundedRadius::Circle.class_with(""), "rounded-circle");
/// assert_eq!(RoundedRadius::None.class_with("rounded-bottom-start"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str) -> String {
if let Some(suffix) = self.suffix() {
let base_len = if prefix.is_empty() {
Self::ROUNDED.len()

View file

@ -145,7 +145,6 @@ impl Border {
/// `"border border-top-0 border-end-3 border-primary border-opacity-50"`, etc.).
///
/// Si no se define ningún tamaño, color ni opacidad, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);

View file

@ -76,7 +76,6 @@ impl Background {
/// Devuelve las clases de fondo como cadena (`"bg-primary"`, `"bg-body-secondary bg-opacity-50"`, etc.).
///
/// Si no se define ni color ni opacidad, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
@ -189,7 +188,6 @@ impl Text {
/// etc.).
///
/// Si no se define ni color ni opacidad, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);

View file

@ -48,7 +48,7 @@ impl Margin {
// **< Margin HELPERS >*************************************************************************
// Devuelve el prefijo `m*` según el lado.
/// Devuelve el prefijo `m*` según el lado.
#[rustfmt::skip]
#[inline]
const fn side_prefix(&self) -> &'static str {
@ -63,7 +63,7 @@ impl Margin {
}
}
// Devuelve el sufijo del tamaño (`auto`, `0`..`5`), o `None` si no define clase.
/// Devuelve el sufijo del tamaño (`auto`, `0`..`5`), o `None` si no define clase.
#[rustfmt::skip]
#[inline]
const fn size_suffix(&self) -> Option<&'static str> {
@ -80,8 +80,8 @@ impl Margin {
}
/* Añade la clase de **margin** a la cadena de clases (reservado).
//
// No añade nada si `size` es `ScaleSize::None`.
///
/// No añade nada si `size` es `ScaleSize::None`.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let Some(size) = self.size_suffix() else {
@ -94,7 +94,6 @@ impl Margin {
/// Devuelve la clase de **margin** como cadena (`"mt-3"`, `"ms-lg-auto"`, etc.).
///
/// Si `size` es `ScaleSize::None`, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let Some(size) = self.size_suffix() else {
return String::new();
@ -148,7 +147,7 @@ impl Padding {
// **< Padding HELPERS >************************************************************************
// Devuelve el prefijo `p*` según el lado.
/// Devuelve el prefijo `p*` según el lado.
#[rustfmt::skip]
#[inline]
const fn prefix(&self) -> &'static str {
@ -163,9 +162,9 @@ impl Padding {
}
}
// Devuelve el sufijo del tamaño (`0`..`5`), o `None` si no define clase.
//
// Nota: `ScaleSize::Auto` **no aplica** a padding ⇒ devuelve `None`.
/// Devuelve el sufijo del tamaño (`0`..`5`), o `None` si no define clase.
///
/// Nota: `ScaleSize::Auto` **no aplica** a padding ⇒ devuelve `None`.
#[rustfmt::skip]
#[inline]
const fn suffix(&self) -> Option<&'static str> {
@ -182,8 +181,8 @@ impl Padding {
}
/* Añade la clase de **padding** a la cadena de clases (reservado).
//
// No añade nada si `size` es `ScaleSize::None` o `ScaleSize::Auto`.
///
/// No añade nada si `size` es `ScaleSize::None` o `ScaleSize::Auto`.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let Some(size) = self.suffix() else {
@ -192,10 +191,9 @@ impl Padding {
self.breakpoint.push_class(classes, self.prefix(), size);
} */
// Devuelve la clase de **padding** como cadena (`"px-2"`, `"pe-sm-4"`, etc.).
//
// Si `size` es `ScaleSize::None` o `ScaleSize::Auto`, devuelve `""`.
#[inline]
/// Devuelve la clase de **padding** como cadena (`"px-2"`, `"pe-sm-4"`, etc.).
///
/// Si `size` es `ScaleSize::None` o `ScaleSize::Auto`, devuelve `""`.
pub fn to_class(self) -> String {
let Some(size) = self.suffix() else {
return String::new();

View file

@ -160,7 +160,6 @@ impl Rounded {
/// `"rounded-top rounded-bottom-start-4 rounded-bottom-end-circle"`, etc.).
///
/// Si no se define ningún radio, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);

View file

@ -6,19 +6,23 @@ use crate::prelude::*;
///
/// Envuelve un contenido con la etiqueta HTML indicada por [`container::Kind`]. Sólo se renderiza
/// si existen componentes hijos (*children*).
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Container {
id : AttrId,
classes : AttrClasses,
container_kind : container::Kind,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al contenedor.
classes: Classes,
/// Devuelve el tipo semántico del contenedor.
container_kind: container::Kind,
/// Devuelve el comportamiento para el ancho del contenedor.
container_width: container::Width,
children : Children,
/// Devuelve la lista de componentes (`children`) del contenedor.
children: Children,
}
impl Component for Container {
fn new() -> Self {
Container::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -26,7 +30,7 @@ impl Component for Container {
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, self.width().to_class());
self.alter_classes(ClassesOp::Prepend, self.container_width().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
@ -34,9 +38,9 @@ impl Component for Container {
if output.is_empty() {
return PrepareMarkup::None;
}
let style = match self.width() {
let style = match self.container_width() {
container::Width::FluidMax(w) if w.is_measurable() => {
Some(join!("max-width: ", w.to_string(), ";"))
Some(util::join!("max-width: ", w.to_string(), ";"))
}
_ => None,
};
@ -78,7 +82,7 @@ impl Component for Container {
impl Container {
/// Crea un contenedor de tipo `Main` (`<main>`).
pub fn main() -> Self {
Container {
Self {
container_kind: container::Kind::Main,
..Default::default()
}
@ -86,7 +90,7 @@ impl Container {
/// Crea un contenedor de tipo `Header` (`<header>`).
pub fn header() -> Self {
Container {
Self {
container_kind: container::Kind::Header,
..Default::default()
}
@ -94,7 +98,7 @@ impl Container {
/// Crea un contenedor de tipo `Footer` (`<footer>`).
pub fn footer() -> Self {
Container {
Self {
container_kind: container::Kind::Footer,
..Default::default()
}
@ -102,7 +106,7 @@ impl Container {
/// Crea un contenedor de tipo `Section` (`<section>`).
pub fn section() -> Self {
Container {
Self {
container_kind: container::Kind::Section,
..Default::default()
}
@ -110,7 +114,7 @@ impl Container {
/// Crea un contenedor de tipo `Article` (`<article>`).
pub fn article() -> Self {
Container {
Self {
container_kind: container::Kind::Article,
..Default::default()
}
@ -121,7 +125,7 @@ impl Container {
/// Establece el identificador único (`id`) del contenedor.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -135,7 +139,7 @@ impl Container {
/// - Redondear las esquinas ([`classes::Rounded`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -159,26 +163,4 @@ impl Container {
self.children.alter_child(op);
self
}
// **< Container GETTERS >**********************************************************************
/// Devuelve las clases CSS asociadas al contenedor.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo semántico del contenedor.
pub fn container_kind(&self) -> &container::Kind {
&self.container_kind
}
/// Devuelve el comportamiento para el ancho del contenedor.
pub fn width(&self) -> &container::Width {
&self.container_width
}
/// Devuelve la lista de componentes (`children`) del contenedor.
pub fn children(&self) -> &Children {
&self.children
}
}

View file

@ -59,7 +59,6 @@ impl Width {
} */
/// Devuelve la clase asociada al comportamiento del contenedor según el ajuste de su ancho.
#[inline]
pub fn to_class(self) -> String {
match self {
Self::Default => BreakPoint::None.class_with(Self::CONTAINER, ""),

View file

@ -17,8 +17,8 @@
//! .with_button_color(ButtonColor::Background(Color::Secondary))
//! .with_auto_close(dropdown::AutoClose::ClickableInside)
//! .with_direction(dropdown::Direction::Dropend)
//! .add_item(dropdown::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://www.google.es"))
//! .add_item(dropdown::Item::link(L10n::n("Home"), |_| "/".into()))
//! .add_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://google.es".into()))
//! .add_item(dropdown::Item::divider())
//! .add_item(dropdown::Item::header(L10n::n("User session")))
//! .add_item(dropdown::Item::button(L10n::n("Sign out")));

View file

@ -19,26 +19,37 @@ use crate::LOCALES_BOOTSIER;
///
/// Ver ejemplo en el módulo [`dropdown`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Dropdown {
id : AttrId,
classes : AttrClasses,
title : L10n,
button_size : ButtonSize,
button_color : ButtonColor,
button_split : bool,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al menú desplegable.
classes: Classes,
/// Devuelve el título del menú desplegable.
title: L10n,
/// Devuelve el tamaño configurado del botón.
button_size: ButtonSize,
/// Devuelve el color/estilo configurado del botón.
button_color: ButtonColor,
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
button_split: bool,
/// Devuelve si el botón del menú está integrado en un grupo de botones.
button_grouped: bool,
auto_close : dropdown::AutoClose,
direction : dropdown::Direction,
menu_align : dropdown::MenuAlign,
menu_position : dropdown::MenuPosition,
items : Children,
/// Devuelve la política de cierre automático del menú desplegado.
auto_close: dropdown::AutoClose,
/// Devuelve la dirección de despliegue configurada.
direction: dropdown::Direction,
/// Devuelve la configuración de alineación horizontal del menú desplegable.
menu_align: dropdown::MenuAlign,
/// Devuelve la posición configurada para el menú desplegable.
menu_position: dropdown::MenuPosition,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Dropdown {
fn new() -> Self {
Dropdown::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -48,7 +59,7 @@ impl Component for Dropdown {
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(
ClassesOp::Prepend,
self.direction().class_with(self.button_grouped()),
self.direction().class_with(*self.button_grouped()),
);
}
@ -65,7 +76,7 @@ impl Component for Dropdown {
PrepareMarkup::With(html! {
div id=[self.id()] class=[self.classes().get()] {
@if !title.is_empty() {
@let mut btn_classes = AttrClasses::new({
@let mut btn_classes = Classes::new({
let mut classes = "btn".to_string();
self.button_size().push_class(&mut classes);
self.button_color().push_class(&mut classes);
@ -75,14 +86,14 @@ impl Component for Dropdown {
@let offset = pos.data_offset();
@let reference = pos.data_reference();
@let auto_close = self.auto_close.as_str();
@let menu_classes = AttrClasses::new({
@let menu_classes = Classes::new({
let mut classes = "dropdown-menu".to_string();
self.menu_align().push_class(&mut classes);
classes
});
// Renderizado en modo split (dos botones) o simple (un botón).
@if self.button_split() {
@if *self.button_split() {
// Botón principal (acción/etiqueta).
@let btn = html! {
button
@ -96,7 +107,7 @@ impl Component for Dropdown {
@let btn_toggle = html! {
button
type="button"
class=[btn_classes.alter_value(
class=[btn_classes.alter_classes(
ClassesOp::Add, "dropdown-toggle dropdown-toggle-split"
).get()]
data-bs-toggle="dropdown"
@ -127,7 +138,7 @@ impl Component for Dropdown {
// Botón único con funcionalidad de *toggle*.
button
type="button"
class=[btn_classes.alter_value(
class=[btn_classes.alter_classes(
ClassesOp::Add, "dropdown-toggle"
).get()]
data-bs-toggle="dropdown"
@ -155,14 +166,14 @@ impl Dropdown {
/// Establece el identificador único (`id`) del menú desplegable.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú desplegable.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -242,61 +253,4 @@ impl Dropdown {
self.items.alter_typed(op);
self
}
// **< Dropdown GETTERS >***********************************************************************
/// Devuelve las clases CSS asociadas al menú desplegable.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el título del menú desplegable.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el tamaño configurado del botón.
pub fn button_size(&self) -> &ButtonSize {
&self.button_size
}
/// Devuelve el color/estilo configurado del botón.
pub fn button_color(&self) -> &ButtonColor {
&self.button_color
}
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
pub fn button_split(&self) -> bool {
self.button_split
}
/// Devuelve si el botón del menú está integrado en un grupo de botones.
pub fn button_grouped(&self) -> bool {
self.button_grouped
}
/// Devuelve la política de cierre automático del menú desplegado.
pub fn auto_close(&self) -> &dropdown::AutoClose {
&self.auto_close
}
/// Devuelve la dirección de despliegue configurada.
pub fn direction(&self) -> &dropdown::Direction {
&self.direction
}
/// Devuelve la configuración de alineación horizontal del menú desplegable.
pub fn menu_align(&self) -> &dropdown::MenuAlign {
&self.menu_align
}
/// Devuelve la posición configurada para el menú desplegable.
pub fn menu_position(&self) -> &dropdown::MenuPosition {
&self.menu_position
}
/// Devuelve la lista de elementos (`children`) del menú.
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -14,11 +14,12 @@ pub enum ItemKind {
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación. Opcionalmente puede abrirse en una nueva ventana y estar
/// inicialmente deshabilitado.
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
path: FnPathByContext,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
@ -40,19 +41,21 @@ pub enum ItemKind {
/// visible que puede comportarse como texto, enlace, botón, encabezado o separador, según su
/// [`ItemKind`].
///
/// Permite definir identificador, clases de estilo adicionales o tipo de interacción asociada,
/// manteniendo una interfaz común para renderizar todos los elementos del menú.
#[rustfmt::skip]
#[derive(AutoDefault)]
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Getters)]
pub struct Item {
id : AttrId,
classes : AttrClasses,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al elemento.
classes: Classes,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Item::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -73,13 +76,13 @@ impl Component for Item {
ItemKind::Link {
label,
path,
route,
blank,
disabled,
} => {
let path = path(cx);
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(path));
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "dropdown-item".to_string();
if is_current {
@ -89,9 +92,9 @@ impl Component for Item {
classes.push_str(" disabled");
}
let href = (!disabled).then_some(path);
let target = (!disabled && *blank).then_some("_blank");
let rel = (!disabled && *blank).then_some("noopener noreferrer");
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = disabled.then_some("true");
@ -155,18 +158,22 @@ impl Component for Item {
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
pub fn link(label: L10n, path: FnPathByContext) -> Self {
Item {
///
/// La ruta se obtiene invocando [`FnPathByContext`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: false,
disabled: false,
},
@ -175,11 +182,11 @@ impl Item {
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: false,
disabled: true,
},
@ -188,11 +195,11 @@ impl Item {
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: false,
},
@ -201,11 +208,11 @@ impl Item {
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: true,
},
@ -215,7 +222,7 @@ impl Item {
/// Crea un botón de acción local, sin navegación asociada.
pub fn button(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Button {
label,
disabled: false,
@ -226,7 +233,7 @@ impl Item {
/// Crea un botón deshabilitado.
pub fn button_disabled(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Button {
label,
disabled: true,
@ -237,7 +244,7 @@ impl Item {
/// Crea un encabezado para un grupo de elementos dentro del menú.
pub fn header(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Header(label),
..Default::default()
}
@ -245,7 +252,7 @@ impl Item {
/// Crea un separador visual entre bloques de elementos.
pub fn divider() -> Self {
Item {
Self {
item_kind: ItemKind::Divider,
..Default::default()
}
@ -256,26 +263,14 @@ impl Item {
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al elemento.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
// **< Item GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al elemento.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo de elemento representado.
pub fn item_kind(&self) -> &ItemKind {
&self.item_kind
}
}

View file

@ -22,7 +22,7 @@ pub enum AutoClose {
}
impl AutoClose {
// Devuelve el valor para `data-bs-auto-close`, o `None` si es el comportamiento por defecto.
/// Devuelve el valor para `data-bs-auto-close`, o `None` si es el comportamiento por defecto.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> Option<&'static str> {
@ -59,7 +59,7 @@ pub enum Direction {
}
impl Direction {
// Mapea la dirección teniendo en cuenta si se agrupa con otros menús [`Dropdown`].
/// Mapea la dirección teniendo en cuenta si se agrupa con otros menús [`Dropdown`].
#[rustfmt::skip ]
#[inline]
const fn as_str(self, grouped: bool) -> &'static str {
@ -74,8 +74,8 @@ impl Direction {
}
}
// Añade la dirección de despliegue a la cadena de clases teniendo en cuenta si se agrupa con
// otros menús [`Dropdown`].
/// Añade la dirección de despliegue a la cadena de clases teniendo en cuenta si se agrupa con
/// otros menús [`Dropdown`].
#[inline]
pub(crate) fn push_class(self, classes: &mut String, grouped: bool) {
if grouped {
@ -93,10 +93,10 @@ impl Direction {
}
}
// Devuelve la clase asociada a la dirección teniendo en cuenta si se agrupa con otros menús
// [`Dropdown`], o `""` si no corresponde ninguna.
#[inline]
pub(crate) fn class_with(self, grouped: bool) -> String {
/// Devuelve la clase asociada a la dirección teniendo en cuenta si se agrupa con otros menús
/// [`Dropdown`], o `""` si no corresponde ninguna.
#[doc(hidden)]
pub fn class_with(self, grouped: bool) -> String {
let mut classes = String::new();
self.push_class(&mut classes, grouped);
classes
@ -138,7 +138,7 @@ impl MenuAlign {
classes.push_str(class);
}
// Añade las clases de alineación a `classes` (sin incluir la base `dropdown-menu`).
/// Añade las clases de alineación a `classes` (sin incluir la base `dropdown-menu`).
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
match self {
@ -179,8 +179,7 @@ impl MenuAlign {
}
/* Devuelve las clases de alineación sin incluir `dropdown-menu` (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
@ -206,7 +205,7 @@ pub enum MenuPosition {
}
impl MenuPosition {
// Devuelve el valor para `data-bs-offset` o `None` si no aplica.
/// Devuelve el valor para `data-bs-offset` o `None` si no aplica.
#[inline]
pub(crate) fn data_offset(self) -> Option<String> {
match self {
@ -215,7 +214,7 @@ impl MenuPosition {
}
}
// Devuelve el valor para `data-bs-reference` o `None` si no aplica.
/// Devuelve el valor para `data-bs-reference` o `None` si no aplica.
#[inline]
pub(crate) fn data_reference(self) -> Option<&'static str> {
match self {

View file

@ -0,0 +1,14 @@
//! Definiciones para crear formularios ([`Form`]).
mod props;
pub use props::{Autocomplete, AutofillField};
pub use props::{InputType, Method};
mod component;
pub use component::Form;
mod fieldset;
pub use fieldset::Fieldset;
mod input;
pub use input::Input;

View file

@ -0,0 +1,130 @@
use pagetop::prelude::*;
use crate::theme::form;
/// Componente para crear un **formulario**.
///
/// Este componente renderiza un `<form>` estándar con soporte para los atributos más habituales:
///
/// - `id`: identificador opcional del formulario.
/// - `classes`: clases CSS adicionales (p. ej. utilidades CSS).
/// - `action`: URL/ruta de destino para el envío.
/// - `method`: método usado por el formulario para el envío de los datos (ver explicaciones en
/// [`form::Method`](crate::theme::form::Method)).
/// - `accept-charset`: juego de caracteres aceptado (por defecto es `"UTF-8"`).
/// - `children`: contenido del formulario.
///
/// # Ejemplo
///
/// ```ignore
/// use pagetop::prelude::*;
/// use crate::prelude::*;
///
/// let form = Form::new()
/// .with_id("search")
/// .with_action("/search")
/// .with_method(form::Method::Get)
/// .with_classes(ClassesOp::Add, "mb-3")
/// .add_child(Input::new().with_name("q"));
/// ```
#[derive(AutoDefault, Getters)]
pub struct Form {
#[getters(skip)]
id: AttrId,
classes: Classes,
action: AttrValue,
method: form::Method,
#[default(_code = "AttrValue::new(\"UTF-8\")")]
charset: AttrValue,
children: Children,
}
impl Component for Form {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, "form");
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let method = match self.method() {
form::Method::Post => Some("post"),
form::Method::Get => None,
};
PrepareMarkup::With(html! {
form
id=[self.id()]
class=[self.classes().get()]
action=[self.action().get()]
method=[method]
accept-charset=[self.charset().get()]
{
(self.children().render(cx))
}
})
}
}
impl Form {
// **< Form BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del formulario.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al formulario.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece la URL/ruta de destino del formulario.
#[builder_fn]
pub fn with_action(mut self, action: impl AsRef<str>) -> Self {
self.action.alter_str(action);
self
}
/// Establece el método para enviar el formulario.
///
/// - `GET`: el atributo `method` se omite.
/// - `POST`: se establece `method="post"`.
#[builder_fn]
pub fn with_method(mut self, method: form::Method) -> Self {
self.method = method;
self
}
/// Establece el juego de caracteres aceptado por el formulario.
///
/// Por defecto se usa `"UTF-8"`.
#[builder_fn]
pub fn with_charset(mut self, charset: impl AsRef<str>) -> Self {
self.charset.alter_str(charset);
self
}
/// Añade un nuevo componente hijo al formulario.
#[inline]
pub fn add_child(mut self, component: impl Component) -> Self {
self.children.add(Child::with(component));
self
}
/// Modifica la lista de componentes (`children`) aplicando una operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: ChildOp) -> Self {
self.children.alter_child(op);
self
}
}

View file

@ -0,0 +1,81 @@
use pagetop::prelude::*;
/// Agrupa controles relacionados de un formulario (`<fieldset>`).
///
/// Se usa para mejorar la accesibilidad cuando se acompaña de una leyenda que encabeza el grupo.
#[derive(AutoDefault, Getters)]
pub struct Fieldset {
#[getters(skip)]
id: AttrId,
classes: Classes,
legend: Attr<L10n>,
disabled: bool,
children: Children,
}
impl Component for Fieldset {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With(html! {
fieldset id=[self.id()] class=[self.classes().get()] disabled[*self.disabled()] {
@if let Some(legend) = self.legend().lookup(cx) {
legend { (legend) }
}
(self.children().render(cx))
}
})
}
}
impl Fieldset {
// **< Fieldset BUILDER >***********************************************************************
/// Establece el identificador único (`id`) del `fieldset` (grupo de controles).
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al `fieldset`.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece la leyenda del `fieldset`.
#[builder_fn]
pub fn with_legend(mut self, legend: L10n) -> Self {
self.legend.alter_value(legend);
self
}
/// Establece si el `fieldset` está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Añade un nuevo componente hijo al `fieldset`.
#[inline]
pub fn add_child(mut self, component: impl Component) -> Self {
self.children.add(Child::with(component));
self
}
/// Modifica la lista de componentes (`children`) aplicando una operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: ChildOp) -> Self {
self.children.alter_child(op);
self
}
}

View file

@ -0,0 +1,205 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
#[derive(AutoDefault, Getters)]
pub struct Input {
classes: Classes,
input_type: form::InputType,
name: AttrName,
value: AttrValue,
label: Attr<L10n>,
help_text: Attr<L10n>,
#[default(_code = "Attr::<u16>::some(60)")]
size: Attr<u16>,
minlength: Attr<u16>,
#[default(_code = "Attr::<u16>::some(128)")]
maxlength: Attr<u16>,
placeholder: AttrValue,
autocomplete: Attr<form::Autocomplete>,
autofocus: bool,
readonly: bool,
required: bool,
disabled: bool,
}
impl Component for Input {
fn new() -> Self {
Self::default()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(
ClassesOp::Prepend,
util::join!("form-item form-type-", self.input_type().to_string()),
);
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let id = self.name().get().map(|name| util::join!("edit-", name));
PrepareMarkup::With(html! {
div class=[self.classes().get()] {
@if let Some(label) = self.label().lookup(cx) {
label for=[&id] class="form-label" {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
{
"*"
}
}
}
}
input
type=(self.input_type())
id=[id]
class="form-control"
name=[self.name().get()]
value=[self.value().get()]
size=[self.size().get()]
minlength=[self.minlength().get()]
maxlength=[self.maxlength().get()]
placeholder=[self.placeholder().get()]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
readonly[*self.readonly()]
required[*self.required()]
disabled[*self.disabled()] {}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Input {
pub fn textfield() -> Self {
Input::default()
}
pub fn password() -> Self {
Self {
input_type: form::InputType::Password,
..Default::default()
}
}
pub fn search() -> Self {
Self {
input_type: form::InputType::Search,
..Default::default()
}
}
pub fn email() -> Self {
Self {
input_type: form::InputType::Email,
..Default::default()
}
}
pub fn telephone() -> Self {
Self {
input_type: form::InputType::Telephone,
..Default::default()
}
}
pub fn url() -> Self {
Self {
input_type: form::InputType::Url,
..Default::default()
}
}
// **< Input BUILDER >**************************************************************************
/// Modifica la lista de clases CSS aplicadas al `input`.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
#[builder_fn]
pub fn with_label(mut self, label: L10n) -> Self {
self.label.alter_value(label);
self
}
#[builder_fn]
pub fn with_help_text(mut self, help_text: L10n) -> Self {
self.help_text.alter_value(help_text);
self
}
#[builder_fn]
pub fn with_size(mut self, size: Option<u16>) -> Self {
self.size.alter_opt(size);
self
}
#[builder_fn]
pub fn with_minlength(mut self, minlength: Option<u16>) -> Self {
self.minlength.alter_opt(minlength);
self
}
#[builder_fn]
pub fn with_maxlength(mut self, maxlength: Option<u16>) -> Self {
self.maxlength.alter_opt(maxlength);
self
}
#[builder_fn]
pub fn with_placeholder(mut self, placeholder: impl AsRef<str>) -> Self {
self.placeholder.alter_str(placeholder);
self
}
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
#[builder_fn]
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -0,0 +1,449 @@
use pagetop::prelude::*;
use std::borrow::Cow;
use std::fmt;
// **< Autocomplete >*******************************************************************************
/// Valor del atributo HTML `autocomplete`.
///
/// Según la [especificación](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill)
/// oficial este valor puede ser:
///
/// - `on` / `off`, o
/// - una lista ordenada de tokens predefinidos separados por espacios.
///
/// Las variantes de `Autocomplete` permiten:
///
/// - Generar valores canónicos `on`/`off` ([`Autocomplete::On`], [`Autocomplete::Off`]).
/// - Generar una lista de tokens en formato texto ([`Autocomplete::Custom`]). Los valores creados
/// mediante [`Autocomplete::custom()`] se normalizan con [`util::normalize_ascii_or_empty()`].
///
/// Las entradas no válidas que lleguen a [`Autocomplete::custom()`] se degradan a
/// [`Autocomplete::On`] (valor canónico y seguro).
#[derive(Clone, Debug, PartialEq)]
pub enum Autocomplete {
/// Genera `autocomplete="on"`.
On,
/// Genera `autocomplete="off"`.
Off,
/// Genera un valor personalizado (se espera en formato canónico).
///
/// Normalmente contiene una lista de tokens separados por espacios (p. ej. `"username"` o
/// `"username webauthn"`).
Custom(CowStr),
}
impl Autocomplete {
// --< Field token >----------------------------------------------------------------------------
/// Genera `autocomplete="<field>"` usando un campo predefinido.
#[inline]
pub fn field(field: AutofillField) -> Self {
Self::Custom(Cow::Borrowed(field.as_str()))
}
// --< Sections >-------------------------------------------------------------------------------
/// Construye `autocomplete` usando un nombre de sección y un campo predefinido.
///
/// Genera `autocomplete="section-<name> <field>"`.
///
/// Si `name` contiene espacios tras normalizar con [`util::normalize_ascii()`] (o si no es
/// ASCII / queda vacío), se ignora la sección y se genera solo el campo (`<field>`).
pub fn section(name: impl AsRef<str>, field: AutofillField) -> Self {
match util::normalize_ascii(name.as_ref()) {
Ok(n) if !n.as_ref().contains(' ') => {
Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str()))
}
_ => Self::field(field),
}
}
// --< Common fields >--------------------------------------------------------------------------
/// Genera `autocomplete="username"`.
pub fn username() -> Self {
Self::field(AutofillField::Username)
}
/// Genera `autocomplete="username webauthn"` (Passkeys / WebAuthn).
pub fn username_webauthn() -> Self {
Self::custom("username webauthn")
}
/// Genera `autocomplete="email"`.
pub fn email() -> Self {
Self::field(AutofillField::Email)
}
/// Genera `autocomplete="current-password"`.
pub fn current_password() -> Self {
Self::field(AutofillField::CurrentPassword)
}
/// Genera `autocomplete="current-password webauthn"` (Passkeys / WebAuthn).
pub fn current_password_webauthn() -> Self {
Self::custom("current-password webauthn")
}
/// Genera `autocomplete="new-password"`.
pub fn new_password() -> Self {
Self::field(AutofillField::NewPassword)
}
/// Genera `autocomplete="one-time-code"`.
pub fn otp() -> Self {
Self::field(AutofillField::OneTimeCode)
}
// --< Address contexts >-----------------------------------------------------------------------
/// Contexto de dirección de envío. Genera `autocomplete="shipping <field>"`.
pub fn shipping(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("shipping ", field.as_str())))
}
/// Contexto de dirección de facturación. Genera `autocomplete="billing <field>"`.
pub fn billing(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("billing ", field.as_str())))
}
// --< Contact hints >--------------------------------------------------------------------------
/// Detalle de contacto: `autocomplete="home <field>"`.
pub fn home(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("home ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="work <field>"`.
pub fn work(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("work ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="mobile <field>"`.
pub fn mobile(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("mobile ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="fax <field>"`.
pub fn fax(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("fax ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="pager <field>"`.
pub fn pager(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("pager ", field.as_str())))
}
// --< Custom tokens >--------------------------------------------------------------------------
/// Crea un `autocomplete` con texto libre (se espera en formato canónico).
///
/// Esta función acepta una cadena con `on`/`off` o una lista de tokens separados por espacios:
///
/// - Rechaza entradas no ASCII.
/// - Recorta separadores ASCII al inicio/fin.
/// - Compacta secuencias de separadores ASCII en un único espacio.
/// - Convierte a minúsculas.
///
/// - Si el valor normalizado es `"on"` o `"off"`, devuelve [`Autocomplete::On`] o
/// [`Autocomplete::Off`].
/// - Si el valor es inválido (vacío tras normalizar o contiene bytes no ASCII), devuelve
/// [`Autocomplete::On`].
pub fn custom(autocomplete: impl Into<CowStr>) -> Self {
let value: CowStr = autocomplete.into();
let raw = value.as_ref();
// Normaliza la entrada.
let Some(normalized) = util::normalize_ascii_or_empty(raw, "Autocomplete::custom") else {
return Self::On;
};
let autocomplete = normalized.as_ref();
// Identifica valores especiales.
if autocomplete == "on" {
return Self::On;
}
if autocomplete == "off" {
return Self::Off;
}
// Mantiene el `Cow` original si no cambia nada (no reserva espacio).
if autocomplete == raw {
return Self::Custom(value);
}
// En otro caso asigna espacio para la normalización.
Self::Custom(Cow::Owned(normalized.into_owned()))
}
}
impl fmt::Display for Autocomplete {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Autocomplete::On => f.write_str("on"),
Autocomplete::Off => f.write_str("off"),
Autocomplete::Custom(c) => f.write_str(c),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AutofillField {
// Identidad / cuenta
/// Nombre completo.
Name,
/// Tratamiento o título (p. ej. "Sr.", "Sra.", "Dra.").
HonorificPrefix,
/// Nombre de pila.
GivenName,
/// Nombre adicional (p. ej. segundo nombre).
AdditionalName,
/// Apellidos.
FamilyName,
/// Sufijo honorífico (p. ej. "Jr.", "PhD").
HonorificSuffix,
/// Apodo.
Nickname,
/// Identificador de usuario (login).
Username,
// Credenciales
/// Contraseña actual.
CurrentPassword,
/// Nueva contraseña.
NewPassword,
/// Código de un solo uso (OTP).
OneTimeCode,
// Organización
/// Cargo o título dentro de una organización.
OrganizationTitle,
/// Nombre de la organización.
Organization,
// Contacto
/// Correo electrónico.
Email,
/// Teléfono.
Tel,
/// Prefijo/código de país del teléfono (incluye `+`).
TelCountryCode,
/// Teléfono sin el código de país.
TelNational,
/// Código de área (si aplica).
TelAreaCode,
/// Teléfono sin código de país ni de área.
TelLocal,
/// Prefijo local (primera parte tras el área).
TelLocalPrefix,
/// Sufijo local (segunda parte tras el área).
TelLocalSuffix,
/// Extensión interna.
TelExtension,
/// URL.
Url,
/// Referencia de mensajería instantánea (URL).
Impp,
// Dirección (muy habitual en formularios)
/// Dirección postal completa (una sola línea/textarea).
StreetAddress,
/// Línea 1 de dirección.
AddressLine1,
/// Línea 2 de dirección.
AddressLine2,
/// Línea 3 de dirección.
AddressLine3,
/// Nivel administrativo 4 (el más específico).
AddressLevel4,
/// Nivel administrativo 3.
AddressLevel3,
/// Nivel administrativo 2 (p. ej. ciudad/municipio).
AddressLevel2,
/// Nivel administrativo 1 (p. ej. provincia/estado).
AddressLevel1,
/// Código postal.
PostalCode,
/// País (código o token `country`).
Country,
/// Nombre del país.
CountryName,
// Pago (si algún día lo necesitas)
/// Nombre del titular de la tarjeta.
CcName,
/// Nombre de pila del titular de la tarjeta.
CcGivenName,
/// Nombre adicional del titular de la tarjeta.
CcAdditionalName,
/// Apellidos del titular de la tarjeta.
CcFamilyName,
/// Número de tarjeta.
CcNumber,
/// Fecha de caducidad (completa).
CcExp,
/// Mes de caducidad.
CcExpMonth,
/// Año de caducidad.
CcExpYear,
/// Código de seguridad (CVC/CVV).
CcCsc,
/// Tipo de tarjeta (p. ej. visa/mastercard).
CcType,
// Transacción / preferencias
/// Moneda preferida para la transacción (código ISO 4217).
TransactionCurrency,
/// Cantidad de la transacción (número).
TransactionAmount,
/// Idioma preferido (BCP 47).
Language,
// Otros datos personales (según necesidad del producto)
/// Fecha de nacimiento completa.
Bday,
/// Día de nacimiento.
BdayDay,
/// Mes de nacimiento.
BdayMonth,
/// Año de nacimiento.
BdayYear,
/// Sexo (según el valor que el UA tenga guardado).
Sex,
/// Foto (URL o referencia, según UA).
Photo,
}
impl AutofillField {
/// Devuelve el token exacto definido por HTML para `autocomplete`.
pub(crate) fn as_str(&self) -> &'static str {
match self {
AutofillField::Name => "name",
AutofillField::HonorificPrefix => "honorific-prefix",
AutofillField::GivenName => "given-name",
AutofillField::AdditionalName => "additional-name",
AutofillField::FamilyName => "family-name",
AutofillField::HonorificSuffix => "honorific-suffix",
AutofillField::Nickname => "nickname",
AutofillField::Username => "username",
AutofillField::CurrentPassword => "current-password",
AutofillField::NewPassword => "new-password",
AutofillField::OneTimeCode => "one-time-code",
AutofillField::OrganizationTitle => "organization-title",
AutofillField::Organization => "organization",
AutofillField::Email => "email",
AutofillField::Tel => "tel",
AutofillField::TelCountryCode => "tel-country-code",
AutofillField::TelNational => "tel-national",
AutofillField::TelAreaCode => "tel-area-code",
AutofillField::TelLocal => "tel-local",
AutofillField::TelLocalPrefix => "tel-local-prefix",
AutofillField::TelLocalSuffix => "tel-local-suffix",
AutofillField::TelExtension => "tel-extension",
AutofillField::Url => "url",
AutofillField::Impp => "impp",
AutofillField::StreetAddress => "street-address",
AutofillField::AddressLine1 => "address-line1",
AutofillField::AddressLine2 => "address-line2",
AutofillField::AddressLine3 => "address-line3",
AutofillField::AddressLevel4 => "address-level4",
AutofillField::AddressLevel3 => "address-level3",
AutofillField::AddressLevel2 => "address-level2",
AutofillField::AddressLevel1 => "address-level1",
AutofillField::PostalCode => "postal-code",
AutofillField::Country => "country",
AutofillField::CountryName => "country-name",
AutofillField::CcName => "cc-name",
AutofillField::CcGivenName => "cc-given-name",
AutofillField::CcAdditionalName => "cc-additional-name",
AutofillField::CcFamilyName => "cc-family-name",
AutofillField::CcNumber => "cc-number",
AutofillField::CcExp => "cc-exp",
AutofillField::CcExpMonth => "cc-exp-month",
AutofillField::CcExpYear => "cc-exp-year",
AutofillField::CcCsc => "cc-csc",
AutofillField::CcType => "cc-type",
AutofillField::TransactionCurrency => "transaction-currency",
AutofillField::TransactionAmount => "transaction-amount",
AutofillField::Language => "language",
AutofillField::Bday => "bday",
AutofillField::BdayDay => "bday-day",
AutofillField::BdayMonth => "bday-month",
AutofillField::BdayYear => "bday-year",
AutofillField::Sex => "sex",
AutofillField::Photo => "photo",
}
}
}
// **< InputType >**********************************************************************************
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum InputType {
#[default]
Textfield,
Password,
Search,
Email,
Telephone,
Url,
}
impl fmt::Display for InputType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
InputType::Textfield => "text",
InputType::Password => "password",
InputType::Search => "search",
InputType::Email => "email",
InputType::Telephone => "tel",
InputType::Url => "url",
})
}
}
// **< Method >*************************************************************************************
/// Método HTTP usado por un formulario ([`Form`](crate::theme::Form)) para el envío de los datos.
///
/// En HTML, el atributo `method` del formulario indica **cómo** se envían los datos:
///
/// - **GET**: los pares `name=value` se codifican en la **URL** añadiendo una cadena de consulta
/// como `?a=1&b=2`. Es el método por defecto en HTML cuando no se especifica. Suele ser apropiado
/// para **búsquedas** o formularios que no modifican datos ni el estado del sistema.
///
/// - **POST**: los datos se envían en el **cuerpo** de la petición (*request body*). Es apropiado
/// para acciones que **modifican el estado** o cuando hay formularios grandes. Es el **método por
/// defecto** en PageTop.
///
/// # Consideraciones prácticas
///
/// - **Visibilidad y privacidad**: con GET los datos quedan visibles en la URL (historial, *logs*,
/// marcadores). No se recomienda para datos sensibles. Con POST no van en la URL, pero **no se
/// cifran** por sí mismos; por eso es esencial el uso de HTTPS.
/// - **Tamaño**: GET está limitado por la longitud máxima de URL que acepten el navegador y el
/// servidor. POST es más flexible para cargas grandes.
/// - **Ficheros**: la subida de ficheros requiere `method="post"` y un `enctype` adecuado
/// (habitualmente `multipart/form-data`).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Method {
/// Envía los datos en el cuerpo de la petición.
///
/// Es el **método por defecto** en PageTop. Recomendado para operaciones que modifican el
/// estado o para envíos grandes.
#[default]
Post,
/// Envía los datos en la URL como una cadena *query*.
///
/// Recomendado para búsquedas y operaciones que no modifican datos ni el estado del sistema.
Get,
}

View file

@ -13,17 +13,17 @@ pub enum IconKind {
},
}
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Icon {
classes : AttrClasses,
icon_kind : IconKind,
/// Devuelve las clases CSS asociadas al icono.
classes: Classes,
icon_kind: IconKind,
aria_label: AttrL10n,
}
impl Component for Icon {
fn new() -> Self {
Icon::default()
Self::default()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
@ -75,22 +75,22 @@ impl Component for Icon {
impl Icon {
pub fn font() -> Self {
Icon::default().with_icon_kind(IconKind::Font(FontSize::default()))
Self::default().with_icon_kind(IconKind::Font(FontSize::default()))
}
pub fn font_sized(font_size: FontSize) -> Self {
Icon::default().with_icon_kind(IconKind::Font(font_size))
Self::default().with_icon_kind(IconKind::Font(font_size))
}
pub fn svg(shapes: Markup) -> Self {
Icon::default().with_icon_kind(IconKind::Svg {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::default(),
})
}
pub fn svg_with_viewbox(shapes: Markup, viewbox: impl AsRef<str>) -> Self {
Icon::default().with_icon_kind(IconKind::Svg {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::new(viewbox),
})
@ -116,19 +116,4 @@ impl Icon {
self.aria_label.alter_value(label);
self
}
// **< Icon GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al icono.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
pub fn icon_kind(&self) -> &IconKind {
&self.icon_kind
}
pub fn aria_label(&self) -> &AttrL10n {
&self.aria_label
}
}

View file

@ -9,19 +9,23 @@ use crate::prelude::*;
/// ([`classes::Border`](crate::theme::classes::Border)) y **redondeo de esquinas**
/// ([`classes::Rounded`](crate::theme::classes::Rounded)).
/// - Resuelve el texto alternativo `alt` con **localización** mediante [`L10n`].
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Image {
id : AttrId,
classes: AttrClasses,
size : image::Size,
source : image::Source,
alt : AttrL10n,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas a la imagen.
classes: Classes,
/// Devuelve las dimensiones de la imagen.
size: image::Size,
/// Devuelve el origen de la imagen.
source: image::Source,
/// Devuelve el texto alternativo localizado.
alternative: Attr<L10n>,
}
impl Component for Image {
fn new() -> Self {
Image::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -69,7 +73,7 @@ impl Component for Image {
impl Image {
/// Crea rápidamente una imagen especificando su origen.
pub fn with(source: image::Source) -> Self {
Image::default().with_source(source)
Self::default().with_source(source)
}
// **< Image BUILDER >**************************************************************************
@ -77,7 +81,7 @@ impl Image {
/// Establece el identificador único (`id`) de la imagen.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -89,7 +93,7 @@ impl Image {
/// - Redondear las esquinas ([`classes::Rounded`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -107,35 +111,13 @@ impl Image {
self
}
/// Define el texto alternativo localizado ([`L10n`]) para la imagen.
/// Define un *texto localizado* ([`L10n`]) alternativo para la imagen.
///
/// Se recomienda siempre aportar un texto alternativo salvo que la imagen sea puramente
/// decorativa.
#[builder_fn]
pub fn with_alternative(mut self, alt: L10n) -> Self {
self.alt.alter_value(alt);
self.alternative.alter_value(alt);
self
}
// **< Image GETTERS >**************************************************************************
/// Devuelve las clases CSS asociadas a la imagen.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve las dimensiones de la imagen.
pub fn size(&self) -> &image::Size {
&self.size
}
/// Devuelve el origen de la imagen.
pub fn source(&self) -> &image::Source {
&self.source
}
/// Devuelve el texto alternativo localizado.
pub fn alternative(&self) -> &AttrL10n {
&self.alt
}
}

View file

@ -31,7 +31,7 @@ pub enum Size {
}
impl Size {
// Devuelve el valor del atributo `style` en función del tamaño, o `None` si no aplica.
/// Devuelve el valor del atributo `style` en función del tamaño, o `None` si no aplica.
#[inline]
pub(crate) fn to_style(self) -> Option<String> {
match self {
@ -54,23 +54,47 @@ pub enum Source {
Logo(PageTopSvg),
/// Imagen que se adapta automáticamente a su contenedor.
///
/// El `String` asociado es la URL (o ruta) de la imagen.
Responsive(String),
/// Lleva asociada la URL (o ruta) de la imagen.
Responsive(CowStr),
/// Imagen que aplica el estilo **miniatura** de Bootstrap.
///
/// El `String` asociado es la URL (o ruta) de la imagen.
Thumbnail(String),
/// Lleva asociada la URL (o ruta) de la imagen.
Thumbnail(CowStr),
/// Imagen sin clases específicas de Bootstrap, útil para controlar con CSS propio.
///
/// El `String` asociado es la URL (o ruta) de la imagen.
Plain(String),
/// Lleva asociada la URL (o ruta) de la imagen.
Plain(CowStr),
}
impl Source {
const IMG_FLUID: &str = "img-fluid";
const IMG_THUMBNAIL: &str = "img-thumbnail";
// Devuelve la clase base asociada a la imagen según la fuente.
/// Imagen con el logotipo de PageTop.
#[inline]
pub fn logo(svg: PageTopSvg) -> Self {
Self::Logo(svg)
}
/// Imagen responsive (`img-fluid`).
#[inline]
pub fn responsive(url: impl Into<CowStr>) -> Self {
Self::Responsive(url.into())
}
/// Imagen miniatura (`img-thumbnail`).
#[inline]
pub fn thumbnail(url: impl Into<CowStr>) -> Self {
Self::Thumbnail(url.into())
}
/// Imagen sin clases adicionales.
#[inline]
pub fn plain(url: impl Into<CowStr>) -> Self {
Self::Plain(url.into())
}
/// Devuelve la clase base asociada a la imagen según la fuente.
#[inline]
fn as_str(&self) -> &'static str {
match self {
@ -93,9 +117,8 @@ impl Source {
classes.push_str(s);
} */
// Devuelve la clase asociada a la imagen según la fuente.
#[inline]
pub(crate) fn to_class(&self) -> String {
/// Devuelve la clase asociada a la imagen según la fuente.
pub fn to_class(&self) -> String {
let s = self.as_str();
if s.is_empty() {
String::new()

View file

@ -14,17 +14,17 @@
//! # use pagetop_bootsier::prelude::*;
//! let nav = Nav::tabs()
//! .with_layout(nav::Layout::End)
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link_blank(L10n::n("External"), |_| "https://www.google.es"))
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .add_item(nav::Item::link_blank(L10n::n("External"), |_| "https://google.es".into()))
//! .add_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Options"))
//! .with_items(TypedOp::AddMany(vec![
//! Typed::with(dropdown::Item::link(L10n::n("Action"), |_| "/action")),
//! Typed::with(dropdown::Item::link(L10n::n("Another action"), |_| "/another")),
//! Typed::with(dropdown::Item::link(L10n::n("Action"), |_| "/action".into())),
//! Typed::with(dropdown::Item::link(L10n::n("Another"), |_| "/another".into())),
//! ])),
//! ))
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#"));
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));
//! ```
mod props;

View file

@ -10,19 +10,23 @@ use crate::prelude::*;
///
/// Ver ejemplo en el módulo [`nav`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Nav {
id : AttrId,
classes : AttrClasses,
items : Children,
nav_kind : nav::Kind,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al menú.
classes: Classes,
/// Devuelve el estilo visual seleccionado.
nav_kind: nav::Kind,
/// Devuelve la distribución y orientación seleccionada.
nav_layout: nav::Layout,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Nav {
fn new() -> Self {
Nav::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -55,17 +59,17 @@ impl Component for Nav {
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Nav::default().with_kind(nav::Kind::Tabs)
Self::default().with_kind(nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Nav::default().with_kind(nav::Kind::Pills)
Self::default().with_kind(nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Nav::default().with_kind(nav::Kind::Underline)
Self::default().with_kind(nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
@ -73,14 +77,14 @@ impl Nav {
/// Establece el identificador único (`id`) del menú.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -110,26 +114,4 @@ impl Nav {
self.items.alter_typed(op);
self
}
// **< Nav GETTERS >****************************************************************************
/// Devuelve las clases CSS asociadas al menú.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el estilo visual seleccionado.
pub fn nav_kind(&self) -> &nav::Kind {
&self.nav_kind
}
/// Devuelve la distribución y orientación seleccionada.
pub fn nav_layout(&self) -> &nav::Layout {
&self.nav_layout
}
/// Devuelve la lista de elementos (`children`) del menú.
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -17,14 +17,18 @@ pub enum ItemKind {
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación. Opcionalmente puede abrirse en una nueva ventana y estar
/// inicialmente deshabilitado.
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
path: FnPathByContext,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del
/// menú, sin añadir ningún comportamiento de navegación adicional.
Html(Typed<Html>),
/// Elemento que despliega un menú [`Dropdown`].
Dropdown(Typed<Dropdown>),
}
@ -56,7 +60,7 @@ impl ItemKind {
classes.push_str(class);
} */
// Devuelve las clases asociadas al tipo de elemento.
/// Devuelve las clases asociadas al tipo de elemento.
#[inline]
pub(crate) fn to_class(&self) -> String {
self.as_str().to_owned()
@ -68,21 +72,23 @@ impl ItemKind {
/// Representa un **elemento individual** de un menú [`Nav`](crate::theme::Nav).
///
/// Cada instancia de [`nav::Item`](crate::theme::nav::Item) se traduce en un componente visible que
/// puede comportarse como texto, enlace, botón o menú desplegable según su [`ItemKind`].
/// puede comportarse como texto, enlace, contenido HTML o menú desplegable, según su [`ItemKind`].
///
/// Permite definir identificador, clases de estilo adicionales o tipo de interacción asociada,
/// manteniendo una interfaz común para renderizar todos los elementos del menú.
#[rustfmt::skip]
#[derive(AutoDefault)]
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Getters)]
pub struct Item {
id : AttrId,
classes : AttrClasses,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al elemento.
classes: Classes,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Item::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -107,13 +113,13 @@ impl Component for Item {
ItemKind::Link {
label,
path,
route,
blank,
disabled,
} => {
let path = path(cx);
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(path));
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "nav-link".to_string();
if is_current {
@ -123,7 +129,7 @@ impl Component for Item {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(path);
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
@ -146,6 +152,12 @@ impl Component for Item {
})
}
ItemKind::Html(html) => PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
(html.render(cx))
}
}),
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.borrow() {
let items = dd.items().render(cx);
@ -184,18 +196,22 @@ impl Component for Item {
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
pub fn link(label: L10n, path: FnPathByContext) -> Self {
Item {
///
/// La ruta se obtiene invocando [`FnPathByContext`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: false,
disabled: false,
},
@ -204,11 +220,11 @@ impl Item {
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: false,
disabled: true,
},
@ -217,11 +233,11 @@ impl Item {
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: false,
},
@ -230,11 +246,11 @@ impl Item {
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: true,
},
@ -242,13 +258,24 @@ impl Item {
}
}
/// Crea un elemento con contenido HTML arbitrario.
///
/// El contenido se renderiza tal cual lo devuelve el componente [`Html`], dentro de un `<li>`
/// con las clases de navegación asociadas a [`Item`].
pub fn html(html: Html) -> Self {
Self {
item_kind: ItemKind::Html(Typed::with(html)),
..Default::default()
}
}
/// Crea un elemento de navegación que contiene un menú desplegable [`Dropdown`].
///
/// Sólo se tienen en cuenta **el título** (si no existe le asigna uno por defecto) y **la lista
/// de elementos** del [`Dropdown`]; el resto de propiedades del componente no afectarán a su
/// representación en [`Nav`].
/// Sólo se tienen en cuenta **el título** (si no existe, se asigna uno por defecto) y **la
/// lista de elementos** del [`Dropdown`]; el resto de propiedades del componente no afectarán
/// a su representación en [`Nav`].
pub fn dropdown(menu: Dropdown) -> Self {
Item {
Self {
item_kind: ItemKind::Dropdown(Typed::with(menu)),
..Default::default()
}
@ -259,26 +286,14 @@ impl Item {
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al elemento.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
// **< Item GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al elemento.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo de elemento representado.
pub fn item_kind(&self) -> &ItemKind {
&self.item_kind
}
}

View file

@ -21,7 +21,7 @@ impl Kind {
const PILLS: &str = "nav-pills";
const UNDERLINE: &str = "nav-underline";
// Devuelve la clase base asociada al tipo de menú, o una cadena vacía si no aplica.
/// Devuelve la clase base asociada al tipo de menú, o una cadena vacía si no aplica.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
@ -33,7 +33,7 @@ impl Kind {
}
}
// Añade la clase asociada al tipo de menú a la cadena de clases.
/// Añade la clase asociada al tipo de menú a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
@ -83,7 +83,7 @@ impl Layout {
const FILL: &str = "nav-fill";
const JUSTIFIED: &str = "nav-justified";
// Devuelve la clase base asociada a la distribución y orientación del menú.
/// Devuelve la clase base asociada a la distribución y orientación del menú.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
@ -98,7 +98,7 @@ impl Layout {
}
}
// Añade la clase asociada a la distribución y orientación del menú a la cadena de clases.
/// Añade la clase asociada a la distribución y orientación del menú a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
@ -112,7 +112,7 @@ impl Layout {
}
/* Devuelve la clase asociada a la distribución y orientación del menú, o una cadena vacía si no
// aplica (reservado).
/// aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_owned()

View file

@ -2,7 +2,7 @@
//!
//! Cada [`navbar::Item`](crate::theme::navbar::Item) representa un elemento individual de la barra
//! de navegación [`Navbar`], con distintos comportamientos según su finalidad, como menús
//! [`Nav`](crate::theme::Nav) o textos localizados usando [`L10n`](pagetop::locale::L10n).
//! [`Nav`](crate::theme::Nav) o *textos localizados* usando [`L10n`](pagetop::locale::L10n).
//!
//! También puede mostrar una marca de identidad ([`navbar::Brand`](crate::theme::navbar::Brand))
//! que identifique la compañía, producto o nombre del proyecto asociado a la solución web.
@ -17,9 +17,9 @@
//! let navbar = Navbar::simple()
//! .add_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("About"), |_| "/about"))
//! .add_item(nav::Item::link(L10n::n("Contact"), |_| "/contact"))
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .add_item(nav::Item::link(L10n::n("About"), |_| "/about".into()))
//! .add_item(nav::Item::link(L10n::n("Contact"), |_| "/contact".into()))
//! ));
//! ```
//!
@ -32,9 +32,9 @@
//! .with_expand(BreakPoint::MD)
//! .add_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link_blank(L10n::n("Docs"), |_| "https://docs.example.com"))
//! .add_item(nav::Item::link(L10n::n("Support"), |_| "/support"))
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .add_item(nav::Item::link_blank(L10n::n("Docs"), |_| "https://sample.com".into()))
//! .add_item(nav::Item::link(L10n::n("Support"), |_| "/support".into()))
//! ));
//! ```
//!
@ -45,19 +45,23 @@
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("PageTop"))
//! .with_path(Some(|_| "/"));
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .add_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .add_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Tools"))
//! .add_item(dropdown::Item::link(L10n::n("Generator"), |_| "/tools/gen"))
//! .add_item(dropdown::Item::link(L10n::n("Reports"), |_| "/tools/reports"))
//! .add_item(dropdown::Item::link(
//! L10n::n("Generator"), |_| "/tools/gen".into())
//! )
//! .add_item(dropdown::Item::link(
//! L10n::n("Reports"), |_| "/tools/reports".into())
//! )
//! ))
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#"))
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()))
//! ));
//! ```
//!
@ -68,14 +72,14 @@
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Intranet"))
//! .with_path(Some(|_| "/"));
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_right(brand)
//! .with_expand(BreakPoint::LG)
//! .add_item(navbar::Item::nav(
//! Nav::pills()
//! .add_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard"))
//! .add_item(nav::Item::link(L10n::n("Users"), |_| "/users"))
//! .add_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard".into()))
//! .add_item(nav::Item::link(L10n::n("Users"), |_| "/users".into()))
//! ));
//! ```
//!
@ -93,13 +97,13 @@
//! let navbar = Navbar::offcanvas(oc)
//! .add_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("Profile"), |_| "/profile"))
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .add_item(nav::Item::link(L10n::n("Profile"), |_| "/profile".into()))
//! .add_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("More"))
//! .add_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings"))
//! .add_item(dropdown::Item::link(L10n::n("Help"), |_| "/help"))
//! .add_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings".into()))
//! .add_item(dropdown::Item::link(L10n::n("Help"), |_| "/help".into()))
//! ))
//! ));
//! ```
@ -111,15 +115,15 @@
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Main App"))
//! .with_path(Some(|_| "/"));
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .with_position(navbar::Position::FixedTop)
//! .add_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Dashboard"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("Donors"), |_| "/donors"))
//! .add_item(nav::Item::link(L10n::n("Stock"), |_| "/stock"))
//! .add_item(nav::Item::link(L10n::n("Dashboard"), |_| "/".into()))
//! .add_item(nav::Item::link(L10n::n("Donors"), |_| "/donors".into()))
//! .add_item(nav::Item::link(L10n::n("Stock"), |_| "/stock".into()))
//! ));
//! ```

View file

@ -6,26 +6,30 @@ use crate::prelude::*;
///
/// Representa la identidad del sitio con una imagen, título y eslogan:
///
/// - Si hay URL ([`with_path()`](Self::with_path)), el bloque completo actúa como enlace. Por
/// - Si hay URL ([`with_route()`](Self::with_route)), el bloque completo actúa como enlace. Por
/// defecto enlaza a la raíz del sitio (`/`).
/// - Si no hay imagen ([`with_image()`](Self::with_image)) ni título
/// ([`with_title()`](Self::with_title)), la marca de identidad no se renderiza.
/// - El eslogan ([`with_slogan()`](Self::with_slogan)) es opcional; por defecto no tiene contenido.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Brand {
id : AttrId,
image : Typed<Image>,
#[getters(skip)]
id: AttrId,
/// Devuelve la imagen de marca (si la hay).
image: Typed<Image>,
/// Devuelve el título de la identidad de marca.
#[default(_code = "L10n::n(&global::SETTINGS.app.name)")]
title : L10n,
title: L10n,
/// Devuelve el eslogan de la marca.
slogan: L10n,
#[default(_code = "Some(|_| \"/\")")]
path : Option<FnPathByContext>,
/// Devuelve la función que resuelve la URL asociada a la marca (si existe).
#[default(_code = "Some(|cx| cx.route(\"/\"))")]
route: Option<FnPathByContext>,
}
impl Component for Brand {
fn new() -> Self {
Brand::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -40,8 +44,8 @@ impl Component for Brand {
}
let slogan = self.slogan().using(cx);
PrepareMarkup::With(html! {
@if let Some(path) = self.path() {
a class="navbar-brand" href=(path(cx)) { (image) (title) (slogan) }
@if let Some(route) = self.route() {
a class="navbar-brand" href=(route(cx)) { (image) (title) (slogan) }
} @else {
span class="navbar-brand" { (image) (title) (slogan) }
}
@ -55,7 +59,7 @@ impl Brand {
/// Establece el identificador único (`id`) de la marca.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -82,30 +86,8 @@ impl Brand {
/// Define la URL de destino. Si es `None`, la marca no será un enlace.
#[builder_fn]
pub fn with_path(mut self, path: Option<FnPathByContext>) -> Self {
self.path = path;
pub fn with_route(mut self, route: Option<FnPathByContext>) -> Self {
self.route = route;
self
}
// **< Brand GETTERS >**************************************************************************
/// Devuelve la imagen de marca (si la hay).
pub fn image(&self) -> &Typed<Image> {
&self.image
}
/// Devuelve el título de la identidad de marca.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el eslogan de la marca.
pub fn slogan(&self) -> &L10n {
&self.slogan
}
/// Devuelve la función que resuelve la URL asociada a la marca (si existe).
pub fn path(&self) -> &Option<FnPathByContext> {
&self.path
}
}

View file

@ -14,20 +14,25 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
///
/// Ver ejemplos en el módulo [`navbar`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Navbar {
id : AttrId,
classes : AttrClasses,
expand : BreakPoint,
layout : navbar::Layout,
position : navbar::Position,
items : Children,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas a la barra de navegación.
classes: Classes,
/// Devuelve el punto de ruptura configurado.
expand: BreakPoint,
/// Devuelve la disposición configurada para la barra de navegación.
layout: navbar::Layout,
/// Devuelve la posición configurada para la barra de navegación.
position: navbar::Position,
/// Devuelve la lista de contenidos.
items: Children,
}
impl Component for Navbar {
fn new() -> Self {
Navbar::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -46,7 +51,7 @@ impl Component for Navbar {
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
// Botón de despliegue (colapso u offcanvas) para la barra.
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
let id_content_target = join!("#", id_content);
let id_content_target = util::join!("#", id_content);
let aria_expanded = if data_bs_toggle == TOGGLE_COLLAPSE {
Some("false")
} else {
@ -73,7 +78,7 @@ impl Component for Navbar {
return PrepareMarkup::None;
}
// Asegura que la barra tiene un id estable para poder asociarlo al colapso/offcanvas.
// Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas.
let id = cx.required_id::<Self>(self.id());
PrepareMarkup::With(html! {
@ -87,7 +92,7 @@ impl Component for Navbar {
// Barra sencilla que se puede contraer/expandir.
navbar::Layout::SimpleToggle => {
@let id_content = join!(id, "-content");
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(id_content) class="collapse navbar-collapse" {
@ -103,7 +108,7 @@ impl Component for Navbar {
// Barra con marca a la izquierda y botón a la derecha.
navbar::Layout::BrandLeft(brand) => {
@let id_content = join!(id, "-content");
@let id_content = util::join!(id, "-content");
(brand.render(cx))
(button(cx, TOGGLE_COLLAPSE, &id_content))
@ -114,7 +119,7 @@ impl Component for Navbar {
// Barra con botón a la izquierda y marca a la derecha.
navbar::Layout::BrandRight(brand) => {
@let id_content = join!(id, "-content");
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx))
@ -164,37 +169,37 @@ impl Component for Navbar {
impl Navbar {
/// Crea una barra de navegación **simple**, sin marca y sin botón.
pub fn simple() -> Self {
Navbar::default().with_layout(navbar::Layout::Simple)
Self::default().with_layout(navbar::Layout::Simple)
}
/// Crea una barra de navegación **simple pero colapsable**, con botón a la izquierda.
pub fn simple_toggle() -> Self {
Navbar::default().with_layout(navbar::Layout::SimpleToggle)
Self::default().with_layout(navbar::Layout::SimpleToggle)
}
/// Crea una barra de navegación **con marca a la izquierda**, siempre visible.
pub fn simple_brand_left(brand: navbar::Brand) -> Self {
Navbar::default().with_layout(navbar::Layout::SimpleBrandLeft(Typed::with(brand)))
Self::default().with_layout(navbar::Layout::SimpleBrandLeft(Typed::with(brand)))
}
/// Crea una barra de navegación con **marca a la izquierda** y **botón a la derecha**.
pub fn brand_left(brand: navbar::Brand) -> Self {
Navbar::default().with_layout(navbar::Layout::BrandLeft(Typed::with(brand)))
Self::default().with_layout(navbar::Layout::BrandLeft(Typed::with(brand)))
}
/// Crea una barra de navegación con **botón a la izquierda** y **marca a la derecha**.
pub fn brand_right(brand: navbar::Brand) -> Self {
Navbar::default().with_layout(navbar::Layout::BrandRight(Typed::with(brand)))
Self::default().with_layout(navbar::Layout::BrandRight(Typed::with(brand)))
}
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
pub fn offcanvas(oc: Offcanvas) -> Self {
Navbar::default().with_layout(navbar::Layout::Offcanvas(Typed::with(oc)))
Self::default().with_layout(navbar::Layout::Offcanvas(Typed::with(oc)))
}
/// Crea una barra de navegación con **marca a la izquierda** y contenido en **offcanvas**.
pub fn offcanvas_brand_left(brand: navbar::Brand, oc: Offcanvas) -> Self {
Navbar::default().with_layout(navbar::Layout::OffcanvasBrandLeft(
Self::default().with_layout(navbar::Layout::OffcanvasBrandLeft(
Typed::with(brand),
Typed::with(oc),
))
@ -202,7 +207,7 @@ impl Navbar {
/// Crea una barra de navegación con **marca a la derecha** y contenido en **offcanvas**.
pub fn offcanvas_brand_right(brand: navbar::Brand, oc: Offcanvas) -> Self {
Navbar::default().with_layout(navbar::Layout::OffcanvasBrandRight(
Self::default().with_layout(navbar::Layout::OffcanvasBrandRight(
Typed::with(brand),
Typed::with(oc),
))
@ -213,7 +218,7 @@ impl Navbar {
/// Establece el identificador único (`id`) de la barra de navegación.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -225,7 +230,7 @@ impl Navbar {
/// - Definir la apariencia del texto ([`classes::Text`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -263,31 +268,4 @@ impl Navbar {
self.items.alter_typed(op);
self
}
// **< Navbar GETTERS >*************************************************************************
/// Devuelve las clases CSS asociadas a la barra de navegación.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el punto de ruptura configurado.
pub fn expand(&self) -> &BreakPoint {
&self.expand
}
/// Devuelve la disposición configurada para la barra de navegación.
pub fn layout(&self) -> &navbar::Layout {
&self.layout
}
/// Devuelve la posición configurada para la barra de navegación.
pub fn position(&self) -> &navbar::Position {
&self.position
}
/// Devuelve la lista de contenidos (`children`).
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -20,13 +20,13 @@ pub enum Item {
Brand(Typed<navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::Nav).
Nav(Typed<Nav>),
/// Representa un texto libre localizado.
/// Representa un *texto localizado* libre.
Text(L10n),
}
impl Component for Item {
fn new() -> Self {
Item::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -88,7 +88,7 @@ impl Item {
Self::Nav(Typed::with(item))
}
/// Crea un elemento de texto localizado, mostrado sin interacción.
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
pub fn text(item: L10n) -> Self {
Self::Text(item)
}

View file

@ -64,7 +64,7 @@ pub enum Position {
}
impl Position {
// Devuelve la clase base asociada a la posición de la barra de navegación.
/// Devuelve la clase base asociada a la posición de la barra de navegación.
#[inline]
const fn as_str(self) -> &'static str {
match self {
@ -76,7 +76,7 @@ impl Position {
}
}
// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
/// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
@ -90,7 +90,7 @@ impl Position {
}
/* Devuelve la clase asociada a la posición de la barra de navegación, o cadena vacía si no
// aplica (reservado).
/// aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_string()

View file

@ -15,8 +15,8 @@
//! .add_child(Dropdown::new()
//! .with_title(L10n::n("Menu"))
//! .add_item(dropdown::Item::label(L10n::n("Label")))
//! .add_item(dropdown::Item::link_blank(L10n::n("Google"), |_| "https://www.google.es"))
//! .add_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout"))
//! .add_item(dropdown::Item::link_blank(L10n::n("Google"), |_| "https://google.es".into()))
//! .add_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout".into()))
//! );
//! ```

View file

@ -21,23 +21,31 @@ use crate::LOCALES_BOOTSIER;
///
/// Ver ejemplo en el módulo [`offcanvas`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Getters)]
pub struct Offcanvas {
id : AttrId,
classes : AttrClasses,
title : L10n,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al panel.
classes: Classes,
/// Devuelve el título del panel.
title: L10n,
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
breakpoint: BreakPoint,
backdrop : offcanvas::Backdrop,
scrolling : offcanvas::BodyScroll,
placement : offcanvas::Placement,
/// Devuelve el comportamiento configurado para la capa de fondo.
backdrop: offcanvas::Backdrop,
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
body_scroll: offcanvas::BodyScroll,
/// Devuelve la posición de inicio del panel.
placement: offcanvas::Placement,
/// Devuelve el estado inicial del panel.
visibility: offcanvas::Visibility,
children : Children,
/// Devuelve la lista de componentes (`children`) del panel.
children: Children,
}
impl Component for Offcanvas {
fn new() -> Self {
Offcanvas::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -65,14 +73,14 @@ impl Offcanvas {
/// Establece el identificador único (`id`) del panel.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al panel.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -109,7 +117,7 @@ impl Offcanvas {
/// Permite o bloquea el desplazamiento de la página principal mientras el panel está abierto.
#[builder_fn]
pub fn with_body_scroll(mut self, scrolling: offcanvas::BodyScroll) -> Self {
self.scrolling = scrolling;
self.body_scroll = scrolling;
self
}
@ -141,48 +149,6 @@ impl Offcanvas {
self
}
// **< Offcanvas GETTERS >**********************************************************************
/// Devuelve las clases CSS asociadas al panel.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el título del panel.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
pub fn breakpoint(&self) -> &BreakPoint {
&self.breakpoint
}
/// Devuelve el comportamiento configurado para la capa de fondo.
pub fn backdrop(&self) -> &offcanvas::Backdrop {
&self.backdrop
}
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
pub fn body_scroll(&self) -> &offcanvas::BodyScroll {
&self.scrolling
}
/// Devuelve la posición de inicio del panel.
pub fn placement(&self) -> &offcanvas::Placement {
&self.placement
}
/// Devuelve el estado inicial del panel.
pub fn visibility(&self) -> &offcanvas::Visibility {
&self.visibility
}
/// Devuelve la lista de componentes (`children`) del panel.
pub fn children(&self) -> &Children {
&self.children
}
// **< Offcanvas HELPERS >**********************************************************************
pub(crate) fn render_offcanvas(&self, cx: &mut Context, extra: Option<&Children>) -> Markup {
@ -193,8 +159,8 @@ impl Offcanvas {
}
let id = cx.required_id::<Self>(self.id());
let id_label = join!(id, "-label");
let id_target = join!("#", id);
let id_label = util::join!(id, "-label");
let id_target = util::join!("#", id);
let body_scroll = match self.body_scroll() {
offcanvas::BodyScroll::Disabled => None,

View file

@ -48,7 +48,7 @@ pub enum Placement {
}
impl Placement {
// Devuelve la clase base asociada a la posición de aparición del panel.
/// Devuelve la clase base asociada a la posición de aparición del panel.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
@ -60,7 +60,7 @@ impl Placement {
}
}
// Añade la clase asociada a la posición de aparición del panel a la cadena de clases.
/// Añade la clase asociada a la posición de aparición del panel a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if !classes.is_empty() {
@ -89,7 +89,7 @@ pub enum Visibility {
}
impl Visibility {
// Devuelve la clase base asociada al estado inicial del panel.
/// Devuelve la clase base asociada al estado inicial del panel.
#[inline]
const fn as_str(self) -> &'static str {
match self {
@ -98,7 +98,7 @@ impl Visibility {
}
}
// Añade la clase asociada al estado inicial del panel a la cadena de clases.
/// Añade la clase asociada al estado inicial del panel a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();

View file

@ -106,3 +106,9 @@ $utilities: map-merge(
),
)
);
// Region Footer
.region-footer {
padding: .75rem 0 3rem;
text-align: center;
}

View file

@ -11,14 +11,14 @@
</div>
## Sobre PageTop
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
# ⚡️ Guía rápida
## ⚡️ Guía rápida
Añadir en el archivo `Cargo.toml` del proyecto:
@ -30,7 +30,7 @@ pagetop-build = { ... }
Y crear un archivo `build.rs` a la altura de `Cargo.toml` para indicar cómo se van a incluir los
archivos estáticos o cómo se van a compilar los archivos SCSS para el proyecto. Casos de uso:
## Incluir archivos estáticos desde un directorio
### Incluir archivos estáticos desde un directorio
Hay que preparar una carpeta en el proyecto con todos los archivos que se quieren incluir, por
ejemplo `static`, y añadir el siguiente código en `build.rs` para crear el conjunto de recursos:
@ -64,7 +64,7 @@ fn main() -> std::io::Result<()> {
}
```
## Compilar archivos SCSS a CSS
### Compilar archivos SCSS a CSS
Se puede compilar un archivo SCSS, que podría importar otros a su vez, para preparar un recurso con
el archivo CSS minificado obtenido. Por ejemplo:
@ -83,7 +83,7 @@ Este código compila el archivo `main.scss` de la carpeta `static` del proyecto,
llamado `main_styles` que contiene el archivo `styles.min.css` obtenido.
# 📦 Archivos generados
## 📦 Archivos generados
Cada conjunto de recursos [`StaticFilesBundle`] genera un archivo en el directorio estándar
[OUT_DIR](https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-build-scripts)
@ -111,14 +111,14 @@ impl Extension for MyExtension {
```
# 🚧 Advertencia
## 🚧 Advertencia
**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**.
# 📜 Licencia
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -104,7 +104,7 @@ use pagetop::prelude::*;
pub struct MyExtension;
impl Extension for MyExtension {
// Servicio web que publica los recursos de `guides` en `/ruta/a/guides`.
/// Servicio web que publica los recursos de `guides` en `/ruta/a/guides`.
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
static_files_service!(scfg, guides => "/ruta/a/guides");
}

View file

@ -11,15 +11,16 @@
</div>
## Sobre PageTop
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## Créditos
Esta librería incluye entre sus macros una adaptación de
## 📚 Créditos
Este *crate* incluye entre sus macros una adaptación de
[maud-macros](https://crates.io/crates/maud_macros)
([0.27.0](https://github.com/lambda-fairy/maud/tree/v0.27.0/maud_macros)) de
[Chris Wong](https://crates.io/users/lambda-fairy) y una versión renombrada de
@ -29,14 +30,14 @@ necesidad de referenciar `maud` o `smart_default` en las dependencias del archiv
cada proyecto PageTop.
# 🚧 Advertencia
## 🚧 Advertencia
**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**.
# 📜 Licencia
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -20,7 +20,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Créditos
Esta librería incluye entre sus macros una adaptación de
Este *crate* incluye entre sus macros una adaptación de
[maud-macros](https://crates.io/crates/maud_macros)
([0.27.0](https://github.com/lambda-fairy/maud/tree/v0.27.0/maud_macros)) de
[Chris Wong](https://crates.io/users/lambda-fairy) y una versión renombrada de
@ -85,7 +85,7 @@ pub fn html(input: TokenStream) -> TokenStream {
/// b: 0,
/// c: Some(0),
/// d: vec![1, 2, 3],
/// e: "four".to_owned(),
/// e: "four".to_string(),
/// });
/// # }
/// ```
@ -138,7 +138,7 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// # }
/// ```
///
/// la macro rescribirá el método `with_` y generará un nuevo método `alter_`:
/// la macro reescribirá el método `with_` y generará un nuevo método `alter_`:
///
/// ```rust
/// # struct Example {value: Option<String>};
@ -157,7 +157,11 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// ```
///
/// De esta forma, cada método *builder* `with_...()` generará automáticamente su correspondiente
/// método `alter_...()` para dejar modificar instancias existentes.
/// método `alter_...()` para modificar instancias existentes.
///
/// La documentación del método `with_...()` incluirá también la firma resumida del método
/// `alter_...()` y un alias de búsqueda con su nombre, de tal manera que buscando `alter_...` en la
/// documentación se mostrará la entrada del método `with_...()`.
#[proc_macro_attribute]
pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
use syn::{parse2, FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type};
@ -282,11 +286,11 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
}
}
// Genera el nombre del método alter_...().
// Genera el nombre del método `alter_...()`.
let stem = with_name_str.strip_prefix("with_").expect("validated");
let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span());
// Extrae genéricos y cláusulas where.
// Extrae genéricos y cláusulas `where`.
let generics = &sig.generics;
let where_clause = &sig.generics.where_clause;
@ -319,28 +323,75 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
v
};
// Filtra los atributos descartando `#[doc]` y `#[inline]` para el método `alter_...()`.
let non_doc_or_inline_attrs: Vec<_> = attrs
.iter()
.filter(|a| {
let p = a.path();
!p.is_ident("doc") && !p.is_ident("inline")
})
.cloned()
.collect();
// Separa atributos de documentación y resto.
let mut doc_attrs = Vec::new();
let mut other_attrs = Vec::new();
let mut non_doc_or_inline_attrs = Vec::new();
// Documentación del método alter_...().
let doc = format!("Equivale a [`Self::{with_name_str}()`], pero fuera del patrón *builder*.");
for a in attrs.iter() {
let p = a.path();
if p.is_ident("doc") {
doc_attrs.push(a.clone());
} else {
other_attrs.push(a.clone());
if !p.is_ident("inline") {
non_doc_or_inline_attrs.push(a.clone());
}
}
}
// Firma resumida de la función `alter_...()` para mostrarla en la doc de `with_...()`.
let alter_sig_tokens = if args.is_empty() {
// Sin argumentos sólo se muestra `&mut self` (puede que no tenga mucho sentido).
quote! { #vis_pub fn #alter_ident #generics (&mut self) -> &mut Self #where_clause }
} else {
// Con argumentos se muestra `&mut self, ...`.
quote! { #vis_pub fn #alter_ident #generics (&mut self, ...) -> &mut Self #where_clause }
};
// Normaliza espacios raros tipo `& mut`.
let alter_sig_str = alter_sig_tokens.to_string().replace("& mut", "&mut");
// Nombre de la función `alter_...()` como alias de búsqueda.
let alter_name_str = alter_ident.to_string();
// Texto introductorio para la documentación adicional de `with_...()`.
let with_alter_title = format!(
"# {} el método `{}()` generado por [`#[builder_fn]`](pagetop_macros::builder_fn)",
if doc_attrs.is_empty() {
"Añade"
} else {
"También añade"
},
alter_name_str
);
let with_alter_doc = concat!(
"Permite modificar la instancia actual (`&mut self`) con los mismos argumentos, ",
"sin consumirla."
);
// Atributos completos que se aplican siempre a `with_...()`.
let with_prefix = quote! {
#(#other_attrs)*
#(#doc_attrs)*
#[doc(alias = #alter_name_str)]
#[doc = ""]
#[doc = #with_alter_title]
#[doc = #with_alter_doc]
#[doc = "```text"]
#[doc = #alter_sig_str]
#[doc = "```"]
};
// Genera el código final.
let expanded = match body_opt {
None => {
quote! {
#(#attrs)*
#with_prefix
fn #with_name #generics (self, #(#args),*) -> Self #where_clause;
#(#non_doc_or_inline_attrs)*
#[doc = #doc]
#[doc(hidden)]
fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause;
}
}
@ -351,8 +402,10 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
} else {
quote! { #[inline] }
};
let with_fn = if is_trait {
quote! {
#with_prefix
#force_inline
#vis_pub fn #with_name #generics (self, #(#args),*) -> Self #where_clause {
let mut s = self;
@ -362,6 +415,7 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
}
} else {
quote! {
#with_prefix
#force_inline
#vis_pub fn #with_name #generics (mut self, #(#args),*) -> Self #where_clause {
self.#alter_ident(#(#call_idents),*);
@ -369,12 +423,12 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
}
}
};
quote! {
#(#attrs)*
#with_fn
#(#non_doc_or_inline_attrs)*
#[doc = #doc]
#[doc(hidden)]
#vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause {
#body
}

View file

@ -0,0 +1,21 @@
[package]
name = "pagetop-minimal"
version = "0.0.10"
edition = "2021"
description = """
Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones
básicas en PageTop.
"""
categories = ["development-tools::build-utils"]
keywords = ["pagetop", "build", "assets", "resources", "static"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
concat-string = "1.0"
indoc = "2.0"
pastey = "0.2"

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 Manuel Cillero
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Manuel Cillero
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,61 @@
<div align="center">
<h1>PageTop Minimal</h1>
<p>Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones básicas en <strong>PageTop</strong>.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-minimal?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-minimal)
[![Crates.io](https://img.shields.io/crates/v/pagetop-minimal.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-minimal)
[![Descargas](https://img.shields.io/crates/d/pagetop-minimal.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-minimal)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-minimal#licencia)
</div>
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## 🗺️ Descripción general
Este *crate* proporciona un conjunto básico de macros que se integran en las utilidades de PageTop
para optimizar operaciones habituales relacionadas con la composición estructurada de texto, la
concatenación de cadenas y el uso rápido de colecciones clave-valor.
## 📚 Créditos
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
eficiencia pueda ser relevante.
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
## 🚧 Advertencia
**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**.
## 📜 Licencia
El código está disponible bajo una doble licencia:
* **Licencia MIT**
([LICENSE-MIT](LICENSE-MIT) o también https://opensource.org/licenses/MIT)
* **Licencia Apache, Versión 2.0**
([LICENSE-APACHE](LICENSE-APACHE) o también https://www.apache.org/licenses/LICENSE-2.0)
Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en
el ecosistema Rust.

View file

@ -0,0 +1,161 @@
/*!
<div align="center">
<h1>PageTop Minimal</h1>
<p>Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones básicas en <strong>PageTop</strong>.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-minimal?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-minimal)
[![Crates.io](https://img.shields.io/crates/v/pagetop-minimal.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-minimal)
[![Descargas](https://img.shields.io/crates/d/pagetop-minimal.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-minimal)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-minimal#licencia)
</div>
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## Descripción general
Este *crate* proporciona un conjunto básico de macros que se integran en las utilidades de PageTop
para optimizar operaciones habituales relacionadas con la composición estructurada de texto, la
concatenación de cadenas y el uso rápido de colecciones clave-valor.
## Créditos
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
eficiencia pueda ser relevante.
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
*/
#![doc(
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
)]
#[doc(hidden)]
pub use concat_string::concat_string;
pub use indoc::{concatdoc, formatdoc, indoc};
/// Permite *pegar* tokens y generar identificadores a partir de otros.
///
/// Dentro de `paste!`, los identificadores escritos como `[< ... >]` se combinan en uno solo que
/// puede reutilizarse para referirse a items existentes o para definir nuevos (funciones,
/// estructuras, métodos, etc.).
///
/// También admite modificadores de estilo (`lower`, `upper`, `snake`, `camel`, etc.) para
/// transformar fragmentos interpolados antes de construir el nuevo identificador.
pub use pastey::paste;
// La documentación anterior se copia en `pagetop::util::paste!` porque el *crate* original no la
// define y `pagetop` no la hereda automáticamente.
/// 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_minimal::join;
/// // 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:expr),+) => {
$crate::concat_string!($($arg),+)
};
}
/// Concatena dos fragmentos en un [`String`] usando un separador inteligente.
///
/// 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_minimal::join_pair;
/// 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) => {{
let first_val = $first;
let second_val = $second;
let separator_val = $separator;
let first = AsRef::<str>::as_ref(&first_val);
let second = AsRef::<str>::as_ref(&second_val);
let separator = if first.is_empty() || second.is_empty() {
""
} else {
AsRef::<str>::as_ref(&separator_val)
};
$crate::concat_string!(first, separator, second)
}};
}
/// Macro para construir una colección de pares clave-valor.
///
/// ```rust
/// # use pagetop_minimal::kv;
/// # use std::collections::HashMap;
/// let args:HashMap<&str, String> = kv![
/// "userName" => "Roberto",
/// "photoCount" => "3",
/// "userGender" => "male",
/// ];
/// ```
#[macro_export]
macro_rules! kv {
( $($key:expr => $value:expr),* $(,)? ) => {{
let mut a = std::collections::HashMap::new();
$(
a.insert($key.into(), $value.into());
)*
a
}};
}

View file

@ -11,19 +11,21 @@
</div>
## Sobre PageTop
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
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
## 🗺️ Descripción general
Este *crate* 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.
## Créditos
## 📚 Créditos
Para ello, adapta el código de los *crates* [static-files](https://crates.io/crates/static_files)
(versión [0.2.5](https://github.com/static-files-rs/static-files/tree/v0.2.5)) y
@ -35,14 +37,14 @@ Estas implementaciones se integran en PageTop para evitar que cada proyecto teng
`static-files` manualmente como dependencia en su `Cargo.toml`.
# 🚧 Advertencia
## 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
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**.
# 📜 Licencia
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -20,7 +20,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
Este *crate* 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.

View file

@ -4,9 +4,10 @@ mod figfont;
use crate::core::{extension, extension::ExtensionRef};
use crate::html::Markup;
use crate::locale::Locale;
use crate::response::page::{ErrorPage, ResultPage};
use crate::service::HttpRequest;
use crate::{global, locale, service, trace};
use crate::{global, service, trace, PAGETOP_VERSION};
use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle};
use actix_session::storage::CookieSessionStore;
@ -49,7 +50,7 @@ impl Application {
Self::internal_prepare(Some(root_extension))
}
// Método interno para preparar la aplicación, opcionalmente con una extensión.
/// Método interno para preparar la aplicación, opcionalmente con una extensión.
fn internal_prepare(root_extension: Option<ExtensionRef>) -> Self {
// Al arrancar muestra una cabecera para la aplicación.
Self::show_banner();
@ -57,8 +58,8 @@ impl Application {
// Inicia gestión de trazas y registro de eventos (logging).
LazyLock::force(&trace::TRACING);
// Valida el identificador de idioma por defecto.
LazyLock::force(&locale::DEFAULT_LANGID);
// Inicializa el idioma predeterminado.
Locale::init();
// Registra las extensiones de la aplicación.
extension::all::register_extensions(root_extension);
@ -72,12 +73,12 @@ impl Application {
Self
}
// Muestra una cabecera para la aplicación basada en la configuración.
/// Muestra una cabecera para la aplicación basada en la configuración.
fn show_banner() {
use colored::Colorize;
use terminal_size::{terminal_size, Width};
if global::SETTINGS.app.startup_banner.to_lowercase() != "off" {
if global::SETTINGS.app.startup_banner != global::StartupBanner::Off {
// Nombre de la aplicación, ajustado al ancho del terminal si es necesario.
let mut app_ff = String::new();
let app_name = &global::SETTINGS.app.name;
@ -108,7 +109,7 @@ impl Application {
println!(
"{} {}\n",
"Powered by PageTop".yellow(),
env!("CARGO_PKG_VERSION").yellow()
PAGETOP_VERSION.yellow()
);
}
}
@ -163,7 +164,7 @@ impl Application {
Self::service_app()
}
// Configura el servicio web de la aplicación.
/// Configura el servicio web de la aplicación.
fn service_app() -> service::App<
impl service::Factory<
service::Request,

View file

@ -10,21 +10,11 @@ pub static FIGFONT: LazyLock<FIGfont> = LazyLock::new(|| {
let speed = include_str!("speed.flf");
let starwars = include_str!("starwars.flf");
FIGfont::from_content(
match global::SETTINGS.app.startup_banner.to_lowercase().as_str() {
"off" => slant,
"slant" => slant,
"small" => small,
"speed" => speed,
"starwars" => starwars,
_ => {
println!(
"\n FIGfont \"{}\" not found for banner. Using \"Slant\". Check settings.",
global::SETTINGS.app.startup_banner,
);
slant
}
},
)
FIGfont::from_content(match global::SETTINGS.app.startup_banner {
global::StartupBanner::Off | global::StartupBanner::Slant => slant,
global::StartupBanner::Small => small,
global::StartupBanner::Speed => speed,
global::StartupBanner::Starwars => starwars,
})
.unwrap()
});

View file

@ -42,7 +42,7 @@ impl<C: Component> AfterRender<C> {
/// Afina el registro para ejecutar la acción [`FnActionWithComponent`] sólo para el componente
/// `C` con identificador `id`.
pub fn filter_by_referer_id(mut self, id: impl AsRef<str>) -> Self {
self.referer_id.alter_value(id);
self.referer_id.alter_id(id);
self
}
@ -52,7 +52,7 @@ impl<C: Component> AfterRender<C> {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
// Primero despacha las acciones para el tipo de componente.

View file

@ -42,7 +42,7 @@ impl<C: Component> BeforeRender<C> {
/// Afina el registro para ejecutar la acción [`FnActionWithComponent`] sólo para el componente
/// `C` con identificador `id`.
pub fn filter_by_referer_id(mut self, id: impl AsRef<str>) -> Self {
self.referer_id.alter_value(id);
self.referer_id.alter_id(id);
self
}
@ -52,7 +52,7 @@ impl<C: Component> BeforeRender<C> {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
// Primero despacha las acciones para el tipo de componente.

View file

@ -34,7 +34,7 @@ impl AfterRenderBody {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline(always)]
#[allow(clippy::inline_always)]
pub(crate) fn dispatch(page: &mut Page) {

View file

@ -34,7 +34,7 @@ impl BeforeRenderBody {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline(always)]
#[allow(clippy::inline_always)]
pub(crate) fn dispatch(page: &mut Page) {

View file

@ -34,7 +34,7 @@ impl<C: Component> AfterRender<C> {
}
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
dispatch_actions(

View file

@ -34,7 +34,7 @@ impl<C: Component> BeforeRender<C> {
}
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
dispatch_actions(

View file

@ -41,7 +41,7 @@ impl<C: Component> PrepareRender<C> {
}
}
// Despacha las acciones. Se detiene en cuanto una renderiza.
/// Despacha las acciones. Se detiene en cuanto una renderiza.
#[inline]
pub(crate) fn dispatch(component: &C, cx: &mut Context) -> PrepareMarkup {
let mut render_component = PrepareMarkup::None;

View file

@ -1,46 +1,8 @@
//! Componentes nativos proporcionados por PageTop.
//!
//! Conviene destacar que PageTop distingue entre:
//!
//! - **Componentes estructurales** que definen el esqueleto de un documento HTML, como [`Template`]
//! y [`Region`], utilizados por [`Page`](crate::response::page::Page) para generar la estructura
//! final.
//! - **Componentes de contenido** (menús, barras, tarjetas, etc.), que se incluyen en las regiones
//! gestionadas por los componentes estructurales.
//!
//! El componente [`Template`] describe cómo maquetar el cuerpo del documento a partir de varias
//! regiones lógicas ([`Region`]). En función de la plantilla seleccionada, determina qué regiones
//! se renderizan y en qué orden. Por ejemplo, la plantilla predeterminada [`Template::DEFAULT`]
//! utiliza las regiones [`Region::HEADER`], [`Region::CONTENT`] y [`Region::FOOTER`].
//!
//! Un componente [`Region`] es un contenedor lógico asociado a un nombre de región. Su contenido se
//! obtiene del [`Context`](crate::core::component::Context), donde los componentes se registran
//! mediante [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in) y
//! otros mecanismos similares, y se integra en el documento a través de [`Template`].
//!
//! Por su parte, una página ([`Page`](crate::response::page::Page)) representa un documento HTML
//! completo. Implementa [`Contextual`](crate::core::component::Contextual) para mantener su propio
//! [`Context`](crate::core::component::Context), donde gestiona el tema activo, la plantilla
//! seleccionada y los componentes asociados a cada región, y se encarga de generar la estructura
//! final de la página.
//!
//! De este modo, temas y extensiones colaboran sobre una estructura común: las aplicaciones
//! registran componentes en el [`Context`](crate::core::component::Context), las plantillas
//! organizan las regiones y las páginas generan el documento HTML resultante.
//!
//! Los temas pueden sobrescribir [`Template`] para exponer nuevas plantillas o adaptar las
//! predeterminadas, y lo mismo con [`Region`] para añadir regiones adicionales o personalizar su
//! representación.
mod html;
pub use html::Html;
mod region;
pub use region::Region;
mod template;
pub use template::Template;
mod block;
pub use block::Block;

View file

@ -4,18 +4,21 @@ use crate::prelude::*;
///
/// 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)]
#[derive(AutoDefault, Getters)]
pub struct Block {
id : AttrId,
classes : AttrClasses,
title : L10n,
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al bloque.
classes: Classes,
/// Devuelve el título del bloque.
title: L10n,
/// Devuelve la lista de componentes hijo del bloque.
children: Children,
}
impl Component for Block {
fn new() -> Self {
Block::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -52,14 +55,14 @@ impl Block {
/// 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.id.alter_id(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.classes.alter_classes(op, classes);
self
}
@ -83,21 +86,4 @@ impl Block {
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.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve la lista de componentes (`children`) del bloque.
pub fn children(&self) -> &Children {
&self.children
}
}

View file

@ -1,6 +1,6 @@
use crate::prelude::*;
/// Componente básico para renderizar dinámicamente código HTML recibiendo el contexto.
/// Componente básico que renderiza dinámicamente código HTML según el contexto.
///
/// Este componente permite generar contenido HTML arbitrario, usando la macro `html!` y accediendo
/// opcionalmente al contexto de renderizado.
@ -33,12 +33,13 @@ pub struct Html(Box<dyn Fn(&mut Context) -> Markup + Send + Sync>);
impl Default for Html {
fn default() -> Self {
Html::with(|_| html! {})
Self::with(|_| html! {})
}
}
impl Component for Html {
fn new() -> Self {
Html::default()
Self::default()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
@ -51,9 +52,8 @@ impl Html {
/// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
///
/// El método [`prepare_component()`](crate::core::component::Component::prepare_component)
/// delega el renderizado en la función proporcionada, que recibe una referencia mutable al
/// contexto de renderizado ([`Context`]).
/// El método [`Self::prepare_component()`] delega el renderizado a la función que aquí se
/// proporciona, que recibe una referencia mutable al [`Context`].
pub fn with<F>(f: F) -> Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
@ -64,8 +64,8 @@ impl Html {
/// Sustituye la función que genera el `Markup`.
///
/// 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`]).
/// [`Self::prepare_component()`] invoque esta instancia. La nueva función también recibe una
/// referencia al [`Context`].
#[builder_fn]
pub fn with_fn<F>(mut self, f: F) -> Self
where

View file

@ -17,19 +17,19 @@ pub enum IntroOpening {
Custom,
}
/// Componente para presentar PageTop (como [`Welcome`](crate::base::extension::Welcome)), o mostrar
/// introducciones.
/// Componente para divulgar PageTop (como hace [`Welcome`](crate::base::extension::Welcome)), o
/// mostrar presentaciones.
///
/// Usa la imagen de PageTop para presentar contenidos con:
/// Usa la imagen de PageTop para mostrar:
///
/// - Una **imagen decorativa** (el *monster* de PageTop) antecediendo al contenido.
/// - Una vista destacada con **título + eslogan**.
/// - Una **figura decorativa** (que incluye el *monster* de PageTop) antecediendo al contenido.
/// - Una vista destacada del **título** de la página con un **eslogan** de presentación.
/// - Un **botón opcional** de llamada a la acción con texto y enlace configurables.
/// - El **área de textos** con *badges* predefinidos (en modo [`IntroOpening::PageTop`]) y bloques
/// ([`Block`](crate::base::component::Block)) para crear párrafos vistosos de texto. Aunque
/// admite todo tipo de componentes.
/// - Un **área para la presentación de contenidos**, con *badges* informativos de PageTop (si se
/// opta por [`IntroOpening::PageTop`]) y bloques ([`Block`](crate::base::component::Block)) de
/// contenido libre para crear párrafos vistosos de texto. Aunque admite todo tipo de componentes.
///
/// ### Ejemplos
/// # Ejemplos
///
/// **Intro mínima por defecto**
///
@ -47,11 +47,11 @@ pub enum IntroOpening {
/// .with_slogan(L10n::l("intro_custom_slogan"))
/// .with_button(Some((
/// L10n::l("intro_learn_more"),
/// |_| "/learn-more"
/// |_| "/learn-more".into()
/// )));
/// ```
///
/// **Sin botón + modo *Custom* (sin *badges* predefinidos)**
/// **Sin botón y en modo *Custom* (sin *badges* predefinidos)**
///
/// ```rust
/// # use pagetop::prelude::*;
@ -76,23 +76,29 @@ pub enum IntroOpening {
/// })),
/// );
/// ```
#[rustfmt::skip]
#[derive(Getters)]
pub struct Intro {
title : L10n,
slogan : L10n,
button : Option<(L10n, FnPathByContext)>,
opening : IntroOpening,
/// Devuelve el título de entrada.
title: L10n,
/// Devuelve el eslogan de la entrada.
slogan: L10n,
/// Devuelve el botón de llamada a la acción, si existe.
button: Option<(L10n, FnPathByContext)>,
/// Devuelve el modo de apertura configurado.
opening: IntroOpening,
/// Devuelve la lista de componentes hijo de la intro.
children: Children,
}
impl Default for Intro {
#[rustfmt::skip]
fn default() -> Self {
const BUTTON_LINK: &str = "https://pagetop.cillero.es";
Intro {
title : L10n::l("intro_default_title"),
slogan : L10n::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name),
button : Some((L10n::l("intro_default_button"), |_| "https://pagetop.cillero.es")),
opening : IntroOpening::default(),
title: L10n::l("intro_default_title"),
slogan: L10n::l("intro_default_slogan").with_arg("app", &global::SETTINGS.app.name),
button: Some((L10n::l("intro_default_button"), |_| BUTTON_LINK.into())),
opening: IntroOpening::default(),
children: Children::default(),
}
}
@ -100,17 +106,17 @@ impl Default for Intro {
impl Component for Intro {
fn new() -> Self {
Intro::default()
Self::default()
}
fn setup_before_prepare(&mut self, cx: &mut Context) {
cx.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/intro.css").with_version(env!("CARGO_PKG_VERSION")),
StyleSheet::from("/css/intro.css").with_version(PAGETOP_VERSION),
));
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
if self.opening() == IntroOpening::PageTop {
if *self.opening() == IntroOpening::PageTop {
cx.alter_assets(ContextOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx|
util::indoc!(r#"
try {
@ -163,7 +169,7 @@ impl Component for Intro {
}
}
div class="intro-text__children" {
@if self.opening() == IntroOpening::PageTop {
@if *self.opening() == IntroOpening::PageTop {
p { (L10n::l("intro_text1").using(cx)) }
div id="intro-badges" {
img
@ -246,7 +252,7 @@ impl Intro {
/// ```rust
/// # use pagetop::prelude::*;
/// // Define un botón con texto y una URL fija.
/// let intro = Intro::default().with_button(Some((L10n::n("Learn more"), |_| "/start")));
/// let intro = Intro::default().with_button(Some((L10n::n("Start"), |_| "/start".into())));
/// // Descarta el botón de la intro.
/// let intro_no_button = Intro::default().with_button(None);
/// ```
@ -289,31 +295,4 @@ impl Intro {
self.children.alter_child(op);
self
}
// **< Intro GETTERS >**************************************************************************
/// Devuelve el título de entrada.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el eslogan de la entrada.
pub fn slogan(&self) -> &L10n {
&self.slogan
}
/// Devuelve el botón de llamada a la acción, si existe.
pub fn button(&self) -> Option<(&L10n, &FnPathByContext)> {
self.button.as_ref().map(|(txt, lnk)| (txt, lnk))
}
/// Devuelve el modo de apertura configurado.
pub fn opening(&self) -> IntroOpening {
self.opening
}
/// Devuelve la lista de componentes (`children`) de la intro.
pub fn children(&self) -> &Children {
&self.children
}
}

View file

@ -3,13 +3,14 @@ use crate::prelude::*;
// Enlace a la página oficial de PageTop.
const LINK: &str = "<a href=\"https://pagetop.cillero.es\" rel=\"noopener noreferrer\">PageTop</a>";
/// Componente que informa del 'Powered by' (*Funciona con*) típica del pie de página.
/// Componente que muestra el típico mensaje *Powered by* (*Funciona con*) en el 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)]
#[derive(AutoDefault, Getters)]
pub struct PoweredBy {
/// Devuelve el texto de copyright actual, si existe.
copyright: Option<String>,
}
@ -17,10 +18,10 @@ 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`].
/// configurada en [`global::SETTINGS`], en el formato `YYYY © Nombre de la aplicación`.
fn new() -> Self {
let year = Utc::now().format("%Y").to_string();
let c = join!(year, " © ", global::SETTINGS.app.name);
let c = util::join!(year, " © ", global::SETTINGS.app.name);
PoweredBy { copyright: Some(c) }
}
@ -56,11 +57,4 @@ impl PoweredBy {
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,150 +0,0 @@
use crate::prelude::*;
/// Componente estructural que renderiza el contenido de una región del documento.
///
/// `Region` actúa como un contenedor lógico asociado a un nombre de región. Su contenido se obtiene
/// del contexto de renderizado ([`Context`]), donde los componentes suelen registrarse con métodos
/// como [`Contextual::with_child_in()`]. Cada región puede integrarse posteriormente en el cuerpo
/// del documento mediante [`Template`], normalmente desde una página ([`Page`]).
#[derive(AutoDefault)]
pub struct Region {
#[default(AttrName::new(Self::DEFAULT))]
name: AttrName,
#[default(L10n::l("region-content"))]
label: L10n,
}
impl Component for Region {
fn new() -> Self {
Region::default()
}
fn id(&self) -> Option<String> {
self.name.get()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let Some(name) = self.name().get() else {
return PrepareMarkup::None;
};
let output = cx.render_region(&name);
if output.is_empty() {
return PrepareMarkup::None;
}
PrepareMarkup::With(html! {
div
id=[self.id()]
class=(join!("region region-", &name))
role="region"
aria-label=[self.label().lookup(cx)]
{
(output)
}
})
}
}
impl Region {
/// Región especial situada al **inicio del documento**.
///
/// Su función es proporcionar un punto estable donde las extensiones puedan inyectar contenido
/// global antes de renderizar el resto de regiones principales (cabecera, contenido, etc.).
///
/// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual,
/// sino como punto de anclaje para elementos auxiliares, marcadores técnicos, inicializadores o
/// contenido de depuración que deban situarse en la parte superior del documento.
///
/// Se considera una región **reservada** para este tipo de usos globales.
pub const PAGETOP: &str = "page-top";
/// Región estándar para la **cabecera** del documento.
///
/// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc.
pub const HEADER: &str = "header";
/// Región principal de **contenido**.
///
/// Es la región donde se espera que se renderice el contenido principal de la página (p. ej.
/// cuerpo de la ruta actual, bloques centrales, vistas principales, etc.). En muchos temas será
/// la región mínima imprescindible para que la página tenga sentido.
pub const CONTENT: &str = "content";
/// Región estándar para el **pie de página**.
///
/// Suele contener información legal, enlaces secundarios, créditos, etc.
pub const FOOTER: &str = "footer";
/// Región especial situada al **final del documento**.
///
/// Pensada para proporcionar un punto estable donde las extensiones puedan inyectar contenido
/// global después de renderizar el resto de regiones principales (cabecera, contenido, etc.).
///
/// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual,
/// sino como punto de anclaje para elementos auxiliares asociados a comportamientos dinámicos
/// que deban situarse en la parte inferior del documento.
///
/// Igual que [`Self::PAGETOP`], se considera una región **reservada** para este tipo de usos
/// globales.
pub const PAGEBOTTOM: &str = "page-bottom";
/// Región por defecto que se asigna cuando no se especifica ningún nombre.
///
/// Por diseño, la región por defecto es la de contenido principal ([`Self::CONTENT`]), de
/// manera que un tema sencillo pueda limitarse a definir una sola región funcional.
pub const DEFAULT: &str = Self::CONTENT;
/// Prepara una región para el nombre indicado.
///
/// El valor de `name` se utiliza como nombre de la región y como identificador (`id`) del
/// contenedor. Al renderizarse, este componente mostrará el contenido registrado en el contexto
/// bajo ese nombre.
pub fn named(name: impl AsRef<str>) -> Self {
Region {
name: AttrName::new(name),
label: L10n::default(),
}
}
/// Prepara una región para el nombre indicado con una etiqueta de accesibilidad.
///
/// El valor de `name` se utiliza como nombre de la región y como identificador (`id`) del
/// contenedor, mientras que `label` será el texto localizado que se usará como `aria-label` del
/// contenedor.
pub fn labeled(name: impl AsRef<str>, label: L10n) -> Self {
Region {
name: AttrName::new(name),
label,
}
}
// **< Region BUILDER >*************************************************************************
/// Establece o modifica el nombre de la región.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_value(name);
self
}
/// Establece la etiqueta localizada de la región.
///
/// Esta etiqueta se utiliza como `aria-label` del contenedor predefinido `<div role="region">`,
/// lo que mejora la accesibilidad para lectores de pantalla y otras tecnologías de apoyo.
#[builder_fn]
pub fn with_label(mut self, label: L10n) -> Self {
self.label = label;
self
}
// **< Region GETTERS >*************************************************************************
/// Devuelve el nombre de la región.
pub fn name(&self) -> &AttrName {
&self.name
}
/// Devuelve la etiqueta localizada asociada a la región.
pub fn label(&self) -> &L10n {
&self.label
}
}

View file

@ -1,84 +0,0 @@
use crate::prelude::*;
/// Componente estructural para renderizar plantillas de contenido.
///
/// `Template` describe cómo se compone el cuerpo del documento a partir de varias regiones lógicas
/// ([`Region`]). En función de su nombre, decide qué regiones se renderizan y en qué orden.
///
/// Normalmente se invoca desde una página ([`Page`]), que consulta el nombre de plantilla guardado
/// en el [`Context`] y delega en `Template` la composición de las regiones que forman el cuerpo del
/// documento.
///
/// Los temas pueden sobrescribir este componente para exponer sus propias plantillas o adaptar las
/// plantillas predeterminadas.
#[derive(AutoDefault)]
pub struct Template {
#[default(AttrName::new(Self::DEFAULT))]
name: AttrName,
}
impl Component for Template {
fn new() -> Self {
Template::default()
}
fn id(&self) -> Option<String> {
self.name.get()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let Some(name) = self.name().get() else {
return PrepareMarkup::None;
};
match name.as_str() {
Self::DEFAULT | Self::ERROR => PrepareMarkup::With(html! {
(Region::labeled(Region::HEADER, L10n::l("region-header")).render(cx))
(Region::default().render(cx))
(Region::labeled(Region::FOOTER, L10n::l("region-footer")).render(cx))
}),
_ => PrepareMarkup::None,
}
}
}
impl Template {
/// Nombre de la plantilla predeterminada.
///
/// Por defecto define una estructura básica con las regiones [`Region::HEADER`],
/// [`Region::CONTENT`] y [`Region::FOOTER`], en ese orden. Esta plantilla se usa cuando no se
/// selecciona ninguna otra de forma explícita (ver [`Contextual::with_template()`]).
pub const DEFAULT: &str = "default";
/// Nombre de la plantilla de error.
///
/// Se utiliza para páginas de error u otros estados excepcionales. Por defecto reutiliza
/// la misma estructura que [`Self::DEFAULT`], pero permite a temas y extensiones distinguir
/// el contexto de error para aplicar estilos o contenidos específicos.
pub const ERROR: &str = "error";
/// Selecciona la plantilla asociada al nombre indicado.
///
/// El valor de `name` se utiliza como nombre de la plantilla y como identificador (`id`) del
/// componente.
pub fn named(name: impl AsRef<str>) -> Self {
Template {
name: AttrName::new(name),
}
}
// **< Template BUILDER >***********************************************************************
/// Establece o modifica el nombre de la plantilla seleccionada.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_value(name);
self
}
// **< Template GETTERS >***********************************************************************
/// Devuelve el nombre de la plantilla seleccionada.
pub fn name(&self) -> &AttrName {
&self.name
}
}

View file

@ -1,10 +1,14 @@
use crate::prelude::*;
/// Página de bienvenida predeterminada de PageTop.
/// Página de bienvenida 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á
/// funcionando correctamente.
/// Esta extensión se instala por defecto si el ajuste de configuración [`global::App::welcome`] es
/// `true`. Muestra una página de bienvenida de PageTop en la ruta raíz (`/`).
///
/// No obstante, cualquier extensión puede sobrescribir este comportamiento si utiliza estas mismas
/// rutas.
///
/// Resulta útil en demos o para comprobar rápidamente que el servidor ha arrancado correctamente.
pub struct Welcome;
impl Extension for Welcome {
@ -17,11 +21,11 @@ impl Extension for Welcome {
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(homepage));
scfg.route("/", service::web::get().to(home));
}
}
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn home(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
let app = &global::SETTINGS.app.name;
Page::new(request)

View file

@ -12,6 +12,19 @@ impl Extension for Basic {
impl Theme for Basic {
fn before_render_page_body(&self, page: &mut Page) {
page.alter_param("include_basic_assets", true);
page.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/basic.css")
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))
.alter_child_in(
&DefaultRegion::Footer,
ChildOp::AddIfEmpty(Child::with(PoweredBy::new())),
);
}
}

View file

@ -1,4 +1,4 @@
//! Carga las opciones de configuración.
//! Carga las opciones de configuración de la aplicación.
//!
//! Estos ajustes se obtienen de archivos [TOML](https://toml.io) como pares `clave = valor` que se
//! mapean a estructuras **fuertemente tipadas** y valores predefinidos.
@ -125,7 +125,7 @@ const DEFAULT_CONFIG_DIR: &str = "config";
// Modo de ejecución por defecto.
const DEFAULT_RUN_MODE: &str = "default";
/// Valores originales cargados desde los archivos de configuración como pares `clave = valor`.
/// Valores originales de los archivos de configuración como pares `clave = valor`.
pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(|| {
// CONFIG_DIR (si existe) o DEFAULT_CONFIG_DIR. Si no se puede resolver, se usa tal cual.
let dir = env::var_os("CONFIG_DIR").unwrap_or_else(|| DEFAULT_CONFIG_DIR.into());
@ -229,10 +229,11 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
macro_rules! include_config {
( $SETTINGS_NAME:ident : $Settings_Type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => {
#[doc = concat!(
"Instancia los ajustes de configuración para [`", stringify!($Settings_Type), "`]."
"Ajustes de configuración y **valores por defecto** para ",
"[`", stringify!($Settings_Type), "`]."
)]
#[doc = ""]
#[doc = "Valores por defecto:"]
#[doc = "Valores predeterminados que se aplican en ausencia de configuración:"]
#[doc = "```text"]
$(
#[doc = concat!($k, " = ", stringify!($v))]

View file

@ -30,28 +30,28 @@ impl TypeInfo {
}
}
// Extrae un rango de segmentos de `type_name` (tokens separados por `::`).
//
// Los argumentos `start` y `end` identifican los índices de los segmentos teniendo en cuenta:
//
// * Los índices positivos cuentan **desde la izquierda**, empezando en `0`.
// * Los índices negativos cuentan **desde la derecha**, `-1` es el último.
// * Si `end` es `None`, el corte llega hasta el último segmento.
// * Si la selección resulta vacía por índices desordenados o segmento inexistente, se devuelve
// la cadena vacía.
//
// Ejemplos (con `type_name = "alloc::vec::Vec<i32>"`):
//
// | Llamada | Resultado |
// |------------------------------|--------------------------|
// | `partial(..., 0, None)` | `"alloc::vec::Vec<i32>"` |
// | `partial(..., 1, None)` | `"vec::Vec<i32>"` |
// | `partial(..., -1, None)` | `"Vec<i32>"` |
// | `partial(..., 0, Some(-2))` | `"alloc::vec"` |
// | `partial(..., -5, None)` | `"alloc::vec::Vec<i32>"` |
//
// La porción devuelta vive tanto como `'static` porque `type_name` es `'static` y sólo se
// presta.
/// Extrae un rango de segmentos de `type_name` (tokens separados por `::`).
///
/// Los argumentos `start` y `end` identifican los índices de los segmentos teniendo en cuenta:
///
/// * Los índices positivos cuentan **desde la izquierda**, empezando en `0`.
/// * Los índices negativos cuentan **desde la derecha**, `-1` es el último.
/// * Si `end` es `None`, el corte llega hasta el último segmento.
/// * Si la selección resulta vacía por índices desordenados o segmento inexistente, se devuelve
/// la cadena vacía.
///
/// Ejemplos (con `type_name = "alloc::vec::Vec<i32>"`):
///
/// | Llamada | Resultado |
/// |------------------------------|--------------------------|
/// | `partial(..., 0, None)` | `"alloc::vec::Vec<i32>"` |
/// | `partial(..., 1, None)` | `"vec::Vec<i32>"` |
/// | `partial(..., -1, None)` | `"Vec<i32>"` |
/// | `partial(..., 0, Some(-2))` | `"alloc::vec"` |
/// | `partial(..., -5, None)` | `"alloc::vec::Vec<i32>"` |
///
/// La porción devuelta vive tanto como `'static` porque `type_name` es `'static` y sólo se
/// presta.
fn partial(type_name: &'static str, start: isize, end: Option<isize>) -> &'static str {
let maxlen = type_name.len();

View file

@ -12,13 +12,13 @@ static ACTIONS: LazyLock<RwLock<HashMap<ActionKey, ActionsList>>> =
// **< AÑADIR ACCIONES >****************************************************************************
// Registra una nueva acción en el sistema.
//
// Si ya existen acciones con la misma `ActionKey`, la acción se añade a la misma lista. Si no, se
// crea una nueva lista.
//
// Las extensiones llamarán a esta función durante su inicialización para instalar acciones
// personalizadas que modifiquen el comportamiento del *core* o de otros componentes.
/// Registra una nueva acción en el sistema.
///
/// Si ya existen acciones con la misma `ActionKey`, la acción se añade a la misma lista. Si no, se
/// crea una nueva lista.
///
/// Las extensiones llamarán a esta función durante su inicialización para instalar acciones
/// personalizadas que modifiquen el comportamiento del *core* o de otros componentes.
pub(crate) fn add_action(action: ActionBox) {
let key = ActionKey::new(
action.type_id(),

View file

@ -10,7 +10,7 @@ pub struct ActionsList(RwLock<Vec<ActionBox>>);
impl ActionsList {
pub fn new() -> Self {
ActionsList::default()
Self::default()
}
pub fn add(&mut self, action: ActionBox) {

View file

@ -1,5 +1,7 @@
//! API para construir nuevos componentes.
use crate::html::RoutePath;
mod definition;
pub use definition::{Component, ComponentRender};
@ -14,8 +16,8 @@ pub use context::{Context, ContextError, ContextOp, Contextual};
/// Alias de función (*callback*) para **determinar si un componente se renderiza o no**.
///
/// Puede usarse para permitir que una instancia concreta de un tipo de componente dado decida
/// dinámicamente durante el proceso de renderizado ([`Component::is_renderable()`]) si se renderiza
/// o no.
/// dinámicamente durante el proceso de renderizado ([`Component::is_renderable()`]), si se
/// renderiza o no.
///
/// # Ejemplo
///
@ -64,8 +66,40 @@ pub use context::{Context, ContextError, ContextOp, Contextual};
/// ```
pub type FnIsRenderable = fn(cx: &Context) -> bool;
/// Alias de función (*callback*) para **resolver una URL** según el contexto de renderizado.
/// Alias de función (*callback*) para **resolver una ruta URL** según el contexto de renderizado.
///
/// Se usa para generar enlaces dinámicos en función del contexto (petición, idioma, etc.). Debe
/// devolver una referencia válida durante el renderizado.
pub type FnPathByContext = fn(cx: &Context) -> &str;
/// Se usa para generar enlaces dinámicos en función del contexto (petición, idioma, parámetros,
/// etc.). Devuelve una [`RoutePath`], que representa un *path* base junto con una lista opcional de
/// parámetros de consulta.
///
/// El caso más común es construir rutas relativas dependientes del contexto, normalmente usando
/// [`Context::route`](crate::core::component::Context::route):
///
/// ```rust
/// # use pagetop::prelude::*;
/// # let relative_route: FnPathByContext =
/// |cx| cx.route("/path/to/page")
/// # ;
/// ```
///
/// También es posible usar rutas estáticas sin asignaciones adicionales:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # let external_route: FnPathByContext =
/// |_| "https://www.example.com".into()
/// # ;
/// ```
///
/// O componer rutas dinámicas en tiempo de ejecución:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # let dynamic_route: FnPathByContext =
/// |cx| RoutePath::new("/user").with_param("id", cx.param::<u64>("user_id").unwrap().to_string())
/// # ;
/// ```
///
/// Los componentes que acepten un [`FnPathByContext`] invocarán esta función durante el renderizado
/// para obtener la URL final que se asignará al atributo HTML correspondiente.
pub type FnPathByContext = fn(cx: &Context) -> RoutePath;

View file

@ -55,7 +55,7 @@ impl Child {
// **< Child HELPERS >**************************************************************************
// Devuelve el [`UniqueId`] del tipo del componente, si existe.
/// Devuelve el [`UniqueId`] del tipo del componente, si existe.
#[inline]
fn type_id(&self) -> Option<UniqueId> {
self.0.as_ref().map(|c| c.read().type_id())
@ -156,7 +156,7 @@ impl<C: Component> Typed<C> {
// **< Typed HELPERS >**************************************************************************
// Método interno para convertir un componente tipado en un [`Child`].
/// Método interno para convertir un componente tipado en un [`Child`].
#[inline]
fn into(self) -> Child {
if let Some(c) = &self.0 {
@ -172,6 +172,7 @@ impl<C: Component> Typed<C> {
/// Operaciones para componentes hijo [`Child`] en una lista [`Children`].
pub enum ChildOp {
Add(Child),
AddIfEmpty(Child),
AddMany(Vec<Child>),
InsertAfterId(&'static str, Child),
InsertBeforeId(&'static str, Child),
@ -185,6 +186,7 @@ pub enum ChildOp {
/// Operaciones con un componente hijo tipado [`Typed<C>`] en una lista [`Children`].
pub enum TypedOp<C: Component> {
Add(Typed<C>),
AddIfEmpty(Typed<C>),
AddMany(Vec<Typed<C>>),
InsertAfterId(&'static str, Typed<C>),
InsertBeforeId(&'static str, Typed<C>),
@ -206,15 +208,15 @@ pub struct Children(Vec<Child>);
impl Children {
/// Crea una lista vacía.
pub fn new() -> Self {
Children::default()
Self::default()
}
/// Crea una lista con un componente hijo inicial.
pub fn with(child: Child) -> Self {
Children::default().with_child(ChildOp::Add(child))
Self::default().with_child(ChildOp::Add(child))
}
// Fusiona varias listas de `Children` en una sola.
/// Fusiona varias listas de `Children` en una sola.
pub(crate) fn merge(mixes: &[Option<&Children>]) -> Self {
let mut opt = Children::default();
for m in mixes.iter().flatten() {
@ -230,6 +232,7 @@ impl Children {
pub fn with_child(mut self, op: ChildOp) -> Self {
match op {
ChildOp::Add(any) => self.add(any),
ChildOp::AddIfEmpty(any) => self.add_if_empty(any),
ChildOp::AddMany(many) => self.add_many(many),
ChildOp::InsertAfterId(id, any) => self.insert_after_id(id, any),
ChildOp::InsertBeforeId(id, any) => self.insert_before_id(id, any),
@ -246,6 +249,7 @@ impl Children {
pub fn with_typed<C: Component>(mut self, op: TypedOp<C>) -> Self {
match op {
TypedOp::Add(typed) => self.add(typed.into()),
TypedOp::AddIfEmpty(typed) => self.add_if_empty(typed.into()),
TypedOp::AddMany(many) => self.add_many(many.into_iter().map(Typed::<C>::into)),
TypedOp::InsertAfterId(id, typed) => self.insert_after_id(id, typed.into()),
TypedOp::InsertBeforeId(id, typed) => self.insert_before_id(id, typed.into()),
@ -266,6 +270,15 @@ impl Children {
self
}
/// Añade un componente hijo en la lista sólo si está vacía.
#[inline]
pub fn add_if_empty(&mut self, child: Child) -> &mut Self {
if self.0.is_empty() {
self.0.push(child);
}
self
}
// **< Children GETTERS >***********************************************************************
/// Devuelve el número de componentes hijo de la lista.
@ -308,7 +321,7 @@ impl Children {
// **< Children HELPERS >***********************************************************************
// Añade más de un componente hijo al final de la lista (en el orden recibido).
/// Añade más de un componente hijo al final de la lista (en el orden recibido).
#[inline]
fn add_many<I>(&mut self, iter: I) -> &mut Self
where
@ -318,7 +331,7 @@ impl Children {
self
}
// Inserta un hijo después del componente con el `id` dado, o al final si no se encuentra.
/// Inserta un hijo después del componente con el `id` dado, o al final si no se encuentra.
#[inline]
fn insert_after_id(&mut self, id: impl AsRef<str>, child: Child) -> &mut Self {
let id = Some(id.as_ref());
@ -329,7 +342,7 @@ impl Children {
self
}
// Inserta un hijo antes del componente con el `id` dado, o al principio si no se encuentra.
/// Inserta un hijo antes del componente con el `id` dado, o al principio si no se encuentra.
#[inline]
fn insert_before_id(&mut self, id: impl AsRef<str>, child: Child) -> &mut Self {
let id = Some(id.as_ref());
@ -340,14 +353,14 @@ impl Children {
self
}
// Inserta un hijo al principio de la colección.
/// Inserta un hijo al principio de la colección.
#[inline]
fn prepend(&mut self, child: Child) -> &mut Self {
self.0.insert(0, child);
self
}
// Inserta más de un componente hijo al principio de la lista (manteniendo el orden recibido).
/// Inserta más de un componente hijo al principio de la lista (manteniendo el orden recibido).
#[inline]
fn prepend_many<I>(&mut self, iter: I) -> &mut Self
where
@ -358,7 +371,7 @@ impl Children {
self
}
// Elimina el primer hijo con el `id` dado.
/// Elimina el primer hijo con el `id` dado.
#[inline]
fn remove_by_id(&mut self, id: impl AsRef<str>) -> &mut Self {
let id = Some(id.as_ref());
@ -368,7 +381,7 @@ impl Children {
self
}
// Sustituye el primer hijo con el `id` dado por otro componente.
/// Sustituye el primer hijo con el `id` dado por otro componente.
#[inline]
fn replace_by_id(&mut self, id: impl AsRef<str>, child: Child) -> &mut Self {
let id = Some(id.as_ref());
@ -381,7 +394,7 @@ impl Children {
self
}
// Elimina todos los componentes hijo de la lista.
/// Elimina todos los componentes hijo de la lista.
#[inline]
fn reset(&mut self) -> &mut Self {
self.0.clear();

View file

@ -1,18 +1,17 @@
use crate::base::component::Template;
use crate::core::component::ChildOp;
use crate::core::theme::all::DEFAULT_THEME;
use crate::core::theme::{ChildrenInRegions, ThemeRef};
use crate::core::theme::{ChildrenInRegions, RegionRef, TemplateRef, ThemeRef};
use crate::core::TypeInfo;
use crate::html::{html, Markup};
use crate::html::{html, Markup, RoutePath};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::locale::{LangId, LangMatch, LanguageIdentifier, DEFAULT_LANGID, FALLBACK_LANGID};
use crate::locale::{LangId, LanguageIdentifier, RequestLocale};
use crate::service::HttpRequest;
use crate::{builder_fn, join};
use crate::{builder_fn, util, CowStr};
use std::any::Any;
use std::collections::HashMap;
/// Operaciones para modificar recursos asociados al contexto ([`Context`]) de un documento.
/// Operaciones para modificar recursos asociados al [`Context`] de un documento.
pub enum ContextOp {
/// Define el *favicon* del documento. Sobrescribe cualquier valor anterior.
SetFavicon(Option<Favicon>),
@ -50,7 +49,7 @@ pub enum ContextError {
///
/// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para:
///
/// - Almacenar la **solicitud HTTP** de origen.
/// - Almacenar la **petición HTTP** de origen.
/// - Seleccionar el **tema** y la **plantilla** de renderizado.
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
/// [`StyleSheet`] o los scripts [`JavaScript`] mediante [`ContextOp`].
@ -66,9 +65,9 @@ pub enum ContextError {
/// # use pagetop::prelude::*;
/// # use pagetop_aliner::Aliner;
/// fn prepare_context<C: Contextual>(cx: C) -> C {
/// cx.with_langid(&LangMatch::resolve("es-ES"))
/// cx.with_langid(&Locale::resolve("es-ES"))
/// .with_theme(&Aliner)
/// .with_template(Template::DEFAULT)
/// .with_template(&DefaultTemplate::Standard)
/// .with_assets(ContextOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
/// .with_assets(ContextOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
/// .with_assets(ContextOp::AddJavaScript(JavaScript::defer("/js/app.js")))
@ -82,7 +81,7 @@ pub trait Contextual: LangId {
#[builder_fn]
fn with_langid(self, language: &impl LangId) -> Self;
/// Almacena la solicitud HTTP de origen en el contexto.
/// Almacena la petición HTTP de origen en el contexto.
#[builder_fn]
fn with_request(self, request: Option<HttpRequest>) -> Self;
@ -92,7 +91,7 @@ pub trait Contextual: LangId {
/// Especifica la plantilla para renderizar el documento.
#[builder_fn]
fn with_template(self, template_name: &'static str) -> Self;
fn with_template(self, template: TemplateRef) -> Self;
/// Añade o modifica un parámetro dinámico del contexto.
#[builder_fn]
@ -102,20 +101,20 @@ pub trait Contextual: LangId {
#[builder_fn]
fn with_assets(self, op: ContextOp) -> Self;
/// Opera con [`ChildOp`] en una región (`region_name`) del documento.
/// Opera con [`ChildOp`] en una región del documento.
#[builder_fn]
fn with_child_in(self, region_name: impl AsRef<str>, op: ChildOp) -> Self;
fn with_child_in(self, region_ref: RegionRef, op: ChildOp) -> Self;
// **< Contextual GETTERS >*********************************************************************
/// Devuelve una referencia a la solicitud HTTP asociada, si existe.
/// Devuelve una referencia a la petición HTTP asociada, si existe.
fn request(&self) -> Option<&HttpRequest>;
/// Devuelve el tema que se usará para renderizar el documento.
fn theme(&self) -> ThemeRef;
/// Devuelve el nombre de la plantilla usada para renderizar el documento.
fn template(&self) -> &str;
/// Devuelve la plantilla configurada para renderizar el documento.
fn template(&self) -> TemplateRef;
/// Recupera un parámetro como [`Option`].
fn param<T: 'static>(&self, key: &'static str) -> Option<&T>;
@ -162,7 +161,7 @@ pub trait Contextual: LangId {
///
/// # Ejemplos
///
/// Crea un nuevo contexto asociado a una solicitud HTTP:
/// Crea un nuevo contexto asociado a una petición HTTP:
///
/// ```rust
/// # use pagetop::prelude::*;
@ -170,7 +169,7 @@ pub trait Contextual: LangId {
/// fn new_context(request: HttpRequest) -> Context {
/// Context::new(Some(request))
/// // Establece el idioma del documento a español.
/// .with_langid(&LangMatch::resolve("es-ES"))
/// .with_langid(&Locale::resolve("es-ES"))
/// // Establece el tema para renderizar.
/// .with_theme(&Aliner)
/// // Asigna un favicon.
@ -205,10 +204,10 @@ pub trait Contextual: LangId {
/// ```
#[rustfmt::skip]
pub struct Context {
request : Option<HttpRequest>, // Solicitud HTTP de origen.
langid : &'static LanguageIdentifier, // Identificador de idioma.
request : Option<HttpRequest>, // Petición HTTP de origen.
locale : RequestLocale, // Idioma asociado a la petición.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : &'static str, // Nombre de la plantilla usada para renderizar.
template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts: Assets<JavaScript>, // Scripts JavaScript.
@ -224,31 +223,18 @@ impl Default for Context {
}
impl Context {
/// Crea un nuevo contexto asociado a una solicitud HTTP.
/// Crea un nuevo contexto asociado a una petición HTTP.
///
/// El contexto inicializa el idioma, el tema y la plantilla por defecto, sin favicon ni otros
/// recursos cargados.
#[rustfmt::skip]
pub fn new(request: Option<HttpRequest>) -> Self {
// Se intenta DEFAULT_LANGID.
let langid = DEFAULT_LANGID
// Si es None evalúa la cadena de extracción desde la cabecera HTTP.
.or_else(|| {
request
// Se usa `as_ref()` sobre `Option<HttpRequest>` para no mover el valor.
.as_ref()
.and_then(|req| req.headers().get("Accept-Language"))
.and_then(|value| value.to_str().ok())
.and_then(|language| LangMatch::resolve(language).as_option())
})
// Si todo falla, se recurre a &FALLBACK_LANGID.
.unwrap_or(&FALLBACK_LANGID);
let locale = RequestLocale::from_request(request.as_ref());
Context {
request,
langid,
locale,
theme : *DEFAULT_THEME,
template : Template::DEFAULT,
template : DEFAULT_THEME.default_template(),
favicon : None,
stylesheets: Assets::<StyleSheet>::new(),
javascripts: Assets::<JavaScript>::new(),
@ -286,10 +272,10 @@ impl Context {
markup
}
/// Renderiza los componentes de la región `region_name`.
pub fn render_region(&mut self, region_name: impl AsRef<str>) -> Markup {
/// Renderiza los componentes de una región.
pub fn render_region(&mut self, region_ref: RegionRef) -> Markup {
self.regions
.children_for(self.theme, region_name)
.children_for(self.theme, region_ref)
.render(self)
}
@ -376,22 +362,40 @@ impl Context {
pub fn remove_param(&mut self, key: &'static str) -> bool {
self.params.remove(key).is_some()
}
// **< Context HELPERS >************************************************************************
/// Construye una ruta aplicada al contexto actual.
///
/// La ruta resultante se envuelve en un [`RoutePath`], que permite añadir parámetros de
/// consulta de forma tipada. Si la política de negociación de idioma actual
/// [`LangNegotiation`](crate::global::LangNegotiation) indica que debe propagarse el idioma
/// para esta petición, se añade o actualiza el parámetro de *query* `lang=...` con el
/// identificador de idioma efectivo del contexto.
///
/// Esto garantiza que los enlaces generados desde el contexto preservan la preferencia de
/// idioma del usuario cuando procede.
pub fn route(&self, path: impl Into<CowStr>) -> RoutePath {
let mut route = RoutePath::new(path);
if self.locale.needs_lang_query() {
route.alter_param("lang", self.locale.langid().to_string());
}
route
}
}
/// Permite a [`Context`](crate::core::component::Context) actuar como proveedor de idioma.
///
/// Devuelve un [`LanguageIdentifier`] siguiendo este orden de prioridad:
/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración
/// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo.
///
/// 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
/// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto
/// permite que el [`Context`] se use como fuente de idioma coherente en
/// [`L10n::lookup()`](crate::locale::L10n::lookup) o [`L10n::using()`](crate::locale::L10n::using).
impl LangId for Context {
#[inline]
fn langid(&self) -> &'static LanguageIdentifier {
self.langid
self.locale.langid()
}
}
@ -401,12 +405,14 @@ impl Contextual for Context {
#[builder_fn]
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.request = request;
// Recalcula el locale según la nueva petición y la política de negociación configurada.
self.locale = RequestLocale::from_request(self.request.as_ref());
self
}
#[builder_fn]
fn with_langid(mut self, language: &impl LangId) -> Self {
self.langid = language.langid();
self.locale.with_langid(language);
self
}
@ -417,8 +423,8 @@ impl Contextual for Context {
}
#[builder_fn]
fn with_template(mut self, template_name: &'static str) -> Self {
self.template = template_name;
fn with_template(mut self, template: TemplateRef) -> Self {
self.template = template;
self
}
@ -474,8 +480,8 @@ impl Contextual for Context {
}
#[builder_fn]
fn with_child_in(mut self, region_name: impl AsRef<str>, op: ChildOp) -> Self {
self.regions.alter_child_in(region_name, op);
fn with_child_in(mut self, region_ref: RegionRef, op: ChildOp) -> Self {
self.regions.alter_child_in(region_ref, op);
self
}
@ -489,7 +495,7 @@ impl Contextual for Context {
self.theme
}
fn template(&self) -> &str {
fn template(&self) -> TemplateRef {
self.template
}
@ -560,7 +566,7 @@ impl Contextual for Context {
prefix
};
self.id_counter += 1;
join!(prefix, "-", self.id_counter.to_string())
util::join!(prefix, "-", self.id_counter.to_string())
}
}
}

View file

@ -29,8 +29,10 @@ pub fn register_extensions(root_extension: Option<ExtensionRef>) {
add_to_enabled(&mut enabled_list, extension);
}
// Añade la página de bienvenida por defecto a la lista de extensiones habilitadas.
add_to_enabled(&mut enabled_list, &crate::base::extension::Welcome);
// Añade la página de bienvenida predefinida si se habilita en la configuración.
if global::SETTINGS.app.welcome {
add_to_enabled(&mut enabled_list, &crate::base::extension::Welcome);
}
// Guarda la lista final de extensiones habilitadas.
ENABLED_EXTENSIONS.write().append(&mut enabled_list);

View file

@ -4,28 +4,27 @@ use crate::core::AnyInfo;
use crate::locale::L10n;
use crate::{actions_boxed, service};
/// Representa una referencia a una extensión.
///
/// Las extensiones se definen como instancias estáticas globales para poder acceder a ellas desde
/// 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.
///
/// 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.
/// Este *trait* es fácil de implementar, basta con declarar una estructura sin campos para la
/// extensión y sobrescribir los métodos que sean necesarios. Por ejemplo:
///
/// ```rust
/// # use pagetop::prelude::*;
/// pub struct Blog;
///
/// impl Extension for Blog {
/// fn name(&self) -> L10n { L10n::n("Blog") }
/// fn description(&self) -> L10n { L10n::n("Blog system") }
/// fn name(&self) -> L10n {
/// L10n::n("Blog")
/// }
///
/// fn description(&self) -> L10n {
/// L10n::n("Blog system")
/// }
/// }
/// ```
pub trait Extension: AnyInfo + Send + Sync {
/// Nombre localizado de la extensión legible para el usuario.
/// Nombre de la extensión como *texto localizado* legible para el usuario.
///
/// Predeterminado por el [`short_name()`](AnyInfo::short_name) del tipo asociado a la
/// extensión.
@ -33,15 +32,20 @@ pub trait Extension: AnyInfo + Send + Sync {
L10n::n(self.short_name())
}
/// Descripción corta localizada de la extensión para paneles, listados, etc.
/// Descripción corta de la extensión como *texto localizado* para paneles, listados, etc.
///
/// Por defecto devuelve un valor vacío (`L10n::default()`).
fn description(&self) -> L10n {
L10n::default()
}
/// Devuelve una referencia a esta misma extensión cuando se trata de un tema.
/// Devuelve una referencia a esta misma extensión cuando actúa como un tema.
///
/// 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.
/// Para ello, la implementación concreta debe ser una extensión que también implemente
/// [`Theme`](crate::core::theme::Theme). Por defecto, asume que la extensión no es un tema y
/// devuelve `None`.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
@ -61,17 +65,17 @@ 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
/// aplicación.
/// PageTop resolverá automáticamente estas dependencias respetando el orden durante el arranque
/// de la aplicación.
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![]
}
/// Devuelve la lista de acciones que la extensión va a registrar.
/// Devuelve la lista de acciones que la extensión registra.
///
/// Estas [acciones](crate::core::action) se despachan por orden de registro o por
/// [peso](crate::Weight), permitiendo personalizar el comportamiento de la aplicación en puntos
/// específicos.
/// [peso](crate::Weight) (ver [`actions_boxed!`](crate::actions_boxed)), permitiendo
/// personalizar el comportamiento de la aplicación en puntos específicos.
fn actions(&self) -> Vec<ActionBox> {
actions_boxed![]
}
@ -85,6 +89,8 @@ pub trait Extension: AnyInfo + Send + Sync {
/// Configura los servicios web de la extensión, como rutas, *middleware*, acceso a ficheros
/// estáticos, etc., usando [`ServiceConfig`](crate::service::web::ServiceConfig).
///
/// # Ejemplo
///
/// ```rust,ignore
/// # use pagetop::prelude::*;
/// pub struct ExtensionSample;
@ -98,11 +104,15 @@ pub trait Extension: AnyInfo + Send + Sync {
#[allow(unused_variables)]
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {}
/// Permite crear extensiones para deshabilitar y desinstalar recursos de otras de versiones
/// anteriores de la aplicación.
/// Permite declarar extensiones destinadas a deshabilitar o desinstalar recursos de otras
/// extensiones asociadas a versiones anteriores de la aplicación.
///
/// Actualmente no se usa, pero se deja como *placeholder* para futuras implementaciones.
/// Actualmente PageTop no utiliza este método, pero se reserva como *placeholder* para futuras
/// implementaciones.
fn drop_extensions(&self) -> Vec<ExtensionRef> {
vec![]
}
}
/// Representa una referencia a una extensión.
pub type ExtensionRef = &'static dyn Extension;

View file

@ -1,18 +1,206 @@
//! API para añadir y gestionar nuevos temas.
//!
//! En PageTop un tema es la *piel* de la aplicación. Es responsable último de los estilos,
//! tipografías, espaciados y cualquier otro detalle visual o interactivo (animaciones, scripts de
//! interfaz, etc.).
//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension) y
//! también [`Theme`], de modo que [`Extension::theme()`](crate::core::extension::Extension::theme)
//! permita identificar y registrar los temas disponibles.
//!
//! Un tema determina el aspecto final de un documento HTML sin alterar la lógica interna de los
//! componentes ni la estructura del documento, que queda definida por la plantilla
//! ([`Template`](crate::base::component::Template)) utilizada por cada página.
//! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos
//! interactivos. Para ello utiliza plantillas ([`Template`]) que describen cómo maquetar el cuerpo
//! del documento a partir de varias regiones ([`Region`]). Cada región es un contenedor lógico
//! identificado por un nombre, cuyo contenido se obtiene del [`Context`] de la página.
//!
//! Los temas son extensiones que implementan [`Extension`](crate::core::extension::Extension), por
//! lo que se instancian, declaran dependencias y se inician igual que cualquier otra extensión.
//! También deben implementar [`Theme`] y sobrescribir el método
//! [`Extension::theme()`](crate::core::extension::Extension::theme) para que PageTop pueda
//! registrarlos como temas.
//! Una página ([`Page`](crate::response::page::Page)) representa un documento HTML completo.
//! Implementa [`Contextual`](crate::core::component::Contextual) para gestionar su propio
//! [`Context`], donde mantiene el tema activo, la plantilla seleccionada y los componentes
//! asociados a cada región.
//!
//! De este modo, temas y extensiones colaboran sobre una estructura común: las aplicaciones
//! registran componentes en el [`Context`], las plantillas organizan las regiones y las páginas
//! generan el documento HTML resultante.
//!
//! Los temas pueden definir sus propias implementaciones de [`Template`] y [`Region`] (por ejemplo,
//! mediante *enums* adicionales) para añadir nuevas plantillas o exponer regiones específicas.
use crate::core::component::Context;
use crate::html::{html, Markup};
use crate::locale::L10n;
use crate::{util, AutoDefault};
// **< Region >*************************************************************************************
/// Interfaz común para las regiones lógicas de un documento.
///
/// Una `Region` representa un contenedor lógico identificado por un nombre de región. Su contenido
/// se obtiene del [`Context`], donde los componentes suelen registrarse usando implementaciones de
/// métodos como [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in).
///
/// El contenido de una región viene determinado únicamente por su nombre, no por su tipo. Distintas
/// implementaciones de [`Region`] que devuelvan el mismo nombre compartirán el mismo conjunto de
/// componentes registrados en el [`Context`], aunque cada región puede renderizar ese contenido de
/// forma diferente. Por ejemplo, [`DefaultRegion::Header`] y `BootsierRegion::Header` mostrarían
/// los mismos componentes si ambas devuelven el nombre `"header"`, pero podrían maquetarse de
/// manera distinta.
///
/// El tema decide qué regiones mostrar en el cuerpo del documento, normalmente usando una plantilla
/// ([`Template`]) al renderizar la página ([`Page`](crate::response::page::Page)).
pub trait Region {
/// Devuelve el nombre de la región.
///
/// Este nombre es el identificador lógico de la región y se usa como clave en el [`Context`]
/// para recuperar y renderizar el contenido registrado bajo ese nombre. Cualquier
/// implementación de [`Region`] que devuelva el mismo nombre compartirá el mismo conjunto de
/// componentes.
///
/// En la implementación predeterminada de [`Self::render()`] también se utiliza para construir
/// las clases del contenedor de la región (`"region region-<name>"`).
fn name(&self) -> &'static str;
/// Devuelve un *texto localizado* como etiqueta de accesibilidad asociada a la región.
///
/// En la implementación predeterminada de [`Self::render()`], este valor se usa como
/// `aria-label` del contenedor de la región.
fn label(&self) -> L10n;
/// Renderiza el contenedor de la región.
///
/// Por defecto, recupera del [`Context`] el contenido de la región y, si no está vacío, lo
/// envuelve en un `<div>` con clases `"region region-<name>"` y un `aria-label` basado en el
/// *texto localizado* de la etiqueta asociada a la región:
///
/// ```html
/// <div class="region region-<name>" role="region" aria-label="<label>">
/// <!-- Componentes de la región "name" -->
/// </div>
/// ```
///
/// Se puede sobrescribir este método para modificar la estructura del contenedor, las clases
/// utilizadas o la semántica del marcado generado para cada región.
fn render(&'static self, cx: &mut Context) -> Markup
where
Self: Sized,
{
html! {
@let region = cx.render_region(self);
@if !region.is_empty() {
div
class=(util::join!("region region-", self.name()))
role="region"
aria-label=[self.label().lookup(cx)]
{
(region)
}
}
}
}
}
/// Referencia estática a una región.
pub type RegionRef = &'static dyn Region;
// **< DefaultRegion >******************************************************************************
/// Regiones básicas que PageTop proporciona por defecto.
///
/// Estas regiones comparten sus nombres (`"header"`, `"content"`, `"footer"`) con cualquier región
/// equivalente definida por otros temas, por lo que comparten también el contenido registrado bajo
/// esos nombres.
#[derive(AutoDefault)]
pub enum DefaultRegion {
/// Región estándar para la **cabecera** del documento, de nombre `"header"`.
///
/// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc.
Header,
/// Región principal de **contenido**, de nombre `"content"`.
///
/// Es la región donde se renderiza el contenido principal del documento. En general será la
/// región mínima imprescindible para que una página tenga sentido.
#[default]
Content,
/// Región estándar para el **pie de página**, de nombre `"footer"`.
///
/// Suele contener información legal, enlaces secundarios, créditos, etc.
Footer,
}
impl Region for DefaultRegion {
#[inline]
fn name(&self) -> &'static str {
match self {
Self::Header => "header",
Self::Content => "content",
Self::Footer => "footer",
}
}
#[inline]
fn label(&self) -> L10n {
match self {
Self::Header => L10n::l("region-header"),
Self::Content => L10n::l("region-content"),
Self::Footer => L10n::l("region-footer"),
}
}
}
// **< Template >***********************************************************************************
/// Interfaz común para definir plantillas de contenido.
///
/// Una `Template` puede proporcionar una o más variantes para decidir la composición del `<body>`
/// de una página ([`Page`](crate::response::page::Page)). El tema utiliza esta información para
/// determinar qué regiones ([`Region`]) deben renderizarse y en qué orden.
pub trait Template {
/// Renderiza el contenido de la plantilla.
///
/// Por defecto, renderiza las regiones básicas de [`DefaultRegion`] en este orden:
/// [`DefaultRegion::Header`], [`DefaultRegion::Content`] y [`DefaultRegion::Footer`].
///
/// Se puede sobrescribir este método para:
///
/// - Cambiar el conjunto de regiones que se renderizan según variantes de la plantilla.
/// - Alterar el orden de dichas regiones.
/// - Envolver las regiones en contenedores adicionales.
/// - Implementar distribuciones específicas (por ejemplo, con barras laterales).
///
/// Este método se invoca normalmente desde [`Theme::render_page_body()`] para generar el
/// contenido del `<body>` de una página según la plantilla devuelta por el contexto de la
/// propia página ([`Contextual::template()`](crate::core::component::Contextual::template())).
fn render(&'static self, cx: &mut Context) -> Markup {
html! {
(DefaultRegion::Header.render(cx))
(DefaultRegion::Content.render(cx))
(DefaultRegion::Footer.render(cx))
}
}
}
/// Referencia estática a una plantilla.
pub type TemplateRef = &'static dyn Template;
// **< DefaultTemplate >****************************************************************************
/// Plantillas que PageTop proporciona por defecto.
#[derive(AutoDefault)]
pub enum DefaultTemplate {
/// Plantilla predeterminada.
///
/// Utiliza la implementación por defecto de [`Template::render()`] y se emplea cuando no se
/// selecciona ninguna otra plantilla explícitamente.
#[default]
Standard,
/// Plantilla de error.
///
/// Se utiliza para páginas de error u otros estados excepcionales. Por defecto utiliza la misma
/// implementación de [`Template::render()`] que [`Self::Standard`].
Error,
}
impl Template for DefaultTemplate {}
// **< Definitions >********************************************************************************
mod definition;
pub use definition::{Theme, ThemeRef};

View file

@ -19,7 +19,7 @@ pub static DEFAULT_THEME: LazyLock<ThemeRef> =
// **< TEMA POR NOMBRE >****************************************************************************
// Devuelve el tema identificado por su [`short_name()`](AnyInfo::short_name).
/// Devuelve el tema identificado por su [`short_name()`](AnyInfo::short_name).
pub fn theme_by_short_name(short_name: &'static str) -> Option<ThemeRef> {
let short_name = short_name.to_lowercase();
match THEMES

View file

@ -1,30 +1,28 @@
use crate::base::component::Template;
use crate::core::component::{ComponentRender, ContextOp, Contextual};
use crate::base::component::{Html, Intro, IntroOpening};
use crate::core::component::{Child, ChildOp, Component, Contextual};
use crate::core::extension::Extension;
use crate::core::theme::{DefaultRegion, DefaultTemplate, TemplateRef};
use crate::global;
use crate::html::{html, Markup, StyleSheet};
use crate::html::{html, Markup};
use crate::locale::L10n;
use crate::response::page::Page;
/// Referencia estática a un tema.
///
/// Los temas son también extensiones. Por tanto, deben declararse como **instancias estáticas** que
/// implementen [`Theme`] y, a su vez, [`Extension`]. Estas instancias se exponen usando
/// [`Extension::theme()`](crate::core::extension::Extension::theme).
pub type ThemeRef = &'static dyn Theme;
use crate::service::http::StatusCode;
/// Interfaz común que debe implementar cualquier tema de PageTop.
///
/// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de
/// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` mediante plantillas
/// ([`Template`]) y qué contenido mostrar en las páginas de error.
/// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` usando plantillas
/// ([`Template`](crate::core::theme::Template)) que maquetan regiones
/// ([`Region`](crate::core::theme::Region)) y qué contenido mostrar en las páginas de error. El
/// contenido de cada región depende del [`Context`](crate::core::component::Context) y de su nombre
/// lógico.
///
/// Todos los métodos de este *trait* tienen una implementación por defecto, por lo que pueden
/// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los
/// predeterminados.
///
/// El único método **obligatorio** de `Extension` para un tema es [`theme()`](Extension::theme),
/// que debe devolver una referencia estática al propio tema:
/// que debe devolver una referencia al propio tema:
///
/// ```rust
/// # use pagetop::prelude::*;
@ -47,32 +45,55 @@ pub type ThemeRef = &'static dyn Theme;
/// impl Theme for MyTheme {}
/// ```
pub trait Theme: Extension + Send + Sync {
/// Devuelve la plantilla ([`Template`](crate::core::theme::Template)) que el propio tema
/// propone como predeterminada.
///
/// Se utiliza al inicializar un [`Context`](crate::core::component::Context) o una página
/// ([`Page`](crate::response::page::Page)) por si no se elige ninguna otra plantilla con
/// [`Contextual::with_template()`](crate::core::component::Contextual::with_template).
///
/// La implementación por defecto devuelve la plantilla estándar ([`DefaultTemplate::Standard`])
/// con una estructura básica para la página. Los temas pueden sobrescribir este método para
/// seleccionar otra plantilla predeterminada o una plantilla propia.
#[inline]
fn default_template(&self) -> TemplateRef {
&DefaultTemplate::Standard
}
/// Acciones específicas del tema antes de renderizar el `<body>` de la página.
///
/// Se invoca antes de que se procese la plantilla ([`Template`]) asociada a la página
/// ([`Page::template()`](crate::response::page::Page::template)). Es un buen lugar para
/// inicializar o ajustar recursos en función del contexto de la página, por ejemplo:
/// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página,
/// por ejemplo:
///
/// - Añadir metadatos o propiedades a la página.
/// - Añadir metadatos o propiedades a la cabecera de la página.
/// - Preparar atributos compartidos.
/// - Registrar *assets* condicionales en el contexto.
///
/// La implementación por defecto no realiza ninguna acción.
#[allow(unused_variables)]
fn before_render_page_body(&self, page: &mut Page) {}
/// Renderiza el contenido del `<body>` de la página.
///
/// Por defecto, delega en la plantilla ([`Template`]) asociada a la página
/// ([`Page::template()`](crate::response::page::Page::template)). La plantilla se encarga de
/// procesar las regiones y renderizar los componentes registrados en el contexto.
/// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde
/// su [`Context`](crate::core::component::Context), y llama a
/// [`Template::render()`](crate::core::theme::Template::render) para componer el `<body>` a
/// partir de las regiones.
///
/// Con la configuración por defecto, la plantilla estándar utiliza las regiones
/// [`DefaultRegion::Header`](crate::core::theme::DefaultRegion::Header),
/// [`DefaultRegion::Content`](crate::core::theme::DefaultRegion::Content) y
/// [`DefaultRegion::Footer`](crate::core::theme::DefaultRegion::Footer) en ese orden.
///
/// Los temas pueden sobrescribir este método para:
///
/// - Forzar una plantilla concreta en determinadas páginas.
/// - Envolver el contenido en marcadores adicionales.
/// - Consultar la plantilla de la página y variar la composición según su nombre.
/// - Envolver el contenido en contenedores adicionales.
/// - Implementar lógicas de composición alternativas.
#[inline]
fn render_page_body(&self, page: &mut Page) -> Markup {
Template::named(page.template()).render(page.context())
page.template().render(page.context())
}
/// Acciones específicas del tema después de renderizar el `<body>` de la página.
@ -84,31 +105,9 @@ pub trait Theme: Extension + Send + Sync {
/// - Aplicar ajustes finales al estado de la página antes de producir el `<head>` o la
/// respuesta final.
///
/// La implementación por defecto añade una serie de hojas de estilo básicas (`normalize.css`,
/// `root.css`, `basic.css`) cuando el parámetro `include_basic_assets` de la página está
/// activado.
/// La implementación por defecto no realiza ninguna acción.
#[allow(unused_variables)]
fn after_render_page_body(&self, page: &mut Page) {
if page.param_or("include_basic_assets", false) {
let pkg_version = env!("CARGO_PKG_VERSION");
page.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/root.css")
.with_version(pkg_version)
.with_weight(-99),
))
.alter_assets(ContextOp::AddStyleSheet(
StyleSheet::from("/css/basic.css")
.with_version(pkg_version)
.with_weight(-99),
));
}
}
fn after_render_page_body(&self, page: &mut Page) {}
/// Renderiza el contenido del `<head>` de la página.
///
@ -125,11 +124,10 @@ pub trait Theme: Extension + Send + Sync {
/// - La etiqueta `viewport` básica para diseño adaptable.
/// - Los metadatos (`name`/`content`) y propiedades (`property`/`content`) declarados en la
/// página.
/// - Todos los *assets* registrados en el contexto de la página.
/// - Los *assets* registrados en el contexto de la página.
///
/// Los temas pueden sobrescribir este método para añadir etiquetas adicionales (por ejemplo,
/// *favicons* personalizados, manifest, etiquetas de analítica, etc.).
#[inline]
fn render_page_head(&self, page: &mut Page) -> Markup {
let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no";
html! {
@ -159,19 +157,78 @@ pub trait Theme: Extension + Send + Sync {
}
}
/// Contenido predeterminado para la página de error "*403 - Forbidden*".
/// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado).
///
/// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la
/// página de error, manteniendo o no el mensaje de los textos localizados.
fn error403(&self, page: &mut Page) -> Markup {
html! { div { h1 { (L10n::l("error403_notice").using(page)) } } }
/// página de error.
fn error_403(&self, page: &mut Page) {
page.alter_title(L10n::l("error403_title"))
.alter_template(&DefaultTemplate::Error)
.alter_child_in(
&DefaultRegion::Content,
ChildOp::Prepend(Child::with(Html::with(move |cx| {
html! {
div {
h1 { (L10n::l("error403_alert").using(cx)) }
p { (L10n::l("error403_help").using(cx)) }
}
}
}))),
);
}
/// Contenido predeterminado para la página de error "*404 - Not Found*".
/// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado).
///
/// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la
/// página de error, manteniendo o no el mensaje de los textos localizados.
fn error404(&self, page: &mut Page) -> Markup {
html! { div { h1 { (L10n::l("error404_notice").using(page)) } } }
/// página de error.
fn error_404(&self, page: &mut Page) {
page.alter_title(L10n::l("error404_title"))
.alter_template(&DefaultTemplate::Error)
.alter_child_in(
&DefaultRegion::Content,
ChildOp::Prepend(Child::with(Html::with(move |cx| {
html! {
div {
h1 { (L10n::l("error404_alert").using(cx)) }
p { (L10n::l("error404_help").using(cx)) }
}
}
}))),
);
}
/// Permite al tema preparar y componer una página de error fatal.
///
/// Por defecto, asigna el título al documento (`title`) y muestra un componente [`Intro`] con
/// el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y `help`) como
/// descripción del error.
///
/// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni
/// [`Self::error_404()`], que definen su propio contenido específico.
///
/// Los temas pueden sobrescribir este método para personalizar el diseño y el contenido de la
/// página de error.
fn error_fatal(&self, page: &mut Page, code: StatusCode, title: L10n, alert: L10n, help: L10n) {
page.alter_title(title)
.alter_template(&DefaultTemplate::Error)
.alter_child_in(
&DefaultRegion::Content,
ChildOp::Prepend(Child::with(
Intro::new()
.with_title(L10n::l("error_code").with_arg("code", code.to_string()))
.with_slogan(L10n::n(code.to_string()))
.with_button(None)
.with_opening(IntroOpening::Custom)
.add_child(Html::with(move |cx| {
html! {
h1 { (alert.using(cx)) }
p { (help.using(cx)) }
}
})),
)),
);
}
}
/// Referencia estática a un tema.
pub type ThemeRef = &'static dyn Theme;

View file

@ -1,6 +1,5 @@
use crate::base::component::Region;
use crate::core::component::{Child, ChildOp, Children};
use crate::core::theme::ThemeRef;
use crate::core::theme::{DefaultRegion, RegionRef, ThemeRef};
use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock;
@ -21,24 +20,23 @@ static COMMON_REGIONS: LazyLock<RwLock<ChildrenInRegions>> =
pub(crate) struct ChildrenInRegions(HashMap<String, Children>);
impl ChildrenInRegions {
pub fn with(region_name: impl AsRef<str>, child: Child) -> Self {
Self::default().with_child_in(region_name, ChildOp::Add(child))
pub fn with(region_ref: RegionRef, child: Child) -> Self {
Self::default().with_child_in(region_ref, ChildOp::Add(child))
}
#[builder_fn]
pub fn with_child_in(mut self, region_name: impl AsRef<str>, op: ChildOp) -> Self {
let name = region_name.as_ref();
if let Some(region) = self.0.get_mut(name) {
pub fn with_child_in(mut self, region_ref: RegionRef, op: ChildOp) -> Self {
if let Some(region) = self.0.get_mut(region_ref.name()) {
region.alter_child(op);
} else {
self.0
.insert(name.to_owned(), Children::new().with_child(op));
.insert(region_ref.name().to_owned(), Children::new().with_child(op));
}
self
}
pub fn children_for(&self, theme_ref: ThemeRef, region_name: impl AsRef<str>) -> Children {
let name = region_name.as_ref();
pub fn children_for(&self, theme_ref: ThemeRef, region_ref: RegionRef) -> Children {
let name = region_ref.name();
let common = COMMON_REGIONS.read();
let themed = THEME_REGIONS.read();
@ -50,20 +48,36 @@ impl ChildrenInRegions {
}
}
/// Permite añadir componentes a regiones globales o específicas de un tema.
/// Añade componentes a regiones globales o específicas de un tema.
///
/// Según la variante, se pueden añadir componentes ([`add()`](Self::add)) que permanecerán
/// disponibles durante toda la ejecución.
///
/// Estos componentes se renderizarán automáticamente al procesar los documentos HTML que incluyen
/// estas regiones, como las páginas de contenido ([`Page`](crate::response::page::Page)).
/// Cada variante indica la región en la que se añade el componente usando [`Self::add()`]. Los
/// componentes añadidos se mantienen durante toda la ejecución y se inyectan automáticamente al
/// renderizar los documentos HTML que utilizan esas regiones, como las páginas de contenido
/// ([`Page`](crate::response::page::Page)).
pub enum InRegion {
/// Región de contenido por defecto.
Default,
/// Región identificada por el nombre proporcionado.
Named(&'static str),
/// Región identificada por su nombre para un tema concreto.
OfTheme(&'static str, ThemeRef),
/// Región principal de **contenido** por defecto.
///
/// Añade el componente a la región lógica de contenido principal de la aplicación. Por
/// convención, esta región corresponde a [`DefaultRegion::Content`], cuyo nombre es
/// `"content"`. Cualquier tema que renderice esa misma región de contenido, ya sea usando
/// directamente [`DefaultRegion::Content`] o cualquier otra implementación de
/// [`Region`](crate::core::theme::Region) que devuelva ese mismo nombre, mostrará los
/// componentes registrados aquí, aunque lo harán según su propio método de renderizado
/// ([`Region::render()`](crate::core::theme::Region::render)).
Content,
/// Región global compartida por todos los temas.
///
/// Los componentes añadidos aquí se asocian al nombre de la región indicado por [`RegionRef`],
/// es decir, al valor devuelto por [`Region::name()`](crate::core::theme::Region::name) para
/// esa región. Se mostrarán en cualquier tema cuya plantilla renderice una región que devuelva
/// ese mismo nombre.
Global(RegionRef),
/// Región asociada a un tema concreto.
///
/// Los componentes sólo se renderizarán cuando el documento se procese con el tema indicado y
/// se utilice la región referenciada. Resulta útil para añadir contenido específico en un tema
/// sin afectar a otros.
ForTheme(ThemeRef, RegionRef),
}
impl InRegion {
@ -73,28 +87,33 @@ impl InRegion {
///
/// ```rust
/// # use pagetop::prelude::*;
/// // Banner global, en la región por defecto de cualquier página.
/// InRegion::Default.add(Child::with(Html::with(|_|
/// html! { ("🎉 ¡Bienvenido!") }
/// )));
/// // Banner global en la región por defecto.
/// InRegion::Content.add(Child::with(Html::with(|_| {
/// html! { "🎉 ¡Bienvenido!" }
/// })));
///
/// // Texto en la región "sidebar".
/// InRegion::Named("sidebar").add(Child::with(Html::with(|_|
/// html! { ("Publicidad") }
/// )));
/// // Texto en la cabecera.
/// InRegion::Global(&DefaultRegion::Header).add(Child::with(Html::with(|_| {
/// html! { "Publicidad" }
/// })));
///
/// // Contenido sólo para la región del pie de página en un tema concreto.
/// InRegion::ForTheme(&theme::Basic, &DefaultRegion::Footer).add(Child::with(Html::with(|_| {
/// html! { "Aviso legal" }
/// })));
/// ```
pub fn add(&self, child: Child) -> &Self {
match self {
InRegion::Default => Self::add_to_common(Region::DEFAULT, child),
InRegion::Named(region_name) => Self::add_to_common(region_name, child),
InRegion::OfTheme(region_name, theme_ref) => {
InRegion::Content => Self::add_to_common(&DefaultRegion::Content, child),
InRegion::Global(region_ref) => Self::add_to_common(*region_ref, child),
InRegion::ForTheme(theme_ref, region_ref) => {
let mut regions = THEME_REGIONS.write();
if let Some(r) = regions.get_mut(&theme_ref.type_id()) {
r.alter_child_in(region_name, ChildOp::Add(child));
r.alter_child_in(*region_ref, ChildOp::Add(child));
} else {
regions.insert(
theme_ref.type_id(),
ChildrenInRegions::with(region_name, child),
ChildrenInRegions::with(*region_ref, child),
);
}
}
@ -103,9 +122,9 @@ impl InRegion {
}
#[inline]
fn add_to_common(region_name: &str, child: Child) {
fn add_to_common(region_ref: RegionRef, child: Child) {
COMMON_REGIONS
.write()
.alter_child_in(region_name, ChildOp::Add(child));
.alter_child_in(region_ref, ChildOp::Add(child));
}
}

View file

@ -4,13 +4,28 @@ use crate::include_config;
use serde::Deserialize;
mod lang_negotiation;
pub use lang_negotiation::LangNegotiation;
mod startup_banner;
pub use startup_banner::StartupBanner;
mod log_rolling;
pub use log_rolling::LogRolling;
mod log_format;
pub use log_format::LogFormat;
// **< SETTINGS >***********************************************************************************
include_config!(SETTINGS: Settings => [
// [app]
"app.name" => "PageTop App",
"app.description" => "Developed with the amazing PageTop framework.",
"app.theme" => "Basic",
"app.language" => "",
"app.lang_negotiation" => "Full",
"app.startup_banner" => "Slant",
"app.welcome" => true,
// [dev]
"dev.pagetop_static_dir" => "",
@ -29,6 +44,8 @@ include_config!(SETTINGS: Settings => [
"server.session_lifetime" => 604_800,
]);
// **< Settings >***********************************************************************************
#[derive(Debug, Deserialize)]
/// Tipos para las secciones globales [`[app]`](App), [`[dev]`](Dev), [`[log]`](Log) y
/// [`[server]`](Server) de [`SETTINGS`].
@ -48,24 +65,38 @@ pub struct App {
pub description: String,
/// Tema predeterminado.
pub theme: String,
/// Idioma por defecto para la aplicación.
/// Idioma predeterminado de la aplicación (p. ej., *"es-ES"* o *"en-US"*).
///
/// Si no está definido o no es válido, [`LangId`](crate::locale::LangId) determinará el idioma
/// efectivo para el renderizado en este orden: primero intentará usar el establecido mediante
/// [`Contextual::with_langid()`](crate::core::component::Contextual::with_langid); si no se ha
/// definido explícitamente, probará el indicado en la cabecera `Accept-Language` del navegador;
/// y, si ninguno aplica, se empleará el idioma de respaldo ("en-US").
pub language: String,
/// Cuando tiene un valor validado por [`Locale`](crate::locale::Locale), se usa como candidato
/// para resolver el idioma efectivo de cada petición según la estrategia definida en
/// [`lang_negotiation`](Self::lang_negotiation) y aplicada por
/// [`RequestLocale`](crate::locale::RequestLocale).
///
/// Si es `None` o no contiene un valor válido, la negociación del idioma pasa a depender de
/// otras fuentes como la cabecera `Accept-Language` de la petición o, en último término, del
/// idioma de respaldo configurado en el sistema.
pub language: Option<String>,
/// Estrategia para resolver el idioma usado en la petición: *"Full"*, *"NoQuery"* o
/// *"ConfigOnly"*.
///
/// Define las fuentes que intervienen en la negociación del idioma para el renderizado de los
/// documentos y la generación de URLs. Ver [`LangNegotiation`] para los modos disponibles.
pub lang_negotiation: LangNegotiation,
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
/// *"Starwars"*.
pub startup_banner: String,
pub startup_banner: StartupBanner,
/// Activa la página de bienvenida de PageTop.
///
/// Si está activada, se instala la extensión [`Welcome`](crate::base::extension::Welcome), que
/// ofrece una página de bienvenida predefinida en `"/"`.
pub welcome: bool,
/// Modo de ejecución, dado por la variable de entorno `PAGETOP_RUN_MODE`, o *"default"* si no
/// está definido.
pub run_mode: String,
}
#[derive(Debug, Deserialize)]
/// Sección `[Dev]` de la configuración. Forma parte de [`Settings`].
/// Sección `[dev]` de la configuración. Forma parte de [`Settings`].
pub struct Dev {
/// Directorio desde el que servir los archivos estáticos de PageTop.
///
@ -81,21 +112,21 @@ pub struct Dev {
#[derive(Debug, Deserialize)]
/// Sección `[log]` de la configuración. Forma parte de [`Settings`].
pub struct Log {
/// Gestión de trazas y registro de eventos activado (`true`) o desactivado (`false`).
/// Gestión de trazas y registro de eventos activada (*true*) o desactivada (*false*).
pub enabled: bool,
/// Opciones, o combinación de opciones separadas por comas, para filtrar las trazas: *"Error"*,
/// *"Warn"*, *"Info"*, *"Debug"* o *"Trace"*.
/// Ejemplo: "Error,actix_server::builder=Info,tracing_actix_web=Debug".
/// Ejemplo: *"Error,actix_server::builder=Info,tracing_actix_web=Debug"*.
pub tracing: String,
/// Muestra los mensajes de traza en el terminal (*"Stdout"*) o las registra en archivos con
/// Muestra los mensajes de traza en el terminal (*"Stdout"*) o los vuelca en archivos con
/// rotación: *"Daily"*, *"Hourly"*, *"Minutely"* o *"Endless"*.
pub rolling: String,
/// Directorio para los archivos de traza (si `rolling` ≠ *"Stdout"*).
pub rolling: LogRolling,
/// Directorio para los archivos de traza (si [`rolling`](Self::rolling) ≠ *"Stdout"*).
pub path: String,
/// Prefijo para los archivos de traza (si `rolling` ≠ *"Stdout"*).
/// Prefijo para los archivos de traza (si [`rolling`](Self::rolling) ≠ *"Stdout"*).
pub prefix: String,
/// Formato de salida de las trazas. Opciones: *"Full"*, *"Compact"*, *"Pretty"* o *"Json"*.
pub format: String,
pub format: LogFormat,
}
#[derive(Debug, Deserialize)]

View file

@ -0,0 +1,56 @@
use crate::AutoDefault;
use serde::{Deserialize, Deserializer};
/// Modos disponibles para negociar el idioma de una petición HTTP.
///
/// El ajuste [`global::SETTINGS.app.lang_negotiation`](crate::global::App::lang_negotiation)
/// determina qué fuentes intervienen en la resolución del idioma efectivo utilizado por
/// [`RequestLocale`](crate::locale::RequestLocale) y en la generación de URLs mediante
/// [`Context::route()`](crate::core::component::Context::route).
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
pub enum LangNegotiation {
/// Usa todas las fuentes disponibles para determinar el idioma, en este orden: comprueba el
/// parámetro `?lang` de la URL; si no está presente o no es válido, usa la cabecera HTTP
/// `Accept-Language`; si tampoco está disponible o no es válido, usa el idioma configurado en
/// [`global::SETTINGS.app.language`](crate::global::App::language) o, en su defecto, el idioma
/// de respaldo. Es el comportamiento por defecto.
#[default]
Full,
/// Igual que `LangNegotiation::Full`, pero sin tener en cuenta el parámetro `?lang` de la URL.
/// El idioma depende únicamente de la cabecera `Accept-Language` del navegador y, en última
/// instancia, de la configuración o idioma de respaldo.
NoQuery,
/// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora la cabecera
/// `Accept-Language` y el parámetro de la URL. Este modo proporciona un comportamiento estable
/// con idioma fijo.
ConfigOnly,
}
impl<'de> Deserialize<'de> for LangNegotiation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
let result = match raw.trim().to_ascii_lowercase().as_str() {
"full" => Self::Full,
"noquery" => Self::NoQuery,
"configonly" => Self::ConfigOnly,
_ => {
let default = Self::default();
println!(
concat!(
"\nInvalid value \"{}\" for [app].lang_negotiation. ",
"Using \"{:?}\". Check settings.",
),
raw, default,
);
default
}
};
Ok(result)
}
}

Some files were not shown because too many files have changed in this diff Show more