Compare commits

...

29 commits

Author SHA1 Message Date
0121fad94a ♻️ (html): Simplifica API de Classes y ClassesOp
Elimina `ClassesOp::Replace` (sustituible con `Remove`+`Add`), renombra
`Set` a `Reset` por claridad semántica, añade `Classes::is_empty()` y
mejora documentación de `ClassesOp` con nota sobre orden CSS.
2026-06-11 07:18:04 +02:00
35a5221c92 (macros): Permite (expr) como atributo en html!
Introduce `Attribute::Splice` en el AST de Maud, de modo que `(expr)` en
posición de atributo renderiza la expresión directamente sobre el buffer
de salida del elemento.
2026-06-11 06:46:16 +02:00
47b6553fe4 🐛 (build): Aísla dir temp. por nombre de destino 2026-06-11 06:21:54 +02:00
0410b8c060 📝 Corrección mínima de documentación 2026-06-09 19:24:51 +02:00
830602b24e 🎨 (seaorm): Mejora API y documentación
- Reescribe la documentación con ejemplos completos, guía rápida y
  tablas de referencia.
- Renombra `connection()` a `dbconn()`.
- Añade `execute()` para SQL en crudo y corrige `fetch_all`/`fetch_one`
  para aceptar `&Q` en lugar de `&mut Q`.
- Cambia `futures::executor::block_on` por `tokio::task::block_in_place`
  para compatibilidad con el *runtime* multi-hilo.
- Los fallos de migración al arrancar provocan `panic!` en lugar de log
  de error silencioso.
- Actualiza `#[pagetop::test]` para usar `flavor = "multi_thread"`,
  alineándolo con `#[pagetop::main]` y con las extensiones que usan
  SeaORM.
2026-06-09 19:22:34 +02:00
dfc1bdbc4c (seaorm): Incluye DbType y retoca docs de config 2026-06-02 00:24:45 +02:00
3951f1da1a 🎨 Corrige orden de atributos externos en structs
`///` debe preceder a `#[derive(...)]` al ser azúcar de `#[doc = "..."]`.
2026-06-01 23:32:28 +02:00
4ccb792db5 📝 Corrige documentación de la extensión Welcome 2026-06-01 22:05:53 +02:00
2c52af4b9d ♻️ (statics): Renombra StaticResource a StaticFile
Clarifica la distinción entre un fichero estático individual
(`StaticFile`) y el contenedor de varios ficheros (`StaticResources`).
2026-06-01 22:02:23 +02:00
b1ce79c78f ♻️ Migra soporte HTTP de actix-web a axum en maud 2026-06-01 02:14:07 +02:00
eb18690a5c (tests): Adapta la suite al nuevo framework web
- Sustituye `service::test::*` por `web::test::*` (migración de actix-web a
  axum).
- Extrae `setup()` en los módulos que sólo renderizan componentes,
  evitando levantar un router completo en cada test.
- Elimina los `env::set_var("PAGETOP_RUN_MODE", "test")` manuales, ya
  cubiertos por la *feature* `testing`.
2026-06-01 02:04:02 +02:00
87e4eac27c 🔥 (statics): Elimina código residual de actix-web
`ResourceFiles` y `UriSegmentError` quedaron sin uso al migrar de
actix-web a axum/tower.
2026-06-01 01:01:24 +02:00
7d43742a11 ♻️ (macros): Adapta main y test a Tokio
`#[pagetop::main]` y `#[pagetop::test]` expanden ahora a
`#[tokio::main]` y `#[tokio::test]`, eliminando la dependencia de
Actix-web.
2026-05-31 23:43:10 +02:00
c1afe0e70c ♻️ Migra API pública de actix-web a Axum
- `configure_service` como `configure_router(Router) -> Router`.
- Macro `static_files_service!` como `serve_static_files!`.
- `ResultPage<M, E>` eliminado; handlers devuelven `Result<M, E>`.
- `ErrorPage` implementa `IntoResponse` en lugar de `ResponseError`.
- Registro con `OnceLock`; eliminados `drop_extensions` y `app.welcome`.
- `Redirect` devuelve `Response`; docs y ejemplos actualizados.
2026-05-31 23:38:43 +02:00
019961ed77 🚚 Actualiza rutas de assets estáticos
Los CSS e imágenes propios de PageTop se sirven bajo `/pagetop/`, por lo
que las referencias a `/css/` e `/img/` deben incluir ese prefijo.
2026-05-31 00:51:48 +02:00
7553ed35ec 🎨 Aplica formato Rust 2024 (rustfmt.toml) 2026-05-30 22:50:40 +02:00
9c58d5e1d6 ♻️ (pagetop): Migra de actix-web a Axum
Sustituye el módulo `service` por `web` y adapta toda la API al modelo
de Axum: router inmutable, extractores via `FromRequestParts` y
servicios Tower para archivos estáticos.

- `HttpRequest` pasa a ser un tipo propio, mínimo y clonable.
- `configure_services` pasa a `configure_routes`.
- `EmbeddedFilesService` pasa a `ServeEmbedded`.
- Elimina `session_lifetime` de `Server` (va a `pagetop-auth`).
- Actualiza tests y ejemplos a la nueva API.
2026-05-30 22:30:58 +02:00
026448e511 ♻️ (seaorm): Separa módulo migration de db
- `db::*` queda como API de consultas (connection, fetch_*).
- `migration::*` sube a primer nivel con su propia documentación.
- `DBCONN` y `run_now` se trasladan a la raíz de la extensión.
- Actualiza README.md y docs para reflejar la nueva estructura.
2026-05-15 00:22:55 +02:00
796ae5ce81 ♻️ (seaorm): Revisa y mejora la API pública 2026-05-11 15:10:49 +02:00
aa931ea052 ⬆️ (seaorm): Actualiza sea-orm a 1.1 2026-05-10 21:42:19 +02:00
8c861bff05 📝 (seaorm): Corrige ejemplos de documentación 2026-05-10 00:43:35 +02:00
fa5489dbb0 ♻️ (seaorm): Elimina prelude para usar db 2026-05-10 00:38:00 +02:00
a0805ed0fb ♻️ (bootsier): Elimina prelude para usar theme 2026-05-10 00:31:33 +02:00
bd8a34341d 🚧 Retoques en documentación y código 2026-05-09 13:33:20 +02:00
23d4fd8a80 (seaorm): Añade acceso a bases de datos 2026-05-09 13:07:49 +02:00
b4284f74f8 🌐 (bootsier): Localiza nombre y descripción 2026-05-09 10:43:04 +02:00
50abfe3b56 🌐 (aliner): Localiza nombre y descripción 2026-05-09 10:42:48 +02:00
35883bdcde Añade alias cargo td y aclara doc de pruebas 2026-05-09 09:35:59 +02:00
9e625c2b46 📝 Retoques en README's y documentación 2026-05-09 08:18:28 +02:00
146 changed files with 5720 additions and 2426 deletions

View file

@ -1,3 +1,4 @@
[alias]
ts = ["test", "--features", "testing"] # cargo ts
tw = ["test", "--workspace", "--features", "testing"] # cargo tw
td = ["test", "--doc", "-p"] # cargo td <crate>

View file

@ -26,13 +26,6 @@ para mostrar un banner de presentación en el terminal con el nombre de la aplic
* [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck*
# 🎨 CSS
La extensión `pagetop-bootsier` es un tema que integra [Bootstrap 5.3.8](https://getbootstrap.com/)
para los estilos y componentes de la interfaz. Bootstrap está distribuido bajo licencia
[MIT](https://github.com/twbs/bootstrap/blob/main/LICENSE).
# 👾 Icono
"La Mascota" sonriente es una simpática creación de [Webalys](https://www.iconfinder.com/webalys).

2012
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,63 +1,3 @@
[package]
name = "pagetop"
version = "0.5.0"
edition = "2021"
description = """
Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables.
"""
categories = ["web-programming::http-server"]
keywords = ["pagetop", "web", "framework", "frontend", "ssr"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
chrono = "0.4"
colored = "3.1"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "1.0"
getter-methods = "2.0"
itoa = "1.0"
indexmap = "2.14"
parking_lot = "0.12"
substring = "1.4"
terminal_size = "0.4"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
tracing-actix-web = "0.7"
fluent-templates = "0.14"
unic-langid = { version = "0.9", features = ["macros"] }
actix-web = { workspace = true, default-features = true }
actix-session = { version = "0.11", features = ["cookie-session"] }
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]
default = []
testing = []
[dev-dependencies]
tempfile = "3.27"
serde_json = "1.0"
pagetop-aliner.workspace = true
pagetop-bootsier.workspace = true
[build-dependencies]
pagetop-build.workspace = true
[workspace]
resolver = "2"
members = [
@ -69,17 +9,51 @@ members = [
# Extensions
"extensions/pagetop-aliner",
"extensions/pagetop-bootsier",
"extensions/pagetop-seaorm",
]
[workspace.package]
repository = "https://git.cillero.es/manuelcillero/pagetop"
homepage = "https://pagetop.cillero.es"
edition = "2024"
license = "MIT OR Apache-2.0"
authors = ["Manuel Cillero <manuel@cillero.es>"]
[workspace.dependencies]
actix-web = { version = "4.13", default-features = false }
async-trait = "0.1"
axum = { version = "0.8" }
change-detection = "1.2"
chrono = "0.4"
colored = "3.1"
concat-string = "1.0"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "1.0"
fluent-templates = "0.14"
getter-methods = "2.0"
grass = "0.13"
indexmap = "2.14"
indoc = "2.0"
itoa = "1.0"
mime_guess = "2.0"
parking_lot = "0.12"
pastey = "0.2"
path-slash = "0.2"
proc-macro2 = "1.0"
proc-macro2-diagnostics = { version = "0.10", default-features = false }
quote = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
syn = { version = "2.0", features = ["full", "extra-traits"] }
tempfile = "3.27"
terminal_size = "0.4"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
unic-langid = { version = "0.9", features = ["macros"] }
url = "2.5"
# Helpers
pagetop-build = { version = "0.3", path = "helpers/pagetop-build" }
pagetop-macros = { version = "0.3", path = "helpers/pagetop-macros" }
@ -88,5 +62,68 @@ pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" }
# Extensions
pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
# PageTop
pagetop = { version = "0.5", path = "." }
[workspace.dependencies.sea-orm]
version = "1.1"
features = ["debug-print", "macros", "runtime-tokio-native-tls"]
default-features = false
[workspace.dependencies.sea-schema]
version = "0.16"
[package]
name = "pagetop"
version = "0.5.0"
description = """
Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables.
"""
categories = ["web-programming::http-server"]
keywords = ["pagetop", "web", "framework", "frontend", "ssr"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[features]
default = []
testing = []
[dependencies]
axum.workspace = true
chrono.workspace = true
colored.workspace = true
config.workspace = true
figlet-rs.workspace = true
fluent-templates.workspace = true
getter-methods.workspace = true
indexmap.workspace = true
itoa.workspace = true
parking_lot.workspace = true
pagetop-macros.workspace = true
pagetop-minimal.workspace = true
pagetop-statics.workspace = true
serde.workspace = true
terminal_size.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
unic-langid.workspace = true
[dev-dependencies]
pagetop-aliner.workspace = true
pagetop-bootsier.workspace = true
serde_json.workspace = true
tempfile.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -11,9 +11,10 @@
[![Descargas](https://img.shields.io/crates/d/pagetop.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop#licencia)
<br>
</div>
<br>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
@ -58,7 +59,7 @@ impl Extension for HelloWorld {
}
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn hello_world(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.add_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.render()
@ -109,22 +110,29 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
tema basado en [Bootstrap](https://getbootstrap.com) para integrar su catálogo de estilos y
componentes flexibles.
* **[pagetop-seaorm](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-seaorm)**,
integra [SeaORM](https://www.sea-ql.org/SeaORM) para acceder a bases de datos relacionales.
## 🧪 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:
| Comando | Descripción |
| ------- | ----------- |
| `cargo ts` | Ejecuta los tests de `pagetop` (*unit + integration*) con la *feature* `testing`. |
| `cargo ts --test util` | Lanza sólo las pruebas de integración del módulo `util`. |
| `cargo ts --doc locale` | Lanza las pruebas de la documentación del módulo `locale`. |
| `cargo tw` | Ejecuta los tests de **todos los paquetes** del *workspace*. |
| Comando | Descripción |
| ----------------------- | --------------------------------------------------------------- |
| `cargo ts` | Lanza **todos los tests** de `pagetop` |
| `cargo ts --test util` | Lanza los tests de integración del archivo `tests/util.rs` |
| `cargo ts --doc locale` | Lanza los *doctests* de `pagetop` cuyo *path* contiene `locale` |
| `cargo tw` | Lanza **todos los tests** del *workspace* |
| `cargo td <crate>` | Lanza los *doctests* de un *crate* concreto del *workspace* |
> **Nota**
> Estos alias ya compilan con la configuración adecuada. No requieren `--no-default-features`.
> Si quieres **activar** las trazas del registro de eventos entonces usa simplemente `cargo test`.
> * Todos los alias, excepto `cargo td`, aplican la *feature* `testing` para los *crates* que la
> declaren.
> * Cuando lanza **todos los tests** se incluyen las pruebas unitarias, de integración y *doctests*.
> * Los alias suprimen las trazas del registro de eventos. Para activarlas usa directamente
> `cargo test`.
## 🚧 Advertencia

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use pagetop_bootsier::prelude::*;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
@ -11,12 +11,12 @@ impl Extension for FormControls {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(form_controls));
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(form_controls))
}
}
async fn form_controls(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()

View file

@ -3,16 +3,15 @@ use pagetop::prelude::*;
struct HelloName;
impl Extension for HelloName {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/hello/{name}", service::web::get().to(hello_name));
fn configure_router(&self, router: Router) -> Router {
router.route("/hello/{name}", web::get(hello_name))
}
}
async fn hello_name(
request: HttpRequest,
path: service::web::Path<String>,
) -> ResultPage<Markup, ErrorPage> {
let name = path.into_inner();
web::Path(name): web::Path<String>,
) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(move |_| {
html! {

View file

@ -3,12 +3,12 @@ use pagetop::prelude::*;
struct HelloWorld;
impl Extension for HelloWorld {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(hello_world));
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(hello_world))
}
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn hello_world(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(|_| {
html! {

View file

@ -5,12 +5,12 @@ include_locales!(LOC from "examples/locale");
struct IntroColors;
impl Extension for IntroColors {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(intro_colors));
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_colors))
}
}
async fn intro_colors(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn intro_colors(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use pagetop_bootsier::prelude::*;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
@ -8,7 +8,11 @@ struct SuperMenu;
impl Extension for SuperMenu {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
vec![
&pagetop_aliner::Aliner,
&pagetop_bootsier::Bootsier,
&pagetop::base::extension::Welcome,
]
}
fn initialize(&self) {

View file

@ -1,7 +1,6 @@
[package]
name = "pagetop-aliner"
version = "0.1.0"
edition = "2021"
description = """
Tema de PageTop que muestra esquemáticamente la composición de las páginas HTML
@ -11,11 +10,15 @@ keywords = ["pagetop", "theme", "css"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
pagetop.workspace = true
[dev-dependencies]
tokio.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -9,7 +9,6 @@
[![Descargas](https://img.shields.io/crates/d/pagetop-aliner.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-aliner)
[![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/extensions/pagetop-aliner#licencia)
<br>
</div>
## 🧭 Sobre PageTop
@ -65,7 +64,7 @@ o **fuerza el tema por código** en una página concreta:
use pagetop::prelude::*;
use pagetop_aliner::Aliner;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Aliner)
.add_child(

View file

@ -66,7 +66,7 @@ o **fuerza el tema por código** en una página concreta:
use pagetop::prelude::*;
use pagetop_aliner::Aliner;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Aliner)
.with_child(
@ -83,9 +83,12 @@ async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
use pagetop::prelude::*;
/// Implementa el tema para usar en pruebas que muestran el esquema de páginas HTML.
include_locales!(LOCALES_ALINER);
/// Implementa el tema.
///
/// Define un tema mínimo útil para:
/// Define un tema mínimo que muestra esquemáticamente la composición de las páginas HTML; útil
/// para:
///
/// - Comprobar el funcionamiento de temas, plantillas y regiones.
/// - Verificar integración de componentes y composiciones (*layouts*) sin estilos complejos.
@ -94,24 +97,33 @@ use pagetop::prelude::*;
pub struct Aliner;
impl Extension for Aliner {
fn name(&self) -> L10n {
L10n::t("extension_name", &LOCALES_ALINER)
}
fn description(&self) -> L10n {
L10n::t("extension_description", &LOCALES_ALINER)
}
fn theme(&self) -> Option<ThemeRef> {
Some(&Self)
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
static_files_service!(scfg, [aliner] => "/aliner");
fn configure_router(&self, router: Router) -> Router {
serve_static_files!(router, [aliner] => "/aliner");
router
}
}
impl Theme for Aliner {
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
StyleSheet::from("/pagetop/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/basic.css")
StyleSheet::from("/pagetop/css/basic.css")
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))

View file

@ -0,0 +1,2 @@
extension_name = Aliner
extension_description = Minimal theme that schematically shows the HTML page composition.

View file

@ -0,0 +1,2 @@
extension_name = Aliner
extension_description = Tema mínimo que muestra esquemáticamente la composición de las páginas HTML.

View file

@ -1,7 +1,6 @@
[package]
name = "pagetop-bootsier"
version = "0.1.1"
edition = "2021"
description = """
Tema de PageTop basado en Bootstrap para aplicar su catálogo de estilos y componentes flexibles.
@ -11,6 +10,7 @@ keywords = ["pagetop", "theme", "bootstrap", "css", "js"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
@ -18,5 +18,8 @@ authors.workspace = true
pagetop.workspace = true
serde.workspace = true
[dev-dependencies]
tokio.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -9,7 +9,6 @@
[![Descargas](https://img.shields.io/crates/d/pagetop-bootsier.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-bootsier)
[![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/extensions/pagetop-bootsier#licencia)
<br>
</div>
## 🧭 Sobre PageTop
@ -65,7 +64,7 @@ o **fuerza el tema por código** en una página concreta:
use pagetop::prelude::*;
use pagetop_bootsier::Bootsier;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Bootsier)
.add_child(
@ -80,6 +79,13 @@ async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
```
## 📚 Créditos
Este *crate* integra la biblioteca de estilos [Bootstrap 5.3.8](https://getbootstrap.com/) para
definir el comportamiento, la apariencia y los componentes de la interfaz. Bootstrap se distribuye
bajo licencia [MIT](https://github.com/twbs/bootstrap/blob/main/LICENSE).
## 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su

View file

@ -28,13 +28,14 @@ include_config!(SETTINGS: Settings => [
"bootsier.max_width" => "1440px",
]);
/// Ajustes para la sección [`Bootsier`] de [`SETTINGS`].
#[derive(Debug, Deserialize)]
/// Tipos para la sección [`[bootsier]`](Bootsier) de [`SETTINGS`].
pub struct Settings {
pub bootsier: Bootsier,
}
/// Sección **`[bootsier]`** de la configuración. Forma parte de [`Settings`].
#[derive(Debug, Deserialize)]
/// Sección `[bootsier]` de la configuración. Forma parte de [`Settings`].
pub struct Bootsier {
/// Ancho máximo predeterminado para la página, por ejemplo "100%" o "90rem".
pub max_width: UnitValue,

View file

@ -66,7 +66,7 @@ o **fuerza el tema por código** en una página concreta:
use pagetop::prelude::*;
use pagetop_bootsier::Bootsier;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Bootsier)
.with_child(
@ -96,12 +96,6 @@ pub mod config;
pub mod theme;
/// *Prelude* del tema.
pub mod prelude {
pub use crate::config::*;
pub use crate::theme::*;
}
/// Plantillas que Bootsier añade.
#[derive(AutoDefault)]
pub enum BootsierTemplate {
@ -134,13 +128,22 @@ impl Template for BootsierTemplate {
pub struct Bootsier;
impl Extension for Bootsier {
fn name(&self) -> L10n {
L10n::t("extension_name", &LOCALES_BOOTSIER)
}
fn description(&self) -> L10n {
L10n::t("extension_description", &LOCALES_BOOTSIER)
}
fn theme(&self) -> Option<ThemeRef> {
Some(&Self)
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
static_files_service!(scfg, [bootsier_bs] => "/bootsier/bs");
static_files_service!(scfg, [bootsier_js] => "/bootsier/js");
fn configure_router(&self, router: Router) -> Router {
serve_static_files!(router, [bootsier_bs] => "/bootsier/bs");
serve_static_files!(router, [bootsier_js] => "/bootsier/js");
router
}
}

View file

@ -0,0 +1,2 @@
extension_name = Bootsier
extension_description = Bootstrap-based theme with flexible styles and components.

View file

@ -0,0 +1,2 @@
extension_name = Bootsier
extension_description = Tema basado en Bootstrap para aplicar su catálogo de estilos y componentes flexibles.

View file

@ -58,7 +58,7 @@ impl BorderColor {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(BorderColor::Theme(Color::Primary).to_class(), "border-primary");
/// assert_eq!(BorderColor::Subtle(Color::Warning).to_class(), "border-warning-subtle");
/// assert_eq!(BorderColor::Black.to_class(), "border-black");

View file

@ -70,7 +70,7 @@ impl BreakPoint {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let bp = BreakPoint::MD;
/// assert_eq!(bp.class_with("col", ""), "col-md");
/// assert_eq!(bp.class_with("col", "6"), "col-md-6");

View file

@ -76,7 +76,7 @@ impl ButtonColor {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(
/// ButtonColor::Background(Color::Primary).to_class(),
/// "btn-primary"
@ -132,7 +132,7 @@ impl ButtonSize {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(ButtonSize::Small.to_class(), "btn-sm");
/// assert_eq!(ButtonSize::Large.to_class(), "btn-lg");
/// assert_eq!(ButtonSize::Default.to_class(), "");

View file

@ -44,7 +44,7 @@ impl Color {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(Color::Primary.to_class(), "primary");
/// assert_eq!(Color::Danger.to_class(), "danger");
/// ```
@ -124,7 +124,7 @@ impl Opacity {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// 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");
@ -156,7 +156,7 @@ impl Opacity {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(Opacity::Opaque.to_class(), "opacity-100");
/// assert_eq!(Opacity::Half.to_class(), "opacity-50");
/// assert_eq!(Opacity::Default.to_class(), "");
@ -237,7 +237,7 @@ impl ColorBg {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(ColorBg::Body.to_class(), "bg-body");
/// assert_eq!(ColorBg::Theme(Color::Primary).to_class(), "bg-primary");
/// assert_eq!(ColorBg::Subtle(Color::Warning).to_class(), "bg-warning-subtle");
@ -321,7 +321,7 @@ impl ColorText {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(ColorText::Body.to_class(), "text-body");
/// assert_eq!(ColorText::Theme(Color::Primary).to_class(), "text-primary");
/// assert_eq!(ColorText::Emphasis(Color::Danger).to_class(), "text-danger-emphasis");

View file

@ -61,7 +61,7 @@ impl ScaleSize {
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// 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");

View file

@ -71,7 +71,7 @@ impl RoundedRadius {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// 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");
@ -103,7 +103,7 @@ impl RoundedRadius {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(RoundedRadius::Default.to_class(), "rounded");
/// assert_eq!(RoundedRadius::Zero.to_class(), "rounded-0");
/// assert_eq!(RoundedRadius::Scale3.to_class(), "rounded-3");

View file

@ -19,8 +19,9 @@ use crate::theme::{ButtonAction, ButtonColor, ButtonSize};
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let save = Button::submit(L10n::n("Save"))
/// .with_color(ButtonColor::Background(Color::Primary));
///

View file

@ -26,45 +26,33 @@ use crate::theme::attrs::{BorderColor, Opacity, ScaleSize, Side};
///
/// # Ejemplos
///
/// **Borde global:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// // Borde global.
/// let b = classes::Border::with(ScaleSize::Two);
/// assert_eq!(b.to_class(), "border-2");
/// ```
///
/// **Aditivo (solo borde superior):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Aditivo (sólo borde superior):
/// let b = classes::Border::default().with_side(Side::Top, ScaleSize::One);
/// assert_eq!(b.to_class(), "border-top-1");
/// ```
///
/// **Sustractivo (borde global menos el superior):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Sustractivo (borde global menos el superior):
/// let b = classes::Border::new().with_side(Side::Top, ScaleSize::Zero);
/// assert_eq!(b.to_class(), "border border-top-0");
/// ```
///
/// **Ancho por lado (lado lógico inicial a 2 y final a 4):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Ancho por lado (lado lógico inicial a 2 y final a 4):
/// let b = classes::Border::default()
/// .with_side(Side::Start, ScaleSize::Two)
/// .with_side(Side::End, ScaleSize::Four);
/// assert_eq!(b.to_class(), "border-end-4 border-start-2");
/// ```
///
/// **Combinado (ejemplo completo):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Combinado (ejemplo completo):
/// let b = classes::Border::new() // Borde por defecto.
/// .with_side(Side::Top, ScaleSize::Zero) // Quita borde superior.
/// .with_side(Side::End, ScaleSize::Three) // Ancho 3 para el lado lógico final.
/// .with_color(BorderColor::Theme(Color::Primary))
/// .with_opacity(Opacity::Half);
///
/// assert_eq!(b.to_class(), "border border-top-0 border-end-3 border-primary border-opacity-50");
/// ```
#[rustfmt::skip]
@ -158,7 +146,7 @@ impl Border {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// // Convertir explícitamente con `From::from`:
/// let b = classes::Border::from(ScaleSize::Two);
/// assert_eq!(b.to_class(), "border-2");

View file

@ -9,7 +9,8 @@ use crate::theme::attrs::{ColorBg, ColorText, Opacity};
/// # Ejemplos
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// // Sin clases.
/// let s = classes::Background::new();
/// assert_eq!(s.to_class(), "");
@ -90,7 +91,7 @@ impl From<(ColorBg, Opacity)> for Background {
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let s: classes::Background = (ColorBg::White, Opacity::SemiTransparent).into();
/// assert_eq!(s.to_class(), "bg-white bg-opacity-25");
/// ```
@ -105,7 +106,7 @@ impl From<ColorBg> for Background {
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let s: classes::Background = ColorBg::Black.into();
/// assert_eq!(s.to_class(), "bg-black");
/// ```
@ -121,7 +122,8 @@ impl From<ColorBg> for Background {
/// # Ejemplos
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// // Sin clases.
/// let s = classes::Text::new();
/// assert_eq!(s.to_class(), "");
@ -202,7 +204,7 @@ impl From<(ColorText, Opacity)> for Text {
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let s: classes::Text = (ColorText::Theme(Color::Danger), Opacity::Opaque).into();
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
/// ```
@ -218,7 +220,7 @@ impl From<ColorText> for Text {
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let s: classes::Text = ColorText::Black.into();
/// assert_eq!(s.to_class(), "text-black");
/// ```

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
use crate::theme::attrs::{ScaleSize, Side};
use crate::theme::BreakPoint;
use crate::theme::attrs::{ScaleSize, Side};
// **< Margin >*************************************************************************************
@ -10,7 +10,8 @@ use crate::theme::BreakPoint;
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let m = classes::Margin::with(Side::Top, ScaleSize::Three);
/// assert_eq!(m.to_class(), "mt-3");
///
@ -97,7 +98,8 @@ impl Margin {
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let p = classes::Padding::with(Side::LeftAndRight, ScaleSize::Two);
/// assert_eq!(p.to_class(), "px-2");
///

View file

@ -14,42 +14,30 @@ use crate::theme::attrs::RoundedRadius;
///
/// # Ejemplos
///
/// **Radio global:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// // Radio global:
/// let r = classes::Rounded::with(RoundedRadius::Default);
/// assert_eq!(r.to_class(), "rounded");
/// ```
///
/// **Sin redondeo:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Sin redondeo:
/// let r = classes::Rounded::new();
/// assert_eq!(r.to_class(), "");
/// ```
///
/// **Radio en las esquinas de un lado lógico:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Radio en las esquinas de un lado lógico:
/// let r = classes::Rounded::new().with_end(RoundedRadius::Scale2);
/// assert_eq!(r.to_class(), "rounded-end-2");
/// ```
///
/// **Radio en una esquina concreta:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Radio en una esquina concreta:
/// let r = classes::Rounded::new().with_top_start(RoundedRadius::Scale3);
/// assert_eq!(r.to_class(), "rounded-top-start-3");
/// ```
///
/// **Combinado (ejemplo completo):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Combinado (ejemplo completo):
/// let r = classes::Rounded::new()
/// .with_top(RoundedRadius::Default) // Añade redondeo arriba.
/// .with_bottom_start(RoundedRadius::Scale4) // Añade una esquina redondeada concreta.
/// .with_bottom_end(RoundedRadius::Circle); // Añade redondeo extremo en otra esquina.
///
/// assert_eq!(r.to_class(), "rounded-top rounded-bottom-start-4 rounded-bottom-end-circle");
/// ```
#[rustfmt::skip]

View file

@ -6,16 +6,6 @@
//! Con [`container::Width`](crate::theme::container::Width) se puede definir el ancho y el
//! comportamiento *responsive* del contenedor. También permite aplicar utilidades de estilo para el
//! fondo, texto, borde o esquinas redondeadas.
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let main = Container::main()
//! .with_id("main-page")
//! .with_width(container::Width::From(BreakPoint::LG));
//! ```
mod props;
pub use props::{Kind, Width};

View file

@ -1,11 +1,23 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
/// Componente para crear un **contenedor de componentes**.
/// Componente para crear un **contenedor de componentes** ([`container`]).
///
/// Envuelve un contenido con la etiqueta HTML indicada por [`container::Kind`]. Sólo se renderiza
/// si existen componentes hijos (*children*).
/// Envuelve un conjunto de componentes en un contenedor establecido que se crea aplicando uno de
/// los tipos definidos en [`container::Kind`].
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop_bootsier::theme::*;
///
/// let main = Container::main()
/// .with_id("main-page")
/// .with_width(container::Width::From(BreakPoint::LG));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container {
#[getters(skip)]

View file

@ -1,4 +1,4 @@
//! Definiciones para crear menús desplegables [`Dropdown`].
//! Definiciones para crear menús desplegables ([`Dropdown`]).
//!
//! Cada [`dropdown::Item`](crate::theme::dropdown::Item) representa un elemento individual del
//! desplegable [`Dropdown`], con distintos comportamientos según su finalidad, como enlaces de
@ -6,23 +6,6 @@
//!
//! Los ítems pueden estar activos, deshabilitados o abrirse en nueva ventana según su contexto y
//! configuración, y permiten incluir etiquetas localizables usando [`L10n`](pagetop::locale::L10n).
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let dd = Dropdown::new()
//! .with_title(L10n::n("Menu"))
//! .with_button_color(ButtonColor::Background(Color::Secondary))
//! .with_auto_close(dropdown::AutoClose::ClickableInside)
//! .with_direction(dropdown::Direction::Dropend)
//! .with_item(dropdown::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
//! .with_item(dropdown::Item::divider())
//! .with_item(dropdown::Item::header(L10n::n("User session")))
//! .with_item(dropdown::Item::button(L10n::n("Sign out")));
//! ```
mod props;
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};

View file

@ -1,14 +1,14 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
/// Componente para crear un **menú desplegable**.
/// Componente para crear un **menú desplegable** ([`dropdown`]).
///
/// Renderiza un botón (único o desdoblado, ver [`with_button_split()`](Self::with_button_split))
/// para mostrar un menú desplegable de elementos [`dropdown::Item`], que se muestra/oculta según la
/// interacción del usuario. Admite variaciones de tamaño/color del botón, también dirección de
/// apertura, alineación o política de cierre.
/// para mostrar un menú desplegable de elementos [`dropdown::Item`], que se muestra u oculta según
/// la interacción del usuario. Admite variaciones para el tamaño y el color del botón, también para
/// la dirección de apertura, alineación o política de cierre.
///
/// Si no tiene título (ver [`with_title()`](Self::with_title)) se muestra únicamente la lista de
/// elementos sin ningún botón para interactuar.
@ -17,8 +17,25 @@ use crate::LOCALES_BOOTSIER;
/// cuenta **el título** (si no existe le asigna uno por defecto) y **la lista de elementos**; el
/// resto de propiedades no afectarán a su representación en [`Nav`].
///
/// Ver ejemplo en el módulo [`dropdown`].
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let dd = Dropdown::new()
/// .with_title(L10n::n("Menu"))
/// .with_button_color(ButtonColor::Background(Color::Secondary))
/// .with_auto_close(dropdown::AutoClose::ClickableInside)
/// .with_direction(dropdown::Direction::Dropend)
/// .with_item(dropdown::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
/// .with_item(dropdown::Item::divider())
/// .with_item(dropdown::Item::header(L10n::n("User session")))
/// .with_item(dropdown::Item::button(L10n::n("Sign out")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
#[getters(skip)]

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
// **< AutoClose >**********************************************************************************

View file

@ -1,36 +1,4 @@
//! Definiciones para crear formularios ([`Form`]).
//!
//! # Ejemplo
//!
//! ```rust
//! use pagetop::prelude::*;
//! use pagetop_bootsier::prelude::*;
//!
//! let form_login = Form::new()
//! .with_id("login")
//! .with_action("/login")
//! .with_child(
//! form::input::Field::email()
//! .with_name("email")
//! .with_label(L10n::n("Email"))
//! .with_required(true),
//! )
//! .with_child(
//! form::input::Field::password()
//! .with_name("password")
//! .with_label(L10n::n("Password"))
//! .with_required(true),
//! )
//! .with_child(
//! form::Checkbox::check()
//! .with_name("remember")
//! .with_label(L10n::n("Remember me")),
//! )
//! .with_child(
//! Button::submit(L10n::n("Sign in"))
//! .with_color(ButtonColor::Background(Color::Primary)),
//! );
//! ```
mod props;
pub use props::{Autocomplete, AutofillField, CheckboxKind, Method};

View file

@ -17,7 +17,7 @@ use pagetop::prelude::*;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let item = form::check::Item::new("apple", L10n::n("Apple")).with_checked(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
@ -82,7 +82,7 @@ impl Item {
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let interests = form::check::Field::new()
/// .with_name("interests")
/// .with_label(L10n::n("Areas of interest"))

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
/// Componente para crear una **casilla de verificación** o un **interruptor** (*toggle switch*).
///
@ -17,7 +17,7 @@ use crate::LOCALES_BOOTSIER;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let accept_terms = form::Checkbox::check() // También sirve new() o default().
/// .with_name("terms_accepted")
/// .with_label(L10n::n("I accept the terms and conditions"))

View file

@ -2,9 +2,9 @@ use pagetop::prelude::*;
use crate::theme::form;
/// Componente para crear un **formulario**.
/// Componente para crear un **formulario** ([`form`]).
///
/// Este componente renderiza un `<form>` estándar con soporte para los atributos más habituales:
/// Este componente renderiza un formulario estándar con soporte para los atributos más habituales:
///
/// - `id`: identificador opcional del formulario.
/// - `classes`: clases CSS adicionales (p. ej. utilidades CSS).
@ -17,13 +17,33 @@ use crate::theme::form;
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let search = Form::new()
/// .with_id("search")
/// .with_action("/search")
/// .with_method(form::Method::Get)
/// .with_child(form::input::Field::search().with_name("q"));
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let form_login = Form::new()
/// .with_id("login")
/// .with_action("/login")
/// .with_child(
/// form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email"))
/// .with_required(true),
/// )
/// .with_child(
/// form::input::Field::password()
/// .with_name("password")
/// .with_label(L10n::n("Password"))
/// .with_required(true),
/// )
/// .with_child(
/// form::Checkbox::check()
/// .with_name("remember")
/// .with_label(L10n::n("Remember me")),
/// )
/// .with_child(
/// Button::submit(L10n::n("Sign in"))
/// .with_color(ButtonColor::Background(Color::Primary)),
/// );
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Form {

View file

@ -15,7 +15,7 @@ use pagetop::prelude::*;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let personal_data = form::Fieldset::new()
/// .with_legend(L10n::n("Personal data"))
/// .with_description(L10n::n("Enter your full name and contact email."))

View file

@ -12,7 +12,7 @@ use pagetop::prelude::*;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let token = form::Hidden::new()
/// .with_name("csrf_token")
/// .with_value("a1b2c3d4e5");

View file

@ -2,8 +2,8 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
use std::fmt;
@ -106,7 +106,7 @@ impl fmt::Display for Mode {
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let email = form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email address"))

View file

@ -52,7 +52,7 @@ pub enum CheckboxKind {
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// // Correo electrónico con sugerencia semántica del navegador.
/// let ac = form::Autocomplete::email();
///
@ -244,7 +244,7 @@ impl fmt::Display for Autocomplete {
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let ac = form::Autocomplete::token(form::AutofillField::Username);
/// let ac = form::Autocomplete::shipping(form::AutofillField::StreetAddress);
/// let ac = form::Autocomplete::section("job", form::AutofillField::Email);

View file

@ -16,7 +16,7 @@ use crate::LOCALES_BOOTSIER;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let item = form::radio::Item::new("monthly", L10n::n("Monthly")).with_checked(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
@ -76,7 +76,7 @@ impl Item {
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let plan = form::radio::Field::new()
/// .with_name("plan")
/// .with_label(L10n::n("Subscription plan"))

View file

@ -10,7 +10,7 @@ use pagetop::prelude::*;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let volume = form::Range::new()
/// .with_name("volume")
/// .with_label(L10n::n("Volume"))

View file

@ -2,8 +2,8 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
// **< Item >***************************************************************************************
@ -20,7 +20,7 @@ use crate::LOCALES_BOOTSIER;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let item = form::select::Item::new("es", L10n::n("Spanish")).with_selected(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
@ -76,7 +76,7 @@ impl Item {
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let group = form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French")));
@ -149,7 +149,7 @@ pub enum Entry {
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let idioma = form::select::Field::new()
/// .with_name("language")
/// .with_label(L10n::n("Language"))

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
/// Componente para crear un **área de texto** de formulario.
///
@ -13,7 +13,7 @@ use crate::LOCALES_BOOTSIER;
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let descripcion = form::Textarea::new()
/// .with_name("description")
/// .with_label(L10n::n("Description"))

View file

@ -1,4 +1,4 @@
use crate::prelude::*;
use crate::theme::*;
const DEFAULT_VIEWBOX: &str = "0 0 16 16";

View file

@ -1,14 +1,16 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
/// Componente para renderizar una **imagen**.
/// Componente para renderizar una **imagen** ([`image`]).
///
/// - Ajusta su disposición según el origen definido en [`image::Source`].
/// - Permite configurar **dimensiones** ([`with_size()`](Self::with_size)), **borde**
/// A una imagen se le puede:
///
/// - Establecer su contenido a partir del origen definido en [`image::Source`].
/// - Configurar sus **dimensiones** ([`with_size()`](Self::with_size)), **borde**
/// ([`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`].
/// - Aplicar el texto alternativo `alt` con **localización** mediante [`L10n`].
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Image {
#[getters(skip)]
@ -53,7 +55,7 @@ impl Component for Image {
{
(logo.render(cx))
}
})
});
}
image::Source::Responsive(source) => Some(source),
image::Source::Thumbnail(source) => Some(source),

View file

@ -1,4 +1,4 @@
//! Definiciones para crear menús [`Nav`] o alguna de sus variantes de presentación.
//! Definiciones para crear menús ([`Nav`]).
//!
//! Cada [`nav::Item`](crate::theme::nav::Item) representa un elemento individual del menú [`Nav`],
//! con distintos comportamientos según su finalidad, como enlaces de navegación o menús
@ -6,26 +6,6 @@
//!
//! Los ítems pueden estar activos, deshabilitados o abrirse en nueva ventana según su contexto y
//! configuración, y permiten incluir etiquetas localizables usando [`L10n`](pagetop::locale::L10n).
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let nav = Nav::tabs()
//! .with_layout(nav::Layout::End)
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Options"))
//! .with_item(ChildOp::AddMany(vec![
//! dropdown::Item::link(L10n::n("Action"), |_| "/action".into()).into(),
//! dropdown::Item::link(L10n::n("Another"), |_| "/another".into()).into(),
//! ])),
//! ))
//! .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));
//! ```
mod props;
pub use props::{Kind, Layout};

View file

@ -1,15 +1,35 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
/// Componente para crear un **menú** o alguna de sus variantes ([`nav::Kind`]).
/// Componente para crear un **menú** ([`nav`]).
///
/// Presenta un menú con una lista de elementos usando una vista básica, o alguna de sus variantes
/// como *pestañas* (`Tabs`), *botones* (`Pills`) o *subrayado* (`Underline`). También permite
/// controlar su distribución y orientación ([`nav::Layout`](crate::theme::nav::Layout)).
/// ([`nav::Kind`]) como *pestañas* (`Tabs`), *botones* (`Pills`) o *subrayado* (`Underline`).
/// También permite controlar su distribución y orientación ([`nav::Layout`](crate::theme::nav::Layout)).
///
/// Ver ejemplo en el módulo [`nav`].
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let nav = Nav::tabs()
/// .with_layout(nav::Layout::End)
/// .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(nav::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
/// .with_item(nav::Item::dropdown(
/// Dropdown::new()
/// .with_title(L10n::n("Options"))
/// .with_item(ChildOp::AddMany(vec![
/// dropdown::Item::link(L10n::n("Action"), |_| "/action".into()).into(),
/// dropdown::Item::link(L10n::n("Another"), |_| "/another".into()).into(),
/// ])),
/// ))
/// .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav {
#[getters(skip)]

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
// **< ItemKind >***********************************************************************************

View file

@ -1,4 +1,4 @@
//! Definiciones para crear barras de navegación [`Navbar`].
//! Definiciones para crear barras de navegación ([`Navbar`]).
//!
//! 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
@ -6,126 +6,6 @@
//!
//! 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.
//!
//! # Ejemplos
//!
//! Barra **simple**, sólo con un menú horizontal:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let navbar = Navbar::simple()
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link(L10n::n("About"), |_| "/about".into()))
//! .with_item(nav::Item::link(L10n::n("Contact"), |_| "/contact".into()))
//! ));
//! ```
//!
//! Barra **colapsable**, con botón de despliegue y contenido en el desplegable cuando colapsa:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let navbar = Navbar::simple_toggle()
//! .with_expand(BreakPoint::MD)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link_blank(L10n::n("Docs"), |_| "https://docs.rs".into()))
//! .with_item(nav::Item::link(L10n::n("Support"), |_| "/support".into()))
//! ));
//! ```
//!
//! Barra con **marca de identidad a la izquierda** y menú a la derecha, típica de una cabecera:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("PageTop"))
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Tools"))
//! .with_item(dropdown::Item::link(
//! L10n::n("Generator"), |_| "/tools/gen".into())
//! )
//! .with_item(dropdown::Item::link(
//! L10n::n("Reports"), |_| "/tools/reports".into())
//! )
//! ))
//! .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()))
//! ));
//! ```
//!
//! Barra con **botón de despliegue a la izquierda** y **marca de identidad a la derecha**:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Intranet"))
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_right(brand)
//! .with_expand(BreakPoint::LG)
//! .with_item(navbar::Item::nav(
//! Nav::pills()
//! .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard".into()))
//! .with_item(nav::Item::link(L10n::n("Users"), |_| "/users".into()))
//! ));
//! ```
//!
//! Barra con el **contenido en un *offcanvas***, ideal para dispositivos móviles o menús largos:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let oc = Offcanvas::new()
//! .with_id("main_offcanvas")
//! .with_title(L10n::n("Main menu"))
//! .with_placement(offcanvas::Placement::Start)
//! .with_backdrop(offcanvas::Backdrop::Enabled);
//!
//! let navbar = Navbar::offcanvas(oc)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link(L10n::n("Profile"), |_| "/profile".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("More"))
//! .with_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings".into()))
//! .with_item(dropdown::Item::link(L10n::n("Help"), |_| "/help".into()))
//! ))
//! ));
//! ```
//!
//! Barra **fija arriba**:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Main App"))
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .with_position(navbar::Position::FixedTop)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/".into()))
//! .with_item(nav::Item::link(L10n::n("Donors"), |_| "/donors".into()))
//! .with_item(nav::Item::link(L10n::n("Stock"), |_| "/stock".into()))
//! ));
//! ```
mod props;
pub use props::{Layout, Position};

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
/// Marca de identidad para mostrar en una barra de navegación [`Navbar`].
///

View file

@ -1,19 +1,139 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
const TOGGLE_COLLAPSE: &str = "collapse";
const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// Componente para crear una **barra de navegación**.
/// Componente para crear una **barra de navegación** ([`navbar`]).
///
/// Permite mostrar enlaces, menús y una marca de identidad en distintas disposiciones (simples, con
/// botón de despliegue o dentro de un [`offcanvas`]), controladas por [`navbar::Layout`]. También
/// puede fijarse en la parte superior o inferior del documento mediante [`navbar::Position`].
///
/// Ver ejemplos en el módulo [`navbar`].
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplos
///
/// Barra **simple**, sólo con un menú horizontal:
///
/// ```rust
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let navbar = Navbar::simple()
/// .with_item(navbar::Item::nav(
/// Nav::new()
/// .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(nav::Item::link(L10n::n("About"), |_| "/about".into()))
/// .with_item(nav::Item::link(L10n::n("Contact"), |_| "/contact".into()))
/// ));
/// ```
///
/// Barra **colapsable**, con botón de despliegue y contenido en el desplegable cuando colapsa:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let navbar = Navbar::simple_toggle()
/// .with_expand(BreakPoint::MD)
/// .with_item(navbar::Item::nav(
/// Nav::new()
/// .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(nav::Item::link_blank(L10n::n("Docs"), |_| "https://docs.rs".into()))
/// .with_item(nav::Item::link(L10n::n("Support"), |_| "/support".into()))
/// ));
/// ```
///
/// Barra con **marca de identidad a la izquierda** y menú a la derecha, típica de una cabecera:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = navbar::Brand::new()
/// .with_title(L10n::n("PageTop"))
/// .with_route(Some(|cx| cx.route("/")));
///
/// let navbar = Navbar::brand_left(brand)
/// .with_item(navbar::Item::nav(
/// Nav::new()
/// .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(nav::Item::dropdown(
/// Dropdown::new()
/// .with_title(L10n::n("Tools"))
/// .with_item(dropdown::Item::link(
/// L10n::n("Generator"), |_| "/tools/gen".into())
/// )
/// .with_item(dropdown::Item::link(
/// L10n::n("Reports"), |_| "/tools/reports".into())
/// )
/// ))
/// .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()))
/// ));
/// ```
///
/// Barra con **botón de despliegue a la izquierda** y **marca de identidad a la derecha**:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = navbar::Brand::new()
/// .with_title(L10n::n("Intranet"))
/// .with_route(Some(|cx| cx.route("/")));
///
/// let navbar = Navbar::brand_right(brand)
/// .with_expand(BreakPoint::LG)
/// .with_item(navbar::Item::nav(
/// Nav::pills()
/// .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard".into()))
/// .with_item(nav::Item::link(L10n::n("Users"), |_| "/users".into()))
/// ));
/// ```
///
/// Barra con el **contenido en un *offcanvas***, ideal para dispositivos móviles o menús largos:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let oc = Offcanvas::new()
/// .with_id("main_offcanvas")
/// .with_title(L10n::n("Main menu"))
/// .with_placement(offcanvas::Placement::Start)
/// .with_backdrop(offcanvas::Backdrop::Enabled);
///
/// let navbar = Navbar::offcanvas(oc)
/// .with_item(navbar::Item::nav(
/// Nav::new()
/// .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(nav::Item::link(L10n::n("Profile"), |_| "/profile".into()))
/// .with_item(nav::Item::dropdown(
/// Dropdown::new()
/// .with_title(L10n::n("More"))
/// .with_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings".into()))
/// .with_item(dropdown::Item::link(L10n::n("Help"), |_| "/help".into()))
/// ))
/// ));
/// ```
///
/// Barra **fija arriba**:
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = navbar::Brand::new()
/// .with_title(L10n::n("Main App"))
/// .with_route(Some(|cx| cx.route("/")));
///
/// let navbar = Navbar::brand_left(brand)
/// .with_position(navbar::Position::FixedTop)
/// .with_item(navbar::Item::nav(
/// Nav::new()
/// .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/".into()))
/// .with_item(nav::Item::link(L10n::n("Donors"), |_| "/donors".into()))
/// .with_item(nav::Item::link(L10n::n("Stock"), |_| "/stock".into()))
/// ));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar {
#[getters(skip)]

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
/// Elementos que puede contener una barra de navegación [`Navbar`](crate::theme::Navbar).
///

View file

@ -1,6 +1,6 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::*;
// **< Layout >*************************************************************************************

View file

@ -1,24 +1,4 @@
//! Definiciones para crear paneles laterales deslizantes [`Offcanvas`].
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let panel = Offcanvas::new()
//! .with_id("offcanvas_example")
//! .with_title(L10n::n("Offcanvas title"))
//! .with_placement(offcanvas::Placement::End)
//! .with_backdrop(offcanvas::Backdrop::Enabled)
//! .with_body_scroll(offcanvas::BodyScroll::Enabled)
//! .with_visibility(offcanvas::Visibility::Default)
//! .with_child(Dropdown::new()
//! .with_title(L10n::n("Menu"))
//! .with_item(dropdown::Item::label(L10n::n("Label")))
//! .with_item(dropdown::Item::link_blank(L10n::n("Docs"), |_| "https://docs.rs".into()))
//! .with_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout".into()))
//! );
//! ```
//! Definiciones para crear paneles laterales deslizantes ([`Offcanvas`]).
mod props;
pub use props::{Backdrop, BodyScroll, Placement, Visibility};

View file

@ -1,9 +1,9 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
/// Componente para crear un **panel lateral deslizante** con contenidos adicionales.
/// Componente para crear un **panel lateral deslizante** ([`offcanvas`]).
///
/// Útil para navegación, filtros, formularios o menús contextuales. Incluye las siguientes
/// características principales:
@ -19,8 +19,28 @@ use crate::LOCALES_BOOTSIER;
/// - Asocia título y controles de accesibilidad a un identificador único y expone atributos
/// adecuados para lectores de pantalla y navegación por teclado.
///
/// Ver ejemplo en el módulo [`offcanvas`].
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let panel = Offcanvas::new()
/// .with_id("offcanvas_example")
/// .with_title(L10n::n("Offcanvas title"))
/// .with_placement(offcanvas::Placement::End)
/// .with_backdrop(offcanvas::Backdrop::Enabled)
/// .with_body_scroll(offcanvas::BodyScroll::Enabled)
/// .with_visibility(offcanvas::Visibility::Default)
/// .with_child(Dropdown::new()
/// .with_title(L10n::n("Menu"))
/// .with_item(dropdown::Item::label(L10n::n("Label")))
/// .with_item(dropdown::Item::link_blank(L10n::n("Docs"), |_| "https://docs.rs".into()))
/// .with_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout".into()))
/// );
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Offcanvas {
#[getters(skip)]

View file

@ -0,0 +1,29 @@
[package]
name = "pagetop-seaorm"
version = "0.0.4"
description = """
Proporciona a PageTop acceso basado en SeaORM a bases de datos relacionales.
"""
categories = ["database", "development-tools", "asynchronous"]
keywords = ["pagetop", "database", "sql", "orm", "ssr"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[features]
mysql = ["sea-orm/sqlx-mysql"]
postgres = ["sea-orm/sqlx-postgres"]
sqlite = ["sea-orm/sqlx-sqlite"]
[dependencies]
async-trait.workspace = true
pagetop.workspace = true
sea-orm.workspace = true
sea-schema.workspace = true
serde.workspace = true
tokio.workspace = true
url.workspace = true

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,201 @@
<div align="center">
<h1>PageTop SeaORM</h1>
<p>Proporciona a <strong>PageTop</strong> acceso basado en <a href="https://www.sea-ql.org/SeaORM">SeaORM</a> a bases de datos relacionales.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-seaorm?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-seaorm)
[![Crates.io](https://img.shields.io/crates/v/pagetop-seaorm.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-seaorm)
[![Descargas](https://img.shields.io/crates/d/pagetop-seaorm.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-seaorm)
[![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/extensions/pagetop-seaorm#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.
## ⚡️ Guía rápida
**Añade la dependencia** a tu `Cargo.toml` activando el motor de base de datos que necesites:
```toml
[dependencies]
pagetop-seaorm = { version = "...", features = ["sqlite"] }
```
Las *features* disponibles son `mysql`, `postgres` y `sqlite`.
**Configura la conexión** en el archivo de configuración de la aplicación:
```toml
[database]
db_type = "sqlite"
db_name = "my_app.db"
max_pool_size = 5
```
Para MySQL o PostgreSQL añade también `db_user`, `db_pass` y `db_host`. El campo `db_port` es
opcional; si se omite se usa el puerto predeterminado del motor.
**Declara la extensión** en tu aplicación o en la extensión que la requiera:
```rust,ignore
use pagetop::prelude::*;
use pagetop_seaorm::install_migrations;
mod migration;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
&pagetop_seaorm::SeaORM,
]
}
fn initialize(&self) {
install_migrations!(m20240101_000001_create_users_table);
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
**Escribe las migraciones** usando la API de [`migration`]:
```rust,no_run
// src/migration/m20240101_000001_create_users.rs
use pagetop_seaorm::migration::*;
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(Users::Table)
.col(pk_auto(Users::Id))
.col(string_uniq(Users::Email))
.col(string(Users::Name))
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
Email,
Name,
}
```
**Define las entidades** en un módulo `entity/` usando las macros de derivación de [`db`]:
```rust,no_run
// src/entity/user.rs
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
pub name: String,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
```
**Opera con la base de datos** pasando la conexión [`db::dbconn()`] a cada consulta:
```rust,ignore
use pagetop_seaorm::db::*;
// Asumiendo que existe un módulo `user` con la entidad definida arriba.
async fn example() -> Result<(), DbErr> {
// Listar todos los registros:
let users = user::Entity::find().all(dbconn()).await?;
// Buscar por clave primaria:
let found = user::Entity::find_by_id(1).one(dbconn()).await?;
// Insertar un registro:
let new_user = user::ActiveModel {
email: Set("alice@example.com".to_owned()),
name: Set("Alice".to_owned()),
..Default::default()
};
user::Entity::insert(new_user).exec(dbconn()).await?;
Ok(())
}
```
## 📚 Créditos
Este *crate* se apoya en bibliotecas del ecosistema [SeaQL](https://github.com/SeaQL) como:
* [SeaORM](https://www.sea-ql.org/SeaORM), ORM asíncrono que usa internamente
[SQLx](https://github.com/launchbadge/sqlx) para el acceso y la ejecución de consultas a la base
de datos.
* [SeaQuery](https://github.com/SeaQL/sea-query), generador de consultas SQL sobre el que se
construye el motor de migraciones y los *helpers* de esquema.
* [sea-schema](https://github.com/SeaQL/sea-schema), librería de introspección de esquemas SQL,
usada por el módulo de migraciones para interrogar la estructura real de la base de datos (tablas,
columnas, índices y claves externas).
El módulo de migraciones (`src/migration/`) incorpora una adaptación de
[sea-orm-migration](https://crates.io/crates/sea-orm-migration). El código que se integra procede de
la versión [**1.1.20**](https://github.com/SeaQL/sea-orm/tree/1.1.20/sea-orm-migration) en lugar de
usarlo como dependencia ya que su paradigma de CLI no es compatible con el ciclo de vida de las
extensiones de PageTop, donde las migraciones deben ejecutarse durante la inicialización de cada
extensión. Los ficheros adaptados del original son:
| Archivos | Observaciones |
|-----------------------|--------------------------------------------------------------------------|
| `lib.rs` | Incluido en `migration.rs`, descarta módulos y exportaciones del CLI |
| `connection.rs` | Integración completa |
| `manager.rs` | Adapta *features* propias |
| `migrator.rs` | Adapta *features* propias y omite gestión de errores del CLI |
| `prelude.rs` | Absorbido en `migration.rs`, descarta exportaciones del CLI |
| `schema.rs` | Integra con ajustes, adaptado de [loco](https://github.com/loco-rs/loco) |
| `seaql_migrations.rs` | Integración completa |
## 🚧 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,85 @@
//! Opciones de configuración de la extensión.
//!
//! Ejemplo:
//!
//! ```toml
//! [database]
//! db_type = "postgres"
//! db_name = "db"
//! db_user = "user"
//! db_pass = "password"
//! db_host = "localhost"
//! db_port = 5432
//! max_pool_size = 5
//! ```
//!
//! Uso:
//!
//! ```rust
//! # use pagetop_seaorm::config;
//! assert_eq!(config::SETTINGS.database.db_host, "localhost");
//! ```
//!
//! Consulta [`pagetop::config`] para ver cómo PageTop lee los archivos de configuración y aplica
//! los valores a los ajustes.
use pagetop::prelude::*;
use serde::Deserialize;
include_config!(SETTINGS: Settings => [
// [database]
"database.db_type" => "",
"database.db_name" => "",
"database.db_user" => "",
"database.db_pass" => "",
"database.db_host" => "localhost",
"database.max_pool_size" => 5,
]);
/// Ajustes para la sección [`Database`] de [`SETTINGS`].
#[derive(Debug, Deserialize)]
pub struct Settings {
pub database: Database,
}
/// Sección **`[database]`** de la configuración. Forma parte de [`Settings`].
#[derive(Debug, Deserialize)]
pub struct Database {
/// Motor de base de datos.
///
/// Valores aceptados: `"mysql"` (también `"mariadb"`), `"postgres"` (también `"postgresql"`) y
/// `"sqlite"`. Si se omite, la aplicación terminará con un error al arrancar.
pub db_type: DbType,
/// Nombre (para mysql/postgres) o referencia (para sqlite) de la base de datos.
pub db_name: String,
/// Usuario de conexión a la base de datos (para mysql/postgres).
pub db_user: String,
/// Contraseña para la conexión a la base de datos (para mysql/postgres).
pub db_pass: String,
/// Servidor de conexión a la base de datos (para mysql/postgres).
pub db_host: String,
/// Puerto de conexión a la base de datos (para mysql/postgres). Si se omite, se usa el puerto
/// predeterminado para el motor: 3306 para MySQL y 5432 para PostgreSQL.
pub db_port: Option<u16>,
/// Número máximo de conexiones habilitadas.
pub max_pool_size: u32,
}
/// Motor de base de datos. Usado en el campo [`Database::db_type`] de [`SETTINGS`].
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DbType {
/// Valor por defecto cuando `db_type` no está configurado. En este caso la aplicación terminará
/// con un error al arrancar.
#[serde(rename = "")]
Unset,
/// Usa el motor MySQL. Acepta también el alias `"mariadb"`.
#[serde(alias = "mariadb")]
Mysql,
/// Usa el motor PostgreSQL. Acepta también el alias `"postgresql"`.
#[serde(alias = "postgresql")]
Postgres,
/// Usa el motor SQLite.
Sqlite,
}

View file

@ -0,0 +1,308 @@
//! Definición de entidades y acceso a la base de datos.
//!
//! Agrupa los *traits*, macros y tipos del sistema de entidades de SeaORM, junto con las funciones
//! [`dbconn`], [`execute`], [`fetch_all`] y [`fetch_one`], en una sola importación:
//!
//! ```rust
//! use pagetop_seaorm::db::*;
//! ```
//!
//! El sistema de entidades (`Entity::find()`, `Entity::insert()`, transacciones) es el camino
//! recomendado para la mayoría de operaciones. Las funciones [`execute`], [`fetch_all`] y
//! [`fetch_one`] ofrecen vías alternativas para cuando ese sistema no es suficiente, como consultas
//! sin entidad concreta, SQL específico para el motor de base de datos utilizado o sentencias
//! puntuales.
//!
//! Estas funciones integran los valores como literales escapados, no como parámetros de base de
//! datos. Para consultas con datos del usuario, el sistema de entidades es más robusto. Si aun así
//! se necesita SQL en crudo con parámetros reales, se puede construir un [`api::Statement`]
//! directamente con [`api::Statement::from_sql_and_values`].
//!
//! ## Tipos esenciales
//!
//! Destacan los siguientes elementos de uso más frecuente:
//!
//! - **Acceso**: [`DatabaseConnection`], [`dbconn`] (para obtener el pool de conexiones).
//! - **Consultas**: [`EntityTrait`], [`QueryFilter`], [`QueryOrder`], [`QuerySelect`].
//! - **Transacciones**: [`TransactionTrait`], [`DatabaseTransaction`].
//! - **Modelos activos**: [`ActiveModelTrait`], [`ActiveValue`] ([`ActiveValue::Set`],
//! [`ActiveValue::Unchanged`], [`ActiveValue::NotSet`]).
//! - **Macros de derivación**: [`DeriveEntityModel`], [`DeriveColumn`], [`DerivePrimaryKey`],
//! [`DeriveRelation`], [`EnumIter`].
//! - **Errores**: [`DbErr`].
//! - **Resultados**: [`QueryResult`] (filas sin tipar), [`ExecResult`] (INSERT/UPDATE/DELETE).
//!
//! ## Definir una entidad
//!
//! ```rust
//! use pagetop_seaorm::db::*;
//!
//! #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
//! #[sea_orm(table_name = "users")]
//! // El struct debe llamarse `Model`: es un requisito de `DeriveEntityModel`.
//! pub struct Model {
//! #[sea_orm(primary_key)]
//! pub id: i32,
//! pub email: String,
//! pub name: String,
//! }
//!
//! #[derive(Clone, Copy, Debug, EnumIter, DeriveRelation)]
//! pub enum Relation {}
//!
//! // `DeriveEntityModel` genera también `ActiveModel`, `Entity`, `Column` y `PrimaryKey`.
//! impl ActiveModelBehavior for ActiveModel {}
//! ```
//!
//! ## Operaciones CRUD
//!
//! ```rust,ignore
//! use pagetop_seaorm::db::*;
//!
//! // El código asume que existe un módulo `user` con una entidad SeaORM definida.
//!
//! async fn example() -> Result<(), DbErr> {
//! // Buscar todos los registros.
//! let users = user::Entity::find().all(dbconn()).await?;
//!
//! // Buscar con filtro.
//! let alices = user::Entity::find()
//! .filter(user::Column::Name.eq("Alice"))
//! .all(dbconn())
//! .await?;
//!
//! // Buscar por clave primaria.
//! let found = user::Entity::find_by_id(1).one(dbconn()).await?;
//!
//! // Insertar.
//! let model = user::ActiveModel {
//! name: ActiveValue::Set("Alice".into()),
//! ..Default::default()
//! };
//! user::Entity::insert(model).exec(dbconn()).await?;
//!
//! // Actualizar: campos con `ActiveValue::Set`, clave primaria con `ActiveValue::Unchanged`.
//! let patch = user::ActiveModel {
//! id: ActiveValue::Unchanged(1),
//! name: ActiveValue::Set("Bob".into()),
//! ..Default::default()
//! };
//! patch.update(dbconn()).await?;
//!
//! // Eliminar por clave primaria.
//! user::Entity::delete_by_id(1).exec(dbconn()).await?;
//!
//! // Transacción. `Box::pin` es necesario: `TransactionTrait` exige `Pin<Box<dyn Future>>`.
//! dbconn().transaction::<_, DbErr, _>(|txn| Box::pin(async move {
//! user::Entity::insert(
//! user::ActiveModel { name: ActiveValue::Set("Carol".into()), ..Default::default() }
//! ).exec(txn).await?;
//! user::Entity::delete_by_id(2).exec(txn).await?;
//! Ok(())
//! })).await?;
//! Ok(())
//! }
//! ```
//!
//! Para migraciones y definición de esquemas usa [`migration`](crate::migration).
//!
//! ## Acceso completo a SeaORM
//!
//! El módulo [`api`] re-exporta el crate `sea_orm` íntegro bajo ese alias. Úsalo cuando necesites
//! un tipo o función que no esté expuesto directamente en `db::*`:
//!
//! ```rust
//! use pagetop_seaorm::db::api;
//!
//! // Tipos o utilidades no incluidos en db::*:
//! let _: api::DatabaseBackend = api::DatabaseBackend::Sqlite;
//! ```
//!
//! ## Construcción de consultas en tiempo de ejecución
//!
//! El módulo [`query`] re-exporta `sea_query` para construir las sentencias SQL que se pasan a
//! [`fetch_all`] y [`fetch_one`]. Es el compañero natural de esas funciones dentro del módulo `db`:
//!
//! ```rust
//! use pagetop_seaorm::db::*;
//! use pagetop_seaorm::db::query::*;
//!
//! async fn example() -> Result<(), DbErr> {
//! let stmt = Query::select()
//! .column(Asterisk)
//! .from(Alias::new("users"))
//! .to_owned();
//! let rows = fetch_all(&stmt).await?;
//! Ok(())
//! }
//! ```
pub use sea_orm::prelude::*;
pub use sea_orm::{
ActiveValue, DatabaseTransaction, ExecResult, QueryOrder, QuerySelect, TransactionTrait,
};
/// Permite implementar *traits* con métodos `async`:
#[doc(inline)]
pub use async_trait;
/// Re-exporta el crate `sea_orm` íntegro como puerta de acceso a su API completa.
///
/// Útil para tipos o utilidades que no están expuestos directamente en [`db::*`](self). La inmensa
/// mayoría de operaciones no necesitan este módulo; `db::*` cubre los casos habituales.
#[doc(inline)]
pub use sea_orm as api;
/// Re-exporta `sea_query` para construir sentencias SQL en tiempo de ejecución.
///
/// Proporciona los constructores de consultas (`Query`, `Expr`, `Alias`, ...) que se pasan a
/// [`fetch_all`] y [`fetch_one`]. Aunque [`migration`](crate::migration) expone las mismas
/// herramientas en el contexto de la definición de esquemas, `query` las sitúa donde corresponde
/// cuando se trata del acceso a la base de datos en tiempo de ejecución.
#[doc(inline)]
pub use sea_orm::sea_query as query;
/// Devuelve una referencia estática al pool de conexiones.
///
/// El pool se inicializa una sola vez al arrancar la aplicación; las llamadas posteriores devuelven
/// la misma referencia sin coste apreciable. Se puede invocar tantas veces como sea necesario sin
/// penalización.
///
/// ```rust,no_run
/// use pagetop_seaorm::db::*;
///
/// let _conn: &DatabaseConnection = dbconn();
/// ```
#[inline]
pub fn dbconn() -> &'static DatabaseConnection {
&super::DBCONN
}
/// Ejecuta una sentencia SQL en crudo y devuelve su resultado.
///
/// No construye la sentencia (INSERT, UPDATE, DELETE), sino que la recibe como una cadena ya
/// formada. Útil para SQL que el sistema de entidades no cubre. El [`ExecResult`] devuelto expone
/// [`rows_affected`](ExecResult::rows_affected) y [`last_insert_id`](ExecResult::last_insert_id)
/// (fiable en MySQL y SQLite; en PostgreSQL devuelve `0`, usa `RETURNING` con el sistema de
/// entidades si necesitas el id insertado).
///
/// > **Nota:** no sirve para SELECT porque no devuelve filas. Para leer datos usa [`fetch_all`] o
/// > [`fetch_one`].
///
/// > **Advertencia:** nunca interpoles valores externos en la cadena SQL directamente. Para
/// > sentencias con parámetros de usuario usa el sistema de entidades.
///
/// ```rust
/// use pagetop_seaorm::db::*;
///
/// async fn example() -> Result<(), DbErr> {
/// let result = execute("DELETE FROM sessions WHERE expired = 1").await?;
/// println!("Filas eliminadas: {}", result.rows_affected());
/// Ok(())
/// }
/// ```
pub async fn execute(stmt: impl Into<String>) -> Result<ExecResult, DbErr> {
let conn = dbconn();
let backend = conn.get_database_backend();
conn.execute(api::Statement::from_string(backend, stmt.into()))
.await
}
/// Ejecuta una consulta para devolver todas las filas resultantes.
///
/// Acepta cualquier tipo que implemente [`query::QueryStatementWriter`] (p. ej.
/// [`query::SelectStatement`]) y serializa la sentencia para el motor de base de datos usado antes
/// de ejecutarla. Cada fila se devuelve como un [`QueryResult`] sin tipar; extrae los valores con
/// [`QueryResult::try_get`].
///
/// Usa esta función cuando la consulta SELECT no mapea una entidad concreta (JOINs, agregaciones,
/// proyecciones parciales) o cuando necesitas control total sobre el SQL generado. Para sentencias
/// que modifican datos (INSERT, UPDATE, DELETE), usa [`execute`]. Para consultas que sí mapean a
/// una entidad, es preferible `Entity::find().all(dbconn())`.
///
/// Los valores se integran como literales escapados, no como parámetros de base de datos. Para
/// datos procedentes del usuario, el sistema de entidades es más robusto.
///
/// ```rust
/// use pagetop_seaorm::db::*;
/// use pagetop_seaorm::db::query::*;
///
/// async fn example() -> Result<(), DbErr> {
/// let stmt = Query::select()
/// .column(Asterisk)
/// .from(Alias::new("users"))
/// .to_owned();
/// let rows = fetch_all(&stmt).await?;
/// for row in rows {
/// let name: String = row.try_get("", "name")?;
/// println!("{name}");
/// }
/// Ok(())
/// }
/// ```
pub async fn fetch_all<Q: query::QueryStatementWriter>(
stmt: &Q,
) -> Result<Vec<QueryResult>, DbErr> {
let conn = dbconn();
let backend = conn.get_database_backend();
conn.query_all(api::Statement::from_string(
backend,
match backend {
api::DatabaseBackend::MySql => stmt.to_string(query::MysqlQueryBuilder),
api::DatabaseBackend::Postgres => stmt.to_string(query::PostgresQueryBuilder),
api::DatabaseBackend::Sqlite => stmt.to_string(query::SqliteQueryBuilder),
},
))
.await
}
/// Ejecuta una consulta y devuelve sólo la primera fila, si existe.
///
/// Funciona igual que [`fetch_all`] pero devuelve la primera fila si existe, o `None` si la
/// consulta no produce resultados. Está diseñada para sentencias SELECT; para modificar datos sin
/// entidad mapeada, usa [`execute`].
///
/// Si la consulta puede devolver varias filas, se recomienda incluir `LIMIT 1` en la sentencia
/// para que el motor detenga la búsqueda en cuanto encuentre la primera fila y no recupere
/// resultados que se descartarán de todas formas.
///
/// Usa esta función cuando la consulta SELECT no mapea una entidad concreta (JOINs, agregaciones,
/// proyecciones parciales) o cuando necesitas control total sobre el SQL generado. Para consultas
/// que sí mapean a una entidad, es preferible `Entity::find().one(dbconn())`.
///
/// Los valores se integran como literales escapados, no como parámetros de base de datos. Para
/// datos procedentes del usuario, el sistema de entidades es más robusto.
///
/// ```rust
/// use pagetop_seaorm::db::*;
/// use pagetop_seaorm::db::query::*;
///
/// async fn example() -> Result<(), DbErr> {
/// let stmt = Query::select()
/// .column(Asterisk)
/// .from(Alias::new("users"))
/// .and_where(Expr::col(Alias::new("id")).eq(1))
/// .to_owned();
/// if let Some(row) = fetch_one(&stmt).await? {
/// let name: String = row.try_get("", "name")?;
/// println!("{name}");
/// }
/// Ok(())
/// }
/// ```
pub async fn fetch_one<Q: query::QueryStatementWriter>(
stmt: &Q,
) -> Result<Option<QueryResult>, DbErr> {
let conn = dbconn();
let backend = conn.get_database_backend();
conn.query_one(api::Statement::from_string(
backend,
match backend {
api::DatabaseBackend::MySql => stmt.to_string(query::MysqlQueryBuilder),
api::DatabaseBackend::Postgres => stmt.to_string(query::PostgresQueryBuilder),
api::DatabaseBackend::Sqlite => stmt.to_string(query::SqliteQueryBuilder),
},
))
.await
}

View file

@ -0,0 +1,249 @@
/*!
<div align="center">
<h1>PageTop SeaORM</h1>
<p>Proporciona a <strong>PageTop</strong> acceso basado en <a href="https://www.sea-ql.org/SeaORM">SeaORM</a> a bases de datos relacionales.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-seaorm?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-seaorm)
[![Crates.io](https://img.shields.io/crates/v/pagetop-seaorm.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-seaorm)
[![Descargas](https://img.shields.io/crates/d/pagetop-seaorm.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-seaorm)
[![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/extensions/pagetop-seaorm#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.
## Guía rápida
**Añade la dependencia** a tu `Cargo.toml` activando el motor de base de datos que necesites:
```toml
[dependencies]
pagetop-seaorm = { version = "...", features = ["sqlite"] }
```
Las *features* disponibles son `mysql`, `postgres` y `sqlite`.
**Configura la conexión** en el archivo de configuración de la aplicación:
```toml
[database]
db_type = "sqlite"
db_name = "my_app.db"
max_pool_size = 5
```
Para MySQL o PostgreSQL añade también `db_user`, `db_pass` y `db_host`. El campo `db_port` es
opcional; si se omite se usa el puerto predeterminado del motor.
**Declara la extensión** en tu aplicación o en la extensión que la requiera:
```rust,ignore
use pagetop::prelude::*;
use pagetop_seaorm::install_migrations;
mod migration;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
&pagetop_seaorm::SeaORM,
]
}
fn initialize(&self) {
install_migrations!(m20240101_000001_create_users);
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
**Escribe las migraciones** usando la API de [`migration`]:
```rust,no_run
// src/migration/m20240101_000001_create_users.rs
use pagetop_seaorm::migration::*;
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(Users::Table)
.col(pk_auto(Users::Id))
.col(string_uniq(Users::Email))
.col(string(Users::Name))
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
Email,
Name,
}
```
**Define las entidades** en un módulo `entity/` usando las macros de derivación de [`db`]:
```rust,no_run
// src/entity/user.rs
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
pub name: String,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
```
**Opera con la base de datos** pasando la conexión [`db::dbconn()`] a cada consulta:
```rust,ignore
use pagetop_seaorm::db::*;
// Asumiendo que existe un módulo `user` con la entidad definida arriba.
async fn example() -> Result<(), DbErr> {
// Listar todos los registros:
let users = user::Entity::find().all(dbconn()).await?;
// Buscar por clave primaria:
let found = user::Entity::find_by_id(1).one(dbconn()).await?;
// Insertar un registro:
let new_user = user::ActiveModel {
email: Set("alice@example.com".to_owned()),
name: Set("Alice".to_owned()),
..Default::default()
};
user::Entity::insert(new_user).exec(dbconn()).await?;
Ok(())
}
```
*/
#![doc(
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
)]
use pagetop::prelude::*;
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use url::Url;
use std::sync::LazyLock;
include_locales!(LOCALES_SEAORM);
pub mod config;
pub mod db;
pub mod migration;
// Ejecuta un *future* de forma síncrona dentro del runtime de Tokio.
//
// Usa [`tokio::task::block_in_place`] para ceder el hilo actual al código bloqueante sin detener el
// *pool* de trabajo de Tokio, y a continuación ejecuta el *future* con el *handle* del *runtime*
// activo. Requiere el *runtime* multi-hilo (predeterminado con `#[pagetop::main]`).
//
// En tests, `#[pagetop::test]` aplica `multi_thread` por defecto. Si se utiliza `#[tokio::test]`
// directamente, habría que añadir `(flavor = "multi_thread")` si el test invoca código que llame a
// esta función.
pub(crate) fn run_now<F: std::future::Future>(future: F) -> F::Output {
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
}
pub(crate) static DBCONN: LazyLock<DatabaseConnection> = LazyLock::new(|| {
trace::info!(
"Connecting to database \"{}\" using a pool of {} connections",
&config::SETTINGS.database.db_name,
&config::SETTINGS.database.max_pool_size
);
let db_uri: String = match config::SETTINGS.database.db_type {
config::DbType::Unset => panic!(
"database.db_type is not configured: set it to \"mysql\", \"postgres\" or \"sqlite\""
),
config::DbType::Mysql | config::DbType::Postgres => {
let scheme = if matches!(config::SETTINGS.database.db_type, config::DbType::Mysql) {
"mysql"
} else {
"postgres"
};
let mut tmp_uri = Url::parse(&format!(
"{}://{}/{}",
scheme,
&config::SETTINGS.database.db_host,
&config::SETTINGS.database.db_name
))
.expect("Invalid database URL: check db_host and db_name in config");
tmp_uri
.set_username(config::SETTINGS.database.db_user.as_str())
.expect("Failed to set db_user in connection URL");
// https://github.com/launchbadge/sqlx/issues/1624
tmp_uri
.set_password(Some(config::SETTINGS.database.db_pass.as_str()))
.expect("Failed to set db_pass in connection URL");
if let Some(port) = config::SETTINGS.database.db_port {
tmp_uri
.set_port(Some(port))
.expect("Failed to set db_port in connection URL");
}
tmp_uri.to_string()
}
config::DbType::Sqlite => {
format!("sqlite://{}", &config::SETTINGS.database.db_name)
}
};
run_now(Database::connect::<ConnectOptions>({
let mut db_opt = ConnectOptions::new(db_uri);
db_opt.max_connections(config::SETTINGS.database.max_pool_size);
db_opt
}))
.expect("Failed to connect to database")
});
/// Implementa la extensión.
pub struct SeaORM;
impl Extension for SeaORM {
fn name(&self) -> L10n {
L10n::t("extension_name", &LOCALES_SEAORM)
}
fn description(&self) -> L10n {
L10n::t("extension_description", &LOCALES_SEAORM)
}
fn initialize(&self) {
std::sync::LazyLock::force(&DBCONN);
}
}

View file

@ -0,0 +1,2 @@
extension_name = SeaORM support
extension_description = Provides SeaORM-based access to relational databases.

View file

@ -0,0 +1,2 @@
extension_name = Soporte a SeaORM
extension_description = Proporciona acceso basado en SeaORM a bases de datos relacionales.

View file

@ -0,0 +1,298 @@
//! API para definir y ejecutar migraciones de base de datos.
//!
//! Cuando una extensión necesita persistir datos en una base de datos usando `pagetop_seaorm`,
//! define sus migraciones en un submódulo `migration/` y las aplica al arrancar con la macro
//! [`install_migrations!`](crate::install_migrations).
//!
//! Con una sola importación tienes todo lo necesario:
//!
//! ```rust
//! use pagetop_seaorm::migration::*;
//! ```
//!
//! # Convención de nombrado
//!
//! Cada migración es un módulo con el formato `m<YYYYMMDD>_<NNNNNN>_<nombre_descriptivo>`. El
//! prefijo numérico garantiza el orden cronológico de aplicación:
//!
//! ```text
//! src/
//! └── migration/
//! ├── m20240101_000001_create_users.rs
//! └── m20240115_000002_add_email_index.rs
//! ```
//!
//! # Estructura de una migración
//!
//! Cada archivo define un *struct* `Migration` que implementa [`MigrationTrait`]. El método `up`
//! aplica el cambio; `down` lo revierte. Si no se implementa, devuelve un error; es **obligatorio**
//! implementarlo si la extensión usa [`uninstall_migrations!`](crate::uninstall_migrations):
//!
//! ```rust,no_run
//! // src/migration/m20240101_000001_create_users.rs
//! use pagetop_seaorm::migration::*;
//!
//! pub struct Migration;
//!
//! #[async_trait::async_trait]
//! impl MigrationTrait for Migration {
//! async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
//! manager
//! .create_table(
//! table_auto(Users::Table)
//! .col(pk_auto(Users::Id))
//! .col(string_uniq(Users::Email))
//! .col(string(Users::Name))
//! .to_owned(),
//! )
//! .await
//! }
//! }
//!
//! #[derive(DeriveIden)]
//! enum Users {
//! Table,
//! Id,
//! Email,
//! Name,
//! }
//! ```
//!
//! # Seguimiento automático
//!
//! Las migraciones se mantienen en una tabla `seaql_migrations` de la base de datos. Cada migración
//! aplicada queda registrada con su nombre y su marca de tiempo. Las migraciones ya aplicadas se
//! omiten en ejecuciones posteriores.
//!
//! # Operaciones con `SchemaManager`
//!
//! El parámetro `manager` que recibe cada migración expone los métodos necesarios para
//! modificar el esquema. Estos son los más habituales:
//!
//! | Método | Acción |
//! |-----------------------------------------------|---------------------------------|
//! | [`SchemaManager::create_table`] | Crea una tabla |
//! | [`SchemaManager::drop_table`] | Elimina una tabla |
//! | [`SchemaManager::alter_table`] | Modifica una tabla existente |
//! | [`SchemaManager::rename_table`] | Renombra una tabla |
//! | [`SchemaManager::truncate_table`] | Vacía una tabla |
//! | [`SchemaManager::create_index`] | Crea un índice |
//! | [`SchemaManager::drop_index`] | Elimina un índice |
//! | [`SchemaManager::create_foreign_key`] | Crea una clave foránea |
//! | [`SchemaManager::drop_foreign_key`] | Elimina una clave foránea |
//! | [`SchemaManager::has_table`] | Comprueba si existe una tabla |
//! | [`SchemaManager::has_column`] | Comprueba si existe una columna |
//! | [`SchemaManager::has_index`] | Comprueba si existe un índice |
//! | [`SchemaManager::create_type`] *(PostgreSQL)* | Crea un tipo personalizado |
//! | [`SchemaManager::alter_type`] *(PostgreSQL)* | Modifica un tipo personalizado |
//! | [`SchemaManager::drop_type`] *(PostgreSQL)* | Elimina un tipo personalizado |
//!
//! # Funciones de esquema
//!
//! Las funciones de esquema disponibles simplifican la definición de columnas. Siguen el patrón
//! `<tipo>(col)` (NOT NULL), `<tipo>_null(col)` (nulable) y `<tipo>_uniq(col)` (NOT NULL + UNIQUE).
//! Algunos ejemplos:
//!
//! | Función | SQL equivalente |
//! |---------------------|-----------------------------------------------------|
//! | `table_auto(tabla)` | `CREATE TABLE IF NOT EXISTS` + timestamps |
//! | `pk_auto(col)` | `INTEGER NOT NULL PRIMARY KEY` (con autoincremento) |
//! | `pk_uuid(col)` | `UUID NOT NULL PRIMARY KEY` |
//! | `string(col)` | `VARCHAR NOT NULL` |
//! | `string_null(col)` | `VARCHAR NULL` |
//! | `string_uniq(col)` | `VARCHAR NOT NULL UNIQUE` |
//! | `integer(col)` | `INTEGER NOT NULL` |
//! | `boolean(col)` | `BOOLEAN NOT NULL` |
//! | `timestamp(col)` | `TIMESTAMP NOT NULL` |
//! | `uuid(col)` | `UUID NOT NULL` |
//!
//! Estas son sólo las funciones más habituales. El módulo [`schema`] define la lista completa, con
//! variantes para `decimal`, `date`, `time`, `json`, `blob`, `binary`, `array`, `enumeration`,
//! `char`, `interval` y sus formas `_null` y `_uniq`.
// **< Adaptación de `sea-orm-migration/lib.rs` (ver §Créditos en README.md) >**********************
//pub mod cli;
pub mod connection;
pub mod manager;
pub mod migrator;
//pub mod prelude;
pub mod schema;
pub mod seaql_migrations;
//pub mod util;
#[doc(inline)]
pub use connection::*;
#[doc(inline)]
pub use manager::*;
//pub use migrator::*;
/// Permite implementar *traits* con métodos `async`:
#[doc(inline)]
pub use async_trait;
//pub use sea_orm;
//pub use sea_orm::sea_query;
pub use sea_orm::DbErr;
pub trait MigrationName {
fn name(&self) -> &str;
}
/// The migration definition
#[async_trait::async_trait]
pub trait MigrationTrait: MigrationName + Send + Sync {
/// Define actions to perform when applying the migration
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr>;
/// Define actions to perform when rolling back the migration
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Err(DbErr::Migration(
"Rollback not implemented for this migration".to_owned(),
))
}
}
// *************************************************************************************************
#[doc(inline)]
pub use migrator::MigratorTrait;
#[doc(inline)]
pub use schema::*;
pub use sea_orm::DeriveIden;
pub use sea_orm::sea_query::*;
use pagetop::core::TypeInfo;
impl<M: MigrationTrait> MigrationName for M {
fn name(&self) -> &str {
// Extrae el módulo contenedor, descartando el segmento final "Migration".
TypeInfo::NameTo(-2).of::<M>()
}
}
/// Elemento de migración listo para incluir en la lista de un [`MigratorTrait`].
pub type MigrationItem = Box<dyn MigrationTrait>;
/// Interfaz síncrona para ejecutar migraciones desde código no asíncrono.
///
/// Todo tipo que implemente [`MigratorTrait`] obtiene esta interfaz automáticamente, incluidos los
/// tipos generados por los macros [`install_migrations!`](crate::install_migrations) y
/// [`uninstall_migrations!`](crate::uninstall_migrations).
pub trait MigratorBase {
/// Ejecuta las migraciones pendientes en orden ascendente.
///
/// Provoca un `panic!` si alguna migración falla, evitando que la aplicación arranque con un
/// esquema de base de datos inconsistente.
fn run_up();
/// Revierte todas las migraciones en orden descendente.
///
/// Provoca un `panic!` si alguna reversión falla.
fn run_down();
}
impl<M: MigratorTrait> MigratorBase for M {
fn run_up() {
let conn = SchemaManagerConnection::Connection(&super::DBCONN);
if let Err(e) = super::run_now(Self::up(conn, None)) {
panic!("Migration upgrade failed: {e}");
}
}
fn run_down() {
let conn = SchemaManagerConnection::Connection(&super::DBCONN);
if let Err(e) = super::run_now(Self::down(conn, None)) {
panic!("Migration downgrade failed: {e}");
}
}
}
/// Aplica las migraciones pendientes al arrancar una extensión.
///
/// Recibe uno o más nombres de módulo de migración y ejecuta el método `up` de los que aún no estén
/// registrados en la tabla `seaql_migrations`. Se invoca habitualmente desde
/// [`Extension::initialize`](pagetop::core::extension::Extension::initialize).
///
/// **Requisito:** cada módulo de migración debe declararse como submódulo público bajo un módulo
/// `migration` accesible desde el punto de llamada, y exportar `pub struct Migration` que
/// implemente [`MigrationTrait`]. El macro genera rutas de la forma
/// `migration::<nombre>::Migration`. Estructura mínima:
///
/// En `src/migration.rs`:
/// ```rust,ignore
/// pub mod m20240101_000001_create_users;
/// pub mod m20240115_000002_add_email_index;
/// ```
///
/// En `src/lib.rs`:
/// ```rust,ignore
/// mod migration;
///
/// impl Extension for MyExt {
/// fn initialize(&self) {
/// install_migrations!(
/// m20240101_000001_create_users,
/// m20240115_000002_add_email_index,
/// );
/// }
/// }
/// ```
#[macro_export]
macro_rules! install_migrations {
( $($migration_module:ident),+ $(,)? ) => {{
use $crate::migration::{MigrationItem, MigratorBase, MigratorTrait};
struct Migrator;
impl MigratorTrait for Migrator {
fn migrations() -> Vec<MigrationItem> {
let mut m = Vec::<MigrationItem>::new();
$(
m.push(Box::new(migration::$migration_module::Migration));
)*
m
}
}
Migrator::run_up();
}};
}
/// Revierte las migraciones de una extensión en orden inverso al de su aplicación.
///
/// Ejecuta el método `down` de cada migración indicada. Si el método `down` de alguna migración
/// devuelve un error, provoca un `panic!`. Complementario a
/// [`install_migrations!`](crate::install_migrations).
///
/// **Requisito:** los módulos de migración deben declararse como en
/// [`install_migrations!`](crate::install_migrations). Todos los módulos indicados **deben
/// implementar `down`**; la implementación por defecto devuelve error, lo que provoca un pánico en
/// `run_down`.
///
/// En `src/lib.rs`:
/// ```rust,ignore
/// impl Extension for MyExt {
/// fn uninitialize(&self) {
/// uninstall_migrations!(
/// m20240101_000001_create_users,
/// m20240115_000002_add_email_index,
/// );
/// }
/// }
/// ```
#[macro_export]
macro_rules! uninstall_migrations {
( $($migration_module:ident),+ $(,)? ) => {{
use $crate::migration::{MigrationItem, MigratorBase, MigratorTrait};
struct Migrator;
impl MigratorTrait for Migrator {
fn migrations() -> Vec<MigrationItem> {
let mut m = Vec::<MigrationItem>::new();
$(
m.push(Box::new(migration::$migration_module::Migration));
)*
m
}
}
Migrator::run_down();
}};
}

View file

@ -0,0 +1,148 @@
use sea_orm::{
AccessMode, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, DbErr,
ExecResult, IsolationLevel, QueryResult, Statement, TransactionError, TransactionTrait,
};
use std::future::Future;
use std::pin::Pin;
pub enum SchemaManagerConnection<'c> {
Connection(&'c DatabaseConnection),
Transaction(&'c DatabaseTransaction),
}
#[async_trait::async_trait]
impl ConnectionTrait for SchemaManagerConnection<'_> {
fn get_database_backend(&self) -> DbBackend {
match self {
SchemaManagerConnection::Connection(conn) => conn.get_database_backend(),
SchemaManagerConnection::Transaction(trans) => trans.get_database_backend(),
}
}
async fn execute(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
match self {
SchemaManagerConnection::Connection(conn) => conn.execute(stmt).await,
SchemaManagerConnection::Transaction(trans) => trans.execute(stmt).await,
}
}
async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
match self {
SchemaManagerConnection::Connection(conn) => conn.execute_unprepared(sql).await,
SchemaManagerConnection::Transaction(trans) => trans.execute_unprepared(sql).await,
}
}
async fn query_one(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
match self {
SchemaManagerConnection::Connection(conn) => conn.query_one(stmt).await,
SchemaManagerConnection::Transaction(trans) => trans.query_one(stmt).await,
}
}
async fn query_all(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
match self {
SchemaManagerConnection::Connection(conn) => conn.query_all(stmt).await,
SchemaManagerConnection::Transaction(trans) => trans.query_all(stmt).await,
}
}
fn is_mock_connection(&self) -> bool {
match self {
SchemaManagerConnection::Connection(conn) => conn.is_mock_connection(),
SchemaManagerConnection::Transaction(trans) => trans.is_mock_connection(),
}
}
}
#[async_trait::async_trait]
impl TransactionTrait for SchemaManagerConnection<'_> {
async fn begin(&self) -> Result<DatabaseTransaction, DbErr> {
match self {
SchemaManagerConnection::Connection(conn) => conn.begin().await,
SchemaManagerConnection::Transaction(trans) => trans.begin().await,
}
}
async fn begin_with_config(
&self,
isolation_level: Option<IsolationLevel>,
access_mode: Option<AccessMode>,
) -> Result<DatabaseTransaction, DbErr> {
match self {
SchemaManagerConnection::Connection(conn) => {
conn.begin_with_config(isolation_level, access_mode).await
}
SchemaManagerConnection::Transaction(trans) => {
trans.begin_with_config(isolation_level, access_mode).await
}
}
}
async fn transaction<F, T, E>(&self, callback: F) -> Result<T, TransactionError<E>>
where
F: for<'a> FnOnce(
&'a DatabaseTransaction,
) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>
+ Send,
T: Send,
E: std::fmt::Display + std::fmt::Debug + Send,
{
match self {
SchemaManagerConnection::Connection(conn) => conn.transaction(callback).await,
SchemaManagerConnection::Transaction(trans) => trans.transaction(callback).await,
}
}
async fn transaction_with_config<F, T, E>(
&self,
callback: F,
isolation_level: Option<IsolationLevel>,
access_mode: Option<AccessMode>,
) -> Result<T, TransactionError<E>>
where
F: for<'a> FnOnce(
&'a DatabaseTransaction,
) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>
+ Send,
T: Send,
E: std::fmt::Display + std::fmt::Debug + Send,
{
match self {
SchemaManagerConnection::Connection(conn) => {
conn.transaction_with_config(callback, isolation_level, access_mode)
.await
}
SchemaManagerConnection::Transaction(trans) => {
trans
.transaction_with_config(callback, isolation_level, access_mode)
.await
}
}
}
}
pub trait IntoSchemaManagerConnection<'c>: Send
where
Self: 'c,
{
fn into_schema_manager_connection(self) -> SchemaManagerConnection<'c>;
}
impl<'c> IntoSchemaManagerConnection<'c> for SchemaManagerConnection<'c> {
fn into_schema_manager_connection(self) -> SchemaManagerConnection<'c> {
self
}
}
impl<'c> IntoSchemaManagerConnection<'c> for &'c DatabaseConnection {
fn into_schema_manager_connection(self) -> SchemaManagerConnection<'c> {
SchemaManagerConnection::Connection(self)
}
}
impl<'c> IntoSchemaManagerConnection<'c> for &'c DatabaseTransaction {
fn into_schema_manager_connection(self) -> SchemaManagerConnection<'c> {
SchemaManagerConnection::Transaction(self)
}
}

View file

@ -0,0 +1,186 @@
use super::{IntoSchemaManagerConnection, SchemaManagerConnection};
use sea_orm::sea_query::{
ForeignKeyCreateStatement, ForeignKeyDropStatement, IndexCreateStatement, IndexDropStatement,
SelectStatement, TableAlterStatement, TableCreateStatement, TableDropStatement,
TableRenameStatement, TableTruncateStatement,
extension::postgres::{TypeAlterStatement, TypeCreateStatement, TypeDropStatement},
};
use sea_orm::{ConnectionTrait, DbBackend, DbErr, StatementBuilder};
#[allow(unused_imports)]
use sea_schema::probe::SchemaProbe;
/// Helper struct for writing migration scripts in migration file
pub struct SchemaManager<'c> {
conn: SchemaManagerConnection<'c>,
}
impl<'c> SchemaManager<'c> {
pub fn new<T>(conn: T) -> Self
where
T: IntoSchemaManagerConnection<'c>,
{
Self {
conn: conn.into_schema_manager_connection(),
}
}
pub async fn exec_stmt<S>(&self, stmt: S) -> Result<(), DbErr>
where
S: StatementBuilder,
{
let builder = self.conn.get_database_backend();
self.conn.execute(builder.build(&stmt)).await.map(|_| ())
}
pub fn get_database_backend(&self) -> DbBackend {
self.conn.get_database_backend()
}
pub fn get_connection(&self) -> &SchemaManagerConnection<'c> {
&self.conn
}
}
/// Schema Creation
impl SchemaManager<'_> {
pub async fn create_table(&self, stmt: TableCreateStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn create_index(&self, stmt: IndexCreateStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn create_foreign_key(&self, stmt: ForeignKeyCreateStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn create_type(&self, stmt: TypeCreateStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
}
/// Schema Mutation
impl SchemaManager<'_> {
pub async fn alter_table(&self, stmt: TableAlterStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn drop_table(&self, stmt: TableDropStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn rename_table(&self, stmt: TableRenameStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn truncate_table(&self, stmt: TableTruncateStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn drop_index(&self, stmt: IndexDropStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn drop_foreign_key(&self, stmt: ForeignKeyDropStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn alter_type(&self, stmt: TypeAlterStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
pub async fn drop_type(&self, stmt: TypeDropStatement) -> Result<(), DbErr> {
self.exec_stmt(stmt).await
}
}
/// Schema Inspection.
impl SchemaManager<'_> {
pub async fn has_table<T>(&self, table: T) -> Result<bool, DbErr>
where
T: AsRef<str>,
{
has_table(&self.conn, table).await
}
pub async fn has_column<T, C>(&self, _table: T, _column: C) -> Result<bool, DbErr>
where
T: AsRef<str>,
C: AsRef<str>,
{
let _stmt: SelectStatement = match self.conn.get_database_backend() {
#[cfg(feature = "mysql")]
DbBackend::MySql => sea_schema::mysql::MySql.has_column(_table, _column),
#[cfg(feature = "postgres")]
DbBackend::Postgres => sea_schema::postgres::Postgres.has_column(_table, _column),
#[cfg(feature = "sqlite")]
DbBackend::Sqlite => sea_schema::sqlite::Sqlite.has_column(_table, _column),
#[allow(unreachable_patterns)]
other => panic!("{other:?} feature is off"),
};
#[allow(unreachable_code)]
let builder = self.conn.get_database_backend();
let res = self
.conn
.query_one(builder.build(&_stmt))
.await?
.ok_or_else(|| DbErr::Custom("Failed to check column exists".to_owned()))?;
res.try_get("", "has_column")
}
pub async fn has_index<T, I>(&self, _table: T, _index: I) -> Result<bool, DbErr>
where
T: AsRef<str>,
I: AsRef<str>,
{
let _stmt: SelectStatement = match self.conn.get_database_backend() {
#[cfg(feature = "mysql")]
DbBackend::MySql => sea_schema::mysql::MySql.has_index(_table, _index),
#[cfg(feature = "postgres")]
DbBackend::Postgres => sea_schema::postgres::Postgres.has_index(_table, _index),
#[cfg(feature = "sqlite")]
DbBackend::Sqlite => sea_schema::sqlite::Sqlite.has_index(_table, _index),
#[allow(unreachable_patterns)]
other => panic!("{other:?} feature is off"),
};
#[allow(unreachable_code)]
let builder = self.conn.get_database_backend();
let res = self
.conn
.query_one(builder.build(&_stmt))
.await?
.ok_or_else(|| DbErr::Custom("Failed to check index exists".to_owned()))?;
res.try_get("", "has_index")
}
}
pub(crate) async fn has_table<C, T>(conn: &C, _table: T) -> Result<bool, DbErr>
where
C: ConnectionTrait,
T: AsRef<str>,
{
let _stmt: SelectStatement = match conn.get_database_backend() {
#[cfg(feature = "mysql")]
DbBackend::MySql => sea_schema::mysql::MySql.has_table(_table),
#[cfg(feature = "postgres")]
DbBackend::Postgres => sea_schema::postgres::Postgres.has_table(_table),
#[cfg(feature = "sqlite")]
DbBackend::Sqlite => sea_schema::sqlite::Sqlite.has_table(_table),
#[allow(unreachable_patterns)]
other => panic!("{other:?} feature is off"),
};
#[allow(unreachable_code)]
let builder = conn.get_database_backend();
let res = conn
.query_one(builder.build(&_stmt))
.await?
.ok_or_else(|| DbErr::Custom("Failed to check table exists".to_owned()))?;
res.try_get("", "has_table")
}

View file

@ -0,0 +1,617 @@
use std::collections::HashSet;
use std::fmt::Display;
use std::future::Future;
use std::pin::Pin;
use std::time::SystemTime;
use pagetop::trace::info;
use sea_orm::sea_query::{
self, Alias, Expr, ExprTrait, ForeignKey, IntoIden, Order, Query, SelectStatement, SimpleExpr,
Table, extension::postgres::Type,
};
use sea_orm::{
ActiveModelTrait, ActiveValue, Condition, ConnectionTrait, DbBackend, DbErr, DeriveIden,
DynIden, EntityTrait, FromQueryResult, Iterable, QueryFilter, Schema, Statement,
TransactionTrait,
};
#[allow(unused_imports)]
use sea_schema::probe::SchemaProbe;
use super::{IntoSchemaManagerConnection, MigrationTrait, SchemaManager, seaql_migrations};
/// Status of migration
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum MigrationStatus {
/// Not yet applied
Pending,
/// Applied
Applied,
}
impl Display for MigrationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let status = match self {
MigrationStatus::Pending => "Pending",
MigrationStatus::Applied => "Applied",
};
write!(f, "{status}")
}
}
pub struct Migration {
migration: Box<dyn MigrationTrait>,
status: MigrationStatus,
}
impl Migration {
/// Get migration name from MigrationName trait implementation
pub fn name(&self) -> &str {
self.migration.name()
}
/// Get migration status
pub fn status(&self) -> MigrationStatus {
self.status
}
}
/// Performing migrations on a database
#[async_trait::async_trait]
pub trait MigratorTrait: Send {
/// Vector of migrations in time sequence
fn migrations() -> Vec<Box<dyn MigrationTrait>>;
/// Name of the migration table, it is `seaql_migrations` by default
fn migration_table_name() -> DynIden {
seaql_migrations::Entity.into_iden()
}
/// Get list of migrations wrapped in `Migration` struct
fn get_migration_files() -> Vec<Migration> {
Self::migrations()
.into_iter()
.map(|migration| Migration {
migration,
status: MigrationStatus::Pending,
})
.collect()
}
/// Get list of applied migrations from database
async fn get_migration_models<C>(db: &C) -> Result<Vec<seaql_migrations::Model>, DbErr>
where
C: ConnectionTrait,
{
Self::install(db).await?;
let stmt = Query::select()
.table_name(Self::migration_table_name())
.columns(seaql_migrations::Column::iter().map(IntoIden::into_iden))
.order_by(seaql_migrations::Column::Version, Order::Asc)
.to_owned();
let builder = db.get_database_backend();
seaql_migrations::Model::find_by_statement(builder.build(&stmt))
.all(db)
.await
}
/// Get list of migrations with status
async fn get_migration_with_status<C>(db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
{
Self::install(db).await?;
let mut migration_files = Self::get_migration_files();
let migration_models = Self::get_migration_models(db).await?;
let migration_in_db: HashSet<String> = migration_models
.into_iter()
.map(|model| model.version)
.collect();
let migration_in_fs: HashSet<String> = migration_files
.iter()
.map(|file| file.migration.name().to_string())
.collect();
let pending_migrations = &migration_in_fs - &migration_in_db;
for migration_file in migration_files.iter_mut() {
if !pending_migrations.contains(migration_file.migration.name()) {
migration_file.status = MigrationStatus::Applied;
}
}
/*
let missing_migrations_in_fs = &migration_in_db - &migration_in_fs;
let errors: Vec<String> = missing_migrations_in_fs
.iter()
.map(|missing_migration| {
format!("Migration file of version '{missing_migration}' is missing, this migration has been applied but its file is missing")
}).collect();
if !errors.is_empty() {
Err(DbErr::Custom(errors.join("\n")))
} else { */
Ok(migration_files)
/* } */
}
/// Get list of pending migrations
async fn get_pending_migrations<C>(db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
{
Self::install(db).await?;
Ok(Self::get_migration_with_status(db)
.await?
.into_iter()
.filter(|file| file.status == MigrationStatus::Pending)
.collect())
}
/// Get list of applied migrations
async fn get_applied_migrations<C>(db: &C) -> Result<Vec<Migration>, DbErr>
where
C: ConnectionTrait,
{
Self::install(db).await?;
Ok(Self::get_migration_with_status(db)
.await?
.into_iter()
.filter(|file| file.status == MigrationStatus::Applied)
.collect())
}
/// Create migration table `seaql_migrations` in the database
async fn install<C>(db: &C) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
let builder = db.get_database_backend();
let table_name = Self::migration_table_name();
let schema = Schema::new(builder);
let mut stmt = schema
.create_table_from_entity(seaql_migrations::Entity)
.table_name(table_name);
stmt.if_not_exists();
db.execute(builder.build(&stmt)).await.map(|_| ())
}
/// Check the status of all migrations
async fn status<C>(db: &C) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
Self::install(db).await?;
info!("Checking migration status");
for Migration { migration, status } in Self::get_migration_with_status(db).await? {
info!("Migration '{}'... {}", migration.name(), status);
}
Ok(())
}
/// Drop all tables from the database, then reapply all migrations
async fn fresh<'c, C>(db: C) -> Result<(), DbErr>
where
C: IntoSchemaManagerConnection<'c>,
{
exec_with_connection::<'_, _, _>(db, move |manager| {
Box::pin(async move { exec_fresh::<Self>(manager).await })
})
.await
}
/// Rollback all applied migrations, then reapply all migrations
async fn refresh<'c, C>(db: C) -> Result<(), DbErr>
where
C: IntoSchemaManagerConnection<'c>,
{
exec_with_connection::<'_, _, _>(db, move |manager| {
Box::pin(async move {
exec_down::<Self>(manager, None).await?;
exec_up::<Self>(manager, None).await
})
})
.await
}
/// Rollback all applied migrations
async fn reset<'c, C>(db: C) -> Result<(), DbErr>
where
C: IntoSchemaManagerConnection<'c>,
{
exec_with_connection::<'_, _, _>(db, move |manager| {
Box::pin(async move { exec_down::<Self>(manager, None).await })
})
.await
}
/// Apply pending migrations
async fn up<'c, C>(db: C, steps: Option<u32>) -> Result<(), DbErr>
where
C: IntoSchemaManagerConnection<'c>,
{
exec_with_connection::<'_, _, _>(db, move |manager| {
Box::pin(async move { exec_up::<Self>(manager, steps).await })
})
.await
}
/// Rollback applied migrations
async fn down<'c, C>(db: C, steps: Option<u32>) -> Result<(), DbErr>
where
C: IntoSchemaManagerConnection<'c>,
{
exec_with_connection::<'_, _, _>(db, move |manager| {
Box::pin(async move { exec_down::<Self>(manager, steps).await })
})
.await
}
}
async fn exec_with_connection<'c, C, F>(db: C, f: F) -> Result<(), DbErr>
where
C: IntoSchemaManagerConnection<'c>,
F: for<'b> Fn(
&'b SchemaManager<'_>,
) -> Pin<Box<dyn Future<Output = Result<(), DbErr>> + Send + 'b>>,
{
let db = db.into_schema_manager_connection();
match db.get_database_backend() {
DbBackend::Postgres => {
let transaction = db.begin().await?;
let manager = SchemaManager::new(&transaction);
f(&manager).await?;
transaction.commit().await
}
DbBackend::MySql | DbBackend::Sqlite => {
let manager = SchemaManager::new(db);
f(&manager).await
}
}
}
async fn exec_fresh<M>(manager: &SchemaManager<'_>) -> Result<(), DbErr>
where
M: MigratorTrait + ?Sized,
{
let db = manager.get_connection();
M::install(db).await?;
let db_backend = db.get_database_backend();
// Temporarily disable the foreign key check
if db_backend == DbBackend::Sqlite {
info!("Disabling foreign key check");
db.execute(Statement::from_string(
db_backend,
"PRAGMA foreign_keys = OFF".to_owned(),
))
.await?;
info!("Foreign key check disabled");
}
// Drop all foreign keys
if db_backend == DbBackend::MySql {
info!("Dropping all foreign keys");
let stmt = query_mysql_foreign_keys(db);
let rows = db.query_all(db_backend.build(&stmt)).await?;
for row in rows.into_iter() {
let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?;
let table_name: String = row.try_get("", "TABLE_NAME")?;
info!(
"Dropping foreign key '{}' from table '{}'",
constraint_name, table_name
);
let mut stmt = ForeignKey::drop();
stmt.table(Alias::new(table_name.as_str()))
.name(constraint_name.as_str());
db.execute(db_backend.build(&stmt)).await?;
info!("Foreign key '{}' has been dropped", constraint_name);
}
info!("All foreign keys dropped");
}
// Drop all tables
let stmt = query_tables(db).await;
let rows = db.query_all(db_backend.build(&stmt)).await?;
for row in rows.into_iter() {
let table_name: String = row.try_get("", "table_name")?;
info!("Dropping table '{}'", table_name);
let mut stmt = Table::drop();
stmt.table(Alias::new(table_name.as_str()))
.if_exists()
.cascade();
db.execute(db_backend.build(&stmt)).await?;
info!("Table '{}' has been dropped", table_name);
}
// Drop all types
if db_backend == DbBackend::Postgres {
info!("Dropping all types");
let stmt = query_pg_types(db);
let rows = db.query_all(db_backend.build(&stmt)).await?;
for row in rows {
let type_name: String = row.try_get("", "typname")?;
info!("Dropping type '{}'", type_name);
let mut stmt = Type::drop();
stmt.name(Alias::new(&type_name));
db.execute(db_backend.build(&stmt)).await?;
info!("Type '{}' has been dropped", type_name);
}
}
// Restore the foreign key check
if db_backend == DbBackend::Sqlite {
info!("Restoring foreign key check");
db.execute(Statement::from_string(
db_backend,
"PRAGMA foreign_keys = ON".to_owned(),
))
.await?;
info!("Foreign key check restored");
}
// Reapply all migrations
exec_up::<M>(manager, None).await
}
async fn exec_up<M>(manager: &SchemaManager<'_>, mut steps: Option<u32>) -> Result<(), DbErr>
where
M: MigratorTrait + ?Sized,
{
let db = manager.get_connection();
M::install(db).await?;
/*
if let Some(steps) = steps {
info!("Applying {} pending migrations", steps);
} else {
info!("Applying all pending migrations");
}
*/
let migrations = M::get_pending_migrations(db).await?.into_iter();
/*
if migrations.len() == 0 {
info!("No pending migrations");
}
*/
for Migration { migration, .. } in migrations {
if let Some(steps) = steps.as_mut() {
if steps == &0 {
break;
}
*steps -= 1;
}
info!("Applying migration '{}'", migration.name());
migration.up(manager).await?;
info!("Migration '{}' has been applied", migration.name());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!");
seaql_migrations::Entity::insert(seaql_migrations::ActiveModel {
version: ActiveValue::Set(migration.name().to_owned()),
applied_at: ActiveValue::Set(now.as_secs() as i64),
})
.table_name(M::migration_table_name())
.exec(db)
.await?;
}
Ok(())
}
async fn exec_down<M>(manager: &SchemaManager<'_>, mut steps: Option<u32>) -> Result<(), DbErr>
where
M: MigratorTrait + ?Sized,
{
let db = manager.get_connection();
M::install(db).await?;
if let Some(steps) = steps {
info!("Rolling back {} applied migrations", steps);
} else {
info!("Rolling back all applied migrations");
}
let migrations = M::get_applied_migrations(db).await?.into_iter().rev();
if migrations.len() == 0 {
info!("No applied migrations");
}
for Migration { migration, .. } in migrations {
if let Some(steps) = steps.as_mut() {
if steps == &0 {
break;
}
*steps -= 1;
}
info!("Rolling back migration '{}'", migration.name());
migration.down(manager).await?;
info!("Migration '{}' has been rollbacked", migration.name());
seaql_migrations::Entity::delete_many()
.filter(Expr::col(seaql_migrations::Column::Version).eq(migration.name()))
.table_name(M::migration_table_name())
.exec(db)
.await?;
}
Ok(())
}
async fn query_tables<C>(db: &C) -> SelectStatement
where
C: ConnectionTrait,
{
match db.get_database_backend() {
#[cfg(feature = "mysql")]
DbBackend::MySql => sea_schema::mysql::MySql.query_tables(),
#[cfg(feature = "postgres")]
DbBackend::Postgres => sea_schema::postgres::Postgres.query_tables(),
#[cfg(feature = "sqlite")]
DbBackend::Sqlite => sea_schema::sqlite::Sqlite.query_tables(),
#[allow(unreachable_patterns)]
other => panic!("{other:?} feature is off"),
}
}
fn get_current_schema<C>(db: &C) -> SimpleExpr
where
C: ConnectionTrait,
{
match db.get_database_backend() {
#[cfg(feature = "mysql")]
DbBackend::MySql => sea_schema::mysql::MySql::get_current_schema(),
#[cfg(feature = "postgres")]
DbBackend::Postgres => sea_schema::postgres::Postgres::get_current_schema(),
#[cfg(feature = "sqlite")]
DbBackend::Sqlite => sea_schema::sqlite::Sqlite::get_current_schema(),
#[allow(unreachable_patterns)]
other => panic!("{other:?} feature is off"),
}
}
#[derive(DeriveIden)]
enum InformationSchema {
#[sea_orm(iden = "information_schema")]
Schema,
#[sea_orm(iden = "TABLE_NAME")]
TableName,
#[sea_orm(iden = "CONSTRAINT_NAME")]
ConstraintName,
TableConstraints,
TableSchema,
ConstraintType,
}
fn query_mysql_foreign_keys<C>(db: &C) -> SelectStatement
where
C: ConnectionTrait,
{
let mut stmt = Query::select();
stmt.columns([
InformationSchema::TableName,
InformationSchema::ConstraintName,
])
.from((
InformationSchema::Schema,
InformationSchema::TableConstraints,
))
.cond_where(
Condition::all()
.add(get_current_schema(db).equals((
InformationSchema::TableConstraints,
InformationSchema::TableSchema,
)))
.add(
Expr::col((
InformationSchema::TableConstraints,
InformationSchema::ConstraintType,
))
.eq("FOREIGN KEY"),
),
);
stmt
}
#[derive(DeriveIden)]
enum PgType {
Table,
Oid,
Typname,
Typnamespace,
Typelem,
}
#[derive(DeriveIden)]
enum PgDepend {
Table,
Objid,
Deptype,
Refclassid,
}
#[derive(DeriveIden)]
enum PgNamespace {
Table,
Oid,
Nspname,
}
fn query_pg_types<C>(db: &C) -> SelectStatement
where
C: ConnectionTrait,
{
Query::select()
.column(PgType::Typname)
.from(PgType::Table)
.left_join(
PgNamespace::Table,
Expr::col((PgNamespace::Table, PgNamespace::Oid))
.equals((PgType::Table, PgType::Typnamespace)),
)
.left_join(
PgDepend::Table,
Expr::col((PgDepend::Table, PgDepend::Objid))
.equals((PgType::Table, PgType::Oid))
.and(
Expr::col((PgDepend::Table, PgDepend::Refclassid))
.eq(Expr::cust("'pg_extension'::regclass::oid")),
)
.and(Expr::col((PgDepend::Table, PgDepend::Deptype)).eq(Expr::cust("'e'"))),
)
.and_where(get_current_schema(db).equals((PgNamespace::Table, PgNamespace::Nspname)))
.and_where(Expr::col((PgType::Table, PgType::Typelem)).eq(0))
.and_where(Expr::col((PgDepend::Table, PgDepend::Objid)).is_null())
.take()
}
trait QueryTable {
type Statement;
fn table_name(self, table_name: DynIden) -> Self::Statement;
}
impl QueryTable for SelectStatement {
type Statement = SelectStatement;
fn table_name(mut self, table_name: DynIden) -> SelectStatement {
self.from(table_name);
self
}
}
impl QueryTable for sea_query::TableCreateStatement {
type Statement = sea_query::TableCreateStatement;
fn table_name(mut self, table_name: DynIden) -> sea_query::TableCreateStatement {
self.table(table_name);
self
}
}
impl<A> QueryTable for sea_orm::Insert<A>
where
A: ActiveModelTrait,
{
type Statement = sea_orm::Insert<A>;
fn table_name(mut self, table_name: DynIden) -> sea_orm::Insert<A> {
sea_orm::QueryTrait::query(&mut self).into_table(table_name);
self
}
}
impl<E> QueryTable for sea_orm::DeleteMany<E>
where
E: EntityTrait,
{
type Statement = sea_orm::DeleteMany<E>;
fn table_name(mut self, table_name: DynIden) -> sea_orm::DeleteMany<E> {
sea_orm::QueryTrait::query(&mut self).from_table(table_name);
self
}
}

View file

@ -0,0 +1,612 @@
//! Adapted from <https://github.com/loco-rs/loco/blob/master/src/schema.rs>
//!
//! # Database Table Schema Helpers
//!
//! This module defines functions and helpers for creating database table
//! schemas using the `sea-orm` and `sea-query` libraries.
//!
//! # Example
//!
//! The following example shows how the user migration file should be and using
//! the schema helpers to create the Db fields.
//!
//! ```rust
//! use pagetop_seaorm::migration::*;
//!
//! pub struct Migration;
//!
//! #[async_trait::async_trait]
//! impl MigrationTrait for Migration {
//! async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
//! let table = table_auto(Users::Table)
//! .col(pk_auto(Users::Id))
//! .col(uuid(Users::Pid))
//! .col(string_uniq(Users::Email))
//! .col(string(Users::Password))
//! .col(string(Users::Name))
//! .col(string_null(Users::ResetToken))
//! .col(timestamp_null(Users::ResetSentAt))
//! .to_owned();
//! manager.create_table(table).await?;
//! Ok(())
//! }
//!
//! async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
//! manager
//! .drop_table(Table::drop().table(Users::Table).to_owned())
//! .await
//! }
//! }
//!
//! #[derive(DeriveIden)]
//! pub enum Users {
//! Table,
//! Id,
//! Pid,
//! Email,
//! Name,
//! Password,
//! ResetToken,
//! ResetSentAt,
//! }
//! ```
use sea_orm::sea_query::{
self, Alias, ColumnDef, ColumnType, Expr, Iden, IntoIden, PgInterval, Table,
TableCreateStatement,
};
#[derive(Iden)]
enum GeneralIds {
CreatedAt,
UpdatedAt,
}
/// Wrapping table schema creation.
pub fn table_auto<T: IntoIden + 'static>(name: T) -> TableCreateStatement {
timestamps(Table::create().table(name).if_not_exists().take())
}
/// Create a primary key column with auto-increment feature.
pub fn pk_auto<T: IntoIden>(name: T) -> ColumnDef {
integer(name).auto_increment().primary_key().take()
}
/// Create a UUID primary key
pub fn pk_uuid<T: IntoIden>(name: T) -> ColumnDef {
uuid(name).primary_key().take()
}
pub fn char_len<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).char_len(length).not_null().take()
}
pub fn char_len_null<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).char_len(length).null().take()
}
pub fn char_len_uniq<T: IntoIden>(col: T, length: u32) -> ColumnDef {
char_len(col, length).unique_key().take()
}
pub fn char<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).char().not_null().take()
}
pub fn char_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).char().null().take()
}
pub fn char_uniq<T: IntoIden>(col: T) -> ColumnDef {
char(col).unique_key().take()
}
pub fn string_len<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).string_len(length).not_null().take()
}
pub fn string_len_null<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).string_len(length).null().take()
}
pub fn string_len_uniq<T: IntoIden>(col: T, length: u32) -> ColumnDef {
string_len(col, length).unique_key().take()
}
pub fn string<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).string().not_null().take()
}
pub fn string_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).string().null().take()
}
pub fn string_uniq<T: IntoIden>(col: T) -> ColumnDef {
string(col).unique_key().take()
}
pub fn text<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).text().not_null().take()
}
pub fn text_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).text().null().take()
}
pub fn text_uniq<T: IntoIden>(col: T) -> ColumnDef {
text(col).unique_key().take()
}
pub fn tiny_integer<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).tiny_integer().not_null().take()
}
pub fn tiny_integer_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).tiny_integer().null().take()
}
pub fn tiny_integer_uniq<T: IntoIden>(col: T) -> ColumnDef {
tiny_integer(col).unique_key().take()
}
pub fn small_integer<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).small_integer().not_null().take()
}
pub fn small_integer_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).small_integer().null().take()
}
pub fn small_integer_uniq<T: IntoIden>(col: T) -> ColumnDef {
small_integer(col).unique_key().take()
}
pub fn integer<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).integer().not_null().take()
}
pub fn integer_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).integer().null().take()
}
pub fn integer_uniq<T: IntoIden>(col: T) -> ColumnDef {
integer(col).unique_key().take()
}
pub fn big_integer<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).big_integer().not_null().take()
}
pub fn big_integer_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).big_integer().null().take()
}
pub fn big_integer_uniq<T: IntoIden>(col: T) -> ColumnDef {
big_integer(col).unique_key().take()
}
pub fn tiny_unsigned<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).tiny_unsigned().not_null().take()
}
pub fn tiny_unsigned_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).tiny_unsigned().null().take()
}
pub fn tiny_unsigned_uniq<T: IntoIden>(col: T) -> ColumnDef {
tiny_unsigned(col).unique_key().take()
}
pub fn small_unsigned<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).small_unsigned().not_null().take()
}
pub fn small_unsigned_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).small_unsigned().null().take()
}
pub fn small_unsigned_uniq<T: IntoIden>(col: T) -> ColumnDef {
small_unsigned(col).unique_key().take()
}
pub fn unsigned<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).unsigned().not_null().take()
}
pub fn unsigned_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).unsigned().null().take()
}
pub fn unsigned_uniq<T: IntoIden>(col: T) -> ColumnDef {
unsigned(col).unique_key().take()
}
pub fn big_unsigned<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).big_unsigned().not_null().take()
}
pub fn big_unsigned_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).big_unsigned().null().take()
}
pub fn big_unsigned_uniq<T: IntoIden>(col: T) -> ColumnDef {
big_unsigned(col).unique_key().take()
}
pub fn float<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).float().not_null().take()
}
pub fn float_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).float().null().take()
}
pub fn float_uniq<T: IntoIden>(col: T) -> ColumnDef {
float(col).unique_key().take()
}
pub fn double<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).double().not_null().take()
}
pub fn double_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).double().null().take()
}
pub fn double_uniq<T: IntoIden>(col: T) -> ColumnDef {
double(col).unique_key().take()
}
pub fn decimal_len<T: IntoIden>(col: T, precision: u32, scale: u32) -> ColumnDef {
ColumnDef::new(col)
.decimal_len(precision, scale)
.not_null()
.take()
}
pub fn decimal_len_null<T: IntoIden>(col: T, precision: u32, scale: u32) -> ColumnDef {
ColumnDef::new(col)
.decimal_len(precision, scale)
.null()
.take()
}
pub fn decimal_len_uniq<T: IntoIden>(col: T, precision: u32, scale: u32) -> ColumnDef {
decimal_len(col, precision, scale).unique_key().take()
}
pub fn decimal<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).decimal().not_null().take()
}
pub fn decimal_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).decimal().null().take()
}
pub fn decimal_uniq<T: IntoIden>(col: T) -> ColumnDef {
decimal(col).unique_key().take()
}
pub fn date_time<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).date_time().not_null().take()
}
pub fn date_time_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).date_time().null().take()
}
pub fn date_time_uniq<T: IntoIden>(col: T) -> ColumnDef {
date_time(col).unique_key().take()
}
pub fn interval<T: IntoIden>(
col: T,
fields: Option<PgInterval>,
precision: Option<u32>,
) -> ColumnDef {
ColumnDef::new(col)
.interval(fields, precision)
.not_null()
.take()
}
pub fn interval_null<T: IntoIden>(
col: T,
fields: Option<PgInterval>,
precision: Option<u32>,
) -> ColumnDef {
ColumnDef::new(col)
.interval(fields, precision)
.null()
.take()
}
pub fn interval_uniq<T: IntoIden>(
col: T,
fields: Option<PgInterval>,
precision: Option<u32>,
) -> ColumnDef {
interval(col, fields, precision).unique_key().take()
}
pub fn timestamp<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).timestamp().not_null().take()
}
pub fn timestamp_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).timestamp().null().take()
}
pub fn timestamp_uniq<T: IntoIden>(col: T) -> ColumnDef {
timestamp(col).unique_key().take()
}
pub fn timestamp_with_time_zone<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col)
.timestamp_with_time_zone()
.not_null()
.take()
}
pub fn timestamp_with_time_zone_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).timestamp_with_time_zone().null().take()
}
pub fn timestamp_with_time_zone_uniq<T: IntoIden>(col: T) -> ColumnDef {
timestamp_with_time_zone(col).unique_key().take()
}
pub fn time<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).time().not_null().take()
}
pub fn time_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).time().null().take()
}
pub fn time_uniq<T: IntoIden>(col: T) -> ColumnDef {
time(col).unique_key().take()
}
pub fn date<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).date().not_null().take()
}
pub fn date_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).date().null().take()
}
pub fn date_uniq<T: IntoIden>(col: T) -> ColumnDef {
date(col).unique_key().take()
}
pub fn year<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).year().not_null().take()
}
pub fn year_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).year().null().take()
}
pub fn year_uniq<T: IntoIden>(col: T) -> ColumnDef {
year(col).unique_key().take()
}
pub fn binary_len<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).binary_len(length).not_null().take()
}
pub fn binary_len_null<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).binary_len(length).null().take()
}
pub fn binary_len_uniq<T: IntoIden>(col: T, length: u32) -> ColumnDef {
binary_len(col, length).unique_key().take()
}
pub fn binary<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).binary().not_null().take()
}
pub fn binary_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).binary().null().take()
}
pub fn binary_uniq<T: IntoIden>(col: T) -> ColumnDef {
binary(col).unique_key().take()
}
pub fn var_binary<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).var_binary(length).not_null().take()
}
pub fn var_binary_null<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).var_binary(length).null().take()
}
pub fn var_binary_uniq<T: IntoIden>(col: T, length: u32) -> ColumnDef {
var_binary(col, length).unique_key().take()
}
pub fn bit<T: IntoIden>(col: T, length: Option<u32>) -> ColumnDef {
ColumnDef::new(col).bit(length).not_null().take()
}
pub fn bit_null<T: IntoIden>(col: T, length: Option<u32>) -> ColumnDef {
ColumnDef::new(col).bit(length).null().take()
}
pub fn bit_uniq<T: IntoIden>(col: T, length: Option<u32>) -> ColumnDef {
bit(col, length).unique_key().take()
}
pub fn varbit<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).varbit(length).not_null().take()
}
pub fn varbit_null<T: IntoIden>(col: T, length: u32) -> ColumnDef {
ColumnDef::new(col).varbit(length).null().take()
}
pub fn varbit_uniq<T: IntoIden>(col: T, length: u32) -> ColumnDef {
varbit(col, length).unique_key().take()
}
pub fn blob<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).blob().not_null().take()
}
pub fn blob_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).blob().null().take()
}
pub fn blob_uniq<T: IntoIden>(col: T) -> ColumnDef {
blob(col).unique_key().take()
}
pub fn boolean<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).boolean().not_null().take()
}
pub fn boolean_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).boolean().null().take()
}
pub fn boolean_uniq<T: IntoIden>(col: T) -> ColumnDef {
boolean(col).unique_key().take()
}
pub fn money_len<T: IntoIden>(col: T, precision: u32, scale: u32) -> ColumnDef {
ColumnDef::new(col)
.money_len(precision, scale)
.not_null()
.take()
}
pub fn money_len_null<T: IntoIden>(col: T, precision: u32, scale: u32) -> ColumnDef {
ColumnDef::new(col)
.money_len(precision, scale)
.null()
.take()
}
pub fn money_len_uniq<T: IntoIden>(col: T, precision: u32, scale: u32) -> ColumnDef {
money_len(col, precision, scale).unique_key().take()
}
pub fn money<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).money().not_null().take()
}
pub fn money_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).money().null().take()
}
pub fn money_uniq<T: IntoIden>(col: T) -> ColumnDef {
money(col).unique_key().take()
}
pub fn json<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).json().not_null().take()
}
pub fn json_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).json().null().take()
}
pub fn json_uniq<T: IntoIden>(col: T) -> ColumnDef {
json(col).unique_key().take()
}
pub fn json_binary<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).json_binary().not_null().take()
}
pub fn json_binary_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).json_binary().null().take()
}
pub fn json_binary_uniq<T: IntoIden>(col: T) -> ColumnDef {
json_binary(col).unique_key().take()
}
pub fn uuid<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).uuid().not_null().take()
}
pub fn uuid_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).uuid().null().take()
}
pub fn uuid_uniq<T: IntoIden>(col: T) -> ColumnDef {
uuid(col).unique_key().take()
}
pub fn custom<T: IntoIden, N: IntoIden>(col: T, name: N) -> ColumnDef {
ColumnDef::new(col).custom(name).not_null().take()
}
pub fn custom_null<T: IntoIden, N: IntoIden>(col: T, name: N) -> ColumnDef {
ColumnDef::new(col).custom(name).null().take()
}
pub fn enumeration<T, N, S, V>(col: T, name: N, variants: V) -> ColumnDef
where
T: IntoIden,
N: IntoIden,
S: IntoIden,
V: IntoIterator<Item = S>,
{
ColumnDef::new(col)
.enumeration(name, variants)
.not_null()
.take()
}
pub fn enumeration_null<T, N, S, V>(col: T, name: N, variants: V) -> ColumnDef
where
T: IntoIden,
N: IntoIden,
S: IntoIden,
V: IntoIterator<Item = S>,
{
ColumnDef::new(col)
.enumeration(name, variants)
.null()
.take()
}
pub fn enumeration_uniq<T, N, S, V>(col: T, name: N, variants: V) -> ColumnDef
where
T: IntoIden,
N: IntoIden,
S: IntoIden,
V: IntoIterator<Item = S>,
{
enumeration(col, name, variants).unique_key().take()
}
pub fn array<T: IntoIden>(col: T, elem_type: ColumnType) -> ColumnDef {
ColumnDef::new(col).array(elem_type).not_null().take()
}
pub fn array_null<T: IntoIden>(col: T, elem_type: ColumnType) -> ColumnDef {
ColumnDef::new(col).array(elem_type).null().take()
}
pub fn array_uniq<T: IntoIden>(col: T, elem_type: ColumnType) -> ColumnDef {
array(col, elem_type).unique_key().take()
}
/// Add timestamp columns (`CreatedAt` and `UpdatedAt`) to an existing table.
pub fn timestamps(t: TableCreateStatement) -> TableCreateStatement {
let mut t = t;
t.col(timestamp(GeneralIds::CreatedAt).default(Expr::current_timestamp()))
.col(timestamp(GeneralIds::UpdatedAt).default(Expr::current_timestamp()))
.take()
}
/// Create an Alias.
pub fn name<T: Into<String>>(name: T) -> Alias {
Alias::new(name)
}

View file

@ -0,0 +1,15 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
// One should override the name of migration table via `MigratorTrait::migration_table_name` method
#[sea_orm(table_name = "seaql_migrations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub version: String,
pub applied_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,7 +1,6 @@
[package]
name = "pagetop-build"
version = "0.3.2"
edition = "2021"
description = """
Prepara un conjunto de archivos estáticos o archivos SCSS compilados para ser incluidos en el
@ -12,9 +11,10 @@ keywords = ["pagetop", "build", "assets", "resources", "static"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
grass = "0.13"
grass.workspace = true
pagetop-statics.workspace = true

View file

@ -94,7 +94,7 @@ No hay ningún problema en generar más de un conjunto de recursos para cada pro
usen nombres diferentes.
Normalmente no habrá que acceder a estos módulos; sólo declarar el nombre del conjunto de recursos
en [`static_files_service!`](https://docs.rs/pagetop/latest/pagetop/macro.static_files_service.html)
en [`serve_static_files!`](https://docs.rs/pagetop/latest/pagetop/macro.serve_static_files.html)
para configurar un servicio web que sirva los archivos desde la ruta indicada. Por ejemplo:
```rust,ignore
@ -105,7 +105,7 @@ pub struct MyExtension;
impl Extension for MyExtension {
// 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");
serve_static_files!(scfg, guides => "/ruta/a/guides");
}
}
```

View file

@ -95,7 +95,7 @@ No hay ningún problema en generar más de un conjunto de recursos para cada pro
usen nombres diferentes.
Normalmente no habrá que acceder a estos módulos; sólo declarar el nombre del conjunto de recursos
en [`static_files_service!`](https://docs.rs/pagetop/latest/pagetop/macro.static_files_service.html)
en [`serve_static_files!`](https://docs.rs/pagetop/latest/pagetop/macro.serve_static_files.html)
para configurar un servicio web que sirva los archivos desde la ruta indicada. Por ejemplo:
```rust,ignore
@ -104,9 +104,10 @@ use pagetop::prelude::*;
pub struct MyExtension;
impl Extension for MyExtension {
/// 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");
/// Registra los recursos de `guides` en el router bajo `/ruta/a/guides`.
fn configure_router(&self, mut router: Router) -> Router {
serve_static_files!(router, [guides] => "/ruta/a/guides");
router
}
}
```
@ -116,10 +117,10 @@ impl Extension for MyExtension {
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
)]
use grass::{from_path, Options, OutputStyle};
use pagetop_statics::{resource_dir, ResourceDir};
use grass::{Options, OutputStyle, from_path};
use pagetop_statics::{ResourceDir, resource_dir};
use std::fs::{create_dir_all, remove_dir_all, File};
use std::fs::{File, create_dir_all, remove_dir_all};
use std::io::Write;
use std::path::Path;
@ -202,9 +203,11 @@ impl StaticFilesBundle {
where
P: AsRef<Path>,
{
// Crea un directorio temporal para el archivo CSS.
// Crea un directorio temporal único para el archivo CSS (basado en su nombre, para que
// varias llamadas a from_scss en el mismo build.rs no se pisen).
let out_dir = std::env::var("OUT_DIR").unwrap();
let temp_dir = Path::new(&out_dir).join("from_scss_files");
let safe_name = target_name.replace(['.', '-'], "_");
let temp_dir = Path::new(&out_dir).join(format!("from_scss_{safe_name}"));
// Limpia el directorio temporal de ejecuciones previas, si existe.
if temp_dir.exists() {

View file

@ -1,7 +1,6 @@
[package]
name = "pagetop-macros"
version = "0.3.0"
edition = "2021"
description = """
Una colección de macros que mejoran la experiencia de desarrollo con PageTop.
@ -11,6 +10,7 @@ keywords = ["pagetop", "macros", "proc-macros", "codegen"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
@ -18,7 +18,7 @@ authors.workspace = true
proc-macro = true
[dependencies]
proc-macro2 = "1.0"
proc-macro2-diagnostics = { version = "0.10", default-features = false }
quote = "1.0"
syn = { version = "2.0", features = ["full", "extra-traits"] }
proc-macro2.workspace = true
proc-macro2-diagnostics.workspace = true
quote.workspace = true
syn.workspace = true

View file

@ -39,7 +39,7 @@ mod smart_default;
use proc_macro::TokenStream;
use quote::{quote, quote_spanned};
use syn::{parse_macro_input, spanned::Spanned, DeriveInput};
use syn::{DeriveInput, parse_macro_input, spanned::Spanned};
/// Macro para escribir plantillas HTML (basada en [Maud](https://docs.rs/maud)).
#[proc_macro]
@ -164,7 +164,7 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// 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};
use syn::{FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type, parse2};
let ts: proc_macro2::TokenStream = item.clone().into();
@ -451,7 +451,7 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
#[proc_macro_attribute]
pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
let mut output: TokenStream = (quote! {
#[::pagetop::service::rt::main(system = "::pagetop::service::rt::System")]
#[::tokio::main]
})
.into();
@ -461,6 +461,9 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
/// Define funciones de prueba asíncronas para usar con PageTop.
///
/// Usa el *runtime* multi-hilo de **Tokio**, igual que [`#[pagetop::main]`](macro@main), para
/// garantizar compatibilidad con extensiones que ejecutan código asíncrono de forma síncrona.
///
/// # Ejemplo
///
/// ```rust,ignore
@ -472,7 +475,7 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
#[proc_macro_attribute]
pub fn test(_: TokenStream, item: TokenStream) -> TokenStream {
let mut output: TokenStream = (quote! {
#[::pagetop::service::rt::test(system = "::pagetop::service::rt::System")]
#[::tokio::test(flavor = "multi_thread")]
})
.into();

View file

@ -4,7 +4,7 @@ use proc_macro2::TokenStream;
use proc_macro2_diagnostics::{Diagnostic, SpanDiagnosticExt};
use quote::ToTokens;
use syn::{
braced, bracketed,
Error, Expr, Ident, Lit, LitBool, LitInt, LitStr, Local, Pat, Stmt, braced, bracketed,
ext::IdentExt,
parenthesized,
parse::{Lookahead1, Parse, ParseStream},
@ -14,7 +14,6 @@ use syn::{
At, Brace, Bracket, Colon, Comma, Dot, Else, Eq, FatArrow, For, If, In, Let, Match, Minus,
Paren, Pound, Question, Semi, Slash, While,
},
Error, Expr, Ident, Lit, LitBool, LitInt, LitStr, Local, Pat, Stmt,
};
#[derive(Debug, Clone)]
@ -213,6 +212,7 @@ impl DiagnosticParse for Element {
|| input.peek(Lit)
|| input.peek(Dot)
|| input.peek(Pound)
|| input.peek(Paren)
{
let attr = input.diagnostic_parse(diagnostics)?;
@ -347,6 +347,10 @@ pub enum Attribute {
name: HtmlName,
attr_type: AttributeType,
},
Splice {
paren_token: Paren,
expr: Expr,
},
}
impl DiagnosticParse for Attribute {
@ -375,6 +379,12 @@ impl DiagnosticParse for Attribute {
pound_token: input.parse()?,
name: input.diagnostic_parse(diagnostics)?,
})
} else if lookahead.peek(Paren) {
let content;
Ok(Self::Splice {
paren_token: parenthesized!(content in input),
expr: content.parse()?,
})
} else {
let name = input.diagnostic_parse::<HtmlName>(diagnostics)?;
@ -425,6 +435,11 @@ impl ToTokens for Attribute {
name.to_tokens(tokens);
attr_type.to_tokens(tokens);
}
Self::Splice { paren_token, expr } => {
paren_token.surround(tokens, |tokens| {
expr.to_tokens(tokens);
});
}
}
}
}
@ -1079,7 +1094,7 @@ impl<E: ToTokens> ToTokens for MatchArm<E> {
pub trait DiagnosticParse: Sized {
fn diagnostic_parse(input: ParseStream, diagnostics: &mut Vec<Diagnostic>)
-> syn::Result<Self>;
-> syn::Result<Self>;
}
impl<T: DiagnosticParse> DiagnosticParse for Box<T> {

View file

@ -1,6 +1,6 @@
use proc_macro2::{Ident, Span, TokenStream};
use quote::{quote, ToTokens};
use syn::{parse_quote, token::Brace, Expr, Local};
use quote::{ToTokens, quote};
use syn::{Expr, Local, parse_quote, token::Brace};
use crate::maud::{ast::*, escape};
@ -139,7 +139,7 @@ impl Generator {
}
fn attrs(&self, attrs: Vec<Attribute>, build: &mut Builder) {
let (classes, id, named_attrs) = split_attrs(attrs);
let (classes, id, named_attrs, spliced) = split_attrs(attrs);
if !classes.is_empty() {
let mut toggle_class_exprs = vec![];
@ -184,6 +184,9 @@ impl Generator {
for (name, attr_type) in named_attrs {
self.attr(name, attr_type, build);
}
for expr in spliced {
self.splice(expr, build);
}
}
fn control_flow<E: Into<Element>>(&self, control_flow: ControlFlow<E>, build: &mut Builder) {
@ -316,10 +319,12 @@ fn split_attrs(
Vec<(HtmlNameOrMarkup, Option<Expr>)>,
Option<HtmlNameOrMarkup>,
Vec<(HtmlName, AttributeType)>,
Vec<Expr>,
) {
let mut classes = vec![];
let mut id = None;
let mut named_attrs = vec![];
let mut spliced = vec![];
for attr in attrs {
match attr {
@ -328,10 +333,11 @@ fn split_attrs(
}
Attribute::Id { name, .. } => id = Some(name),
Attribute::Named { name, attr_type } => named_attrs.push((name, attr_type)),
Attribute::Splice { expr, .. } => spliced.push(expr),
}
}
(classes, id, named_attrs)
(classes, id, named_attrs, spliced)
}
////////////////////////////////////////////////////////

View file

@ -1,9 +1,9 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::DeriveInput;
use syn::parse::Error;
use syn::spanned::Spanned;
use syn::DeriveInput;
use crate::smart_default::default_attr::{ConversionStrategy, DefaultAttr};
use crate::smart_default::util::find_only;
@ -68,7 +68,7 @@ fn default_body_tt(body: &syn::Fields) -> Result<(TokenStream, String), Error> {
let mut doc = String::new();
use std::fmt::Write;
let body_tt = match body {
syn::Fields::Named(ref fields) => {
syn::Fields::Named(fields) => {
doc.push_str(" {");
let result = {
let field_assignments = fields
@ -101,7 +101,7 @@ fn default_body_tt(body: &syn::Fields) -> Result<(TokenStream, String), Error> {
doc.push('}');
result
}
syn::Fields::Unnamed(ref fields) => {
syn::Fields::Unnamed(fields) => {
doc.push('(');
let result = {
let field_assignments = fields

View file

@ -1,6 +1,6 @@
use proc_macro2::TokenStream;
use quote::ToTokens;
use syn::{parse::Error, MetaNameValue};
use syn::{MetaNameValue, parse::Error};
use crate::smart_default::util::find_only;

View file

@ -1,7 +1,6 @@
[package]
name = "pagetop-minimal"
version = "0.1.0"
edition = "2021"
description = """
Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones
@ -12,10 +11,11 @@ keywords = ["pagetop", "build", "assets", "resources", "static"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
concat-string = "1.0"
indoc = "2.0"
pastey = "0.2"
concat-string.workspace = true
indoc.workspace = true
pastey.workspace = true

View file

@ -1,7 +1,6 @@
[package]
name = "pagetop-statics"
version = "0.1.3"
edition = "2021"
description = """
Librería para automatizar la recopilación de recursos estáticos en PageTop.
@ -11,6 +10,7 @@ keywords = ["pagetop", "build", "static", "resources", "file"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
@ -19,15 +19,11 @@ default = ["change-detection"]
sort = []
[dependencies]
change-detection = { version = "1.2", optional = true }
mime_guess = "2.0"
path-slash = "0.2"
actix-web.workspace = true
derive_more = "0.99.17"
futures-util = { version = "0.3", default-features = false, features = ["std"] }
change-detection = { workspace = true, optional = true }
mime_guess.workspace = true
path-slash.workspace = true
[build-dependencies]
change-detection = { version = "1.2", optional = true }
mime_guess = "2.0"
path-slash = "0.2"
change-detection = { workspace = true, optional = true }
mime_guess.workspace = true
path-slash.workspace = true

View file

@ -11,30 +11,25 @@
</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
## 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
[actix-web-static-files](https://crates.io/crates/actix_web_static_files) (versión
[4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por
[Alexander Korolev](https://crates.io/users/kilork).
Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar
`static-files` manualmente como dependencia en su `Cargo.toml`.
Para ello, adapta el código de [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)) desarrollado por
[Alexander Korolev](https://crates.io/users/kilork), bajo licencia MIT/Apache 2.0. La implementación
se integra en PageTop para evitar que cada proyecto tenga que declarar `static-files` manualmente
como dependencia en su `Cargo.toml`.
## 🚧 Advertencia

View file

@ -13,7 +13,7 @@ use resource_dir::resource_dir;
mod sets {
include!("src/sets.rs");
}
use sets::{generate_resources_sets, SplitByCount};
use sets::{SplitByCount, generate_resources_sets};
use std::{env, path::Path};

View file

@ -26,14 +26,11 @@ compilación.
## 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
[actix-web-static-files](https://crates.io/crates/actix_web_static_files) (versión
[4.0.1](https://github.com/kilork/actix-web-static-files/tree/v4.0.1)), desarrollados ambos por
[Alexander Korolev](https://crates.io/users/kilork).
Estas implementaciones se integran en PageTop para evitar que cada proyecto tenga que declarar
`static-files` manualmente como dependencia en su `Cargo.toml`.
Para ello, adapta el código de [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)) desarrollado por
[Alexander Korolev](https://crates.io/users/kilork), bajo licencia MIT/Apache 2.0. La implementación
se integra en PageTop para evitar que cada proyecto tenga que declarar `static-files` manualmente
como dependencia en su `Cargo.toml`.
*/
#![doc(test(no_crate_inject))]
@ -44,13 +41,10 @@ Estas implementaciones se integran en PageTop para evitar que cada proyecto teng
/// Resource definition and single module based generation.
pub mod resource;
pub use resource::Resource as StaticResource;
pub use resource::Resource as StaticFile;
mod resource_dir;
pub use resource_dir::{resource_dir, ResourceDir};
mod resource_files;
pub use resource_files::{ResourceFiles, UriSegmentError};
pub use resource_dir::{ResourceDir, resource_dir};
/// Support for module based generations. Use it for large data sets (more than 128 Mb).
pub mod sets;

View file

@ -93,9 +93,9 @@ pub fn generate_resources<P: AsRef<Path>, G: AsRef<Path>>(
/// ```rust
/// use std::collections::HashMap;
///
/// use pagetop_statics::StaticResource;
/// use pagetop_statics::StaticFile;
///
/// fn generate_mapping() -> HashMap<&'static str, StaticResource> {
/// fn generate_mapping() -> HashMap<&'static str, StaticFile> {
/// include!(concat!(env!("OUT_DIR"), "/generated_mapping.rs"))
/// }
///
@ -221,7 +221,7 @@ pub(crate) fn generate_function_header<F: Write>(
) -> io::Result<()> {
writeln!(
f,
"#[allow(clippy::unreadable_literal)] pub fn {fn_name}() -> ::std::collections::HashMap<&'static str, ::{crate_name}::StaticResource> {{",
"#[allow(clippy::unreadable_literal)] pub fn {fn_name}() -> ::std::collections::HashMap<&'static str, ::{crate_name}::StaticFile> {{",
)
}

View file

@ -1,4 +1,4 @@
use super::sets::{generate_resources_sets, SplitByCount};
use super::sets::{SplitByCount, generate_resources_sets};
use std::{
env, io,
path::{Path, PathBuf},

View file

@ -1,396 +0,0 @@
use super::resource::Resource;
use actix_web::{
dev::{
always_ready, AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory,
ServiceRequest, ServiceResponse,
},
error::Error,
guard::{Guard, GuardContext},
http::{
header::{self, ContentType},
Method, StatusCode,
},
HttpMessage, HttpRequest, HttpResponse, ResponseError,
};
use derive_more::{Deref, Display, Error};
use futures_util::future::{ok, FutureExt, LocalBoxFuture, Ready};
use std::{collections::HashMap, ops::Deref, rc::Rc};
/// Static resource files handling
///
/// `ResourceFiles` service must be registered with `App::service` method.
///
/// ```rust
/// use std::collections::HashMap;
///
/// use actix_web::App;
///
/// fn main() {
/// // serve root directory with default options:
/// // - resolve index.html
/// let files: HashMap<&'static str, pagetop_statics::StaticResource> = HashMap::new();
/// let app = App::new()
/// .service(pagetop_statics::ResourceFiles::new("/", files));
/// // or subpath with additional option to not resolve index.html
/// let files: HashMap<&'static str, pagetop_statics::StaticResource> = HashMap::new();
/// let app = App::new()
/// .service(pagetop_statics::ResourceFiles::new("/imgs", files)
/// .do_not_resolve_defaults());
/// }
/// ```
#[allow(clippy::needless_doctest_main)]
pub struct ResourceFiles {
not_resolve_defaults: bool,
use_guard: bool,
not_found_resolves_to: Option<String>,
inner: Rc<ResourceFilesInner>,
}
pub struct ResourceFilesInner {
path: String,
files: HashMap<&'static str, Resource>,
}
const INDEX_HTML: &str = "index.html";
impl ResourceFiles {
pub fn new(path: &str, files: HashMap<&'static str, Resource>) -> Self {
let inner = ResourceFilesInner {
path: path.into(),
files,
};
Self {
inner: Rc::new(inner),
not_resolve_defaults: false,
not_found_resolves_to: None,
use_guard: false,
}
}
/// By default trying to resolve '.../' to '.../index.html' if it exists.
/// Turn off this resolution by calling this function.
pub fn do_not_resolve_defaults(mut self) -> Self {
self.not_resolve_defaults = true;
self
}
/// Resolves not found references to this path.
///
/// This can be useful for angular-like applications.
pub fn resolve_not_found_to<S: ToString>(mut self, path: S) -> Self {
self.not_found_resolves_to = Some(path.to_string());
self
}
/// Resolves not found references to root path.
///
/// This can be useful for angular-like applications.
pub fn resolve_not_found_to_root(self) -> Self {
self.resolve_not_found_to(INDEX_HTML)
}
/// If this is called, we will use an [actix_web::guard::Guard] to check if this request should be handled.
/// If set to true, we skip using the handler for files that haven't been found, instead of sending 404s.
/// Would be ignored, if `resolve_not_found_to` or `resolve_not_found_to_root` is used.
///
/// Can be useful if you want to share files on a (sub)path that's also used by a different route handler.
pub fn skip_handler_when_not_found(mut self) -> Self {
self.use_guard = true;
self
}
fn select_guard(&self) -> Box<dyn Guard> {
if self.not_resolve_defaults {
Box::new(NotResolveDefaultsGuard::from(self))
} else {
Box::new(ResolveDefaultsGuard::from(self))
}
}
}
impl Deref for ResourceFiles {
type Target = ResourceFilesInner;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
struct NotResolveDefaultsGuard {
inner: Rc<ResourceFilesInner>,
}
impl Guard for NotResolveDefaultsGuard {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
self.inner
.files
.contains_key(ctx.head().uri.path().trim_start_matches('/'))
}
}
impl From<&ResourceFiles> for NotResolveDefaultsGuard {
fn from(files: &ResourceFiles) -> Self {
Self {
inner: files.inner.clone(),
}
}
}
struct ResolveDefaultsGuard {
inner: Rc<ResourceFilesInner>,
}
impl Guard for ResolveDefaultsGuard {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
let path = ctx.head().uri.path().trim_start_matches('/');
self.inner.files.contains_key(path)
|| ((path.is_empty() || path.ends_with('/'))
&& self
.inner
.files
.contains_key((path.to_string() + INDEX_HTML).as_str()))
}
}
impl From<&ResourceFiles> for ResolveDefaultsGuard {
fn from(files: &ResourceFiles) -> Self {
Self {
inner: files.inner.clone(),
}
}
}
impl HttpServiceFactory for ResourceFiles {
fn register(self, config: &mut AppService) {
let prefix = self.path.trim_start_matches('/');
let rdef = if config.is_root() {
ResourceDef::root_prefix(prefix)
} else {
ResourceDef::prefix(prefix)
};
let guards = if self.use_guard && self.not_found_resolves_to.is_none() {
Some(vec![self.select_guard()])
} else {
None
};
config.register_service(rdef, guards, self, None);
}
}
impl ServiceFactory<ServiceRequest> for ResourceFiles {
type Config = ();
type Response = ServiceResponse;
type Error = Error;
type Service = ResourceFilesService;
type InitError = ();
type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
fn new_service(&self, _: ()) -> Self::Future {
ok(ResourceFilesService {
resolve_defaults: !self.not_resolve_defaults,
not_found_resolves_to: self.not_found_resolves_to.clone(),
inner: self.inner.clone(),
})
.boxed_local()
}
}
#[derive(Deref)]
pub struct ResourceFilesService {
resolve_defaults: bool,
not_found_resolves_to: Option<String>,
#[deref]
inner: Rc<ResourceFilesInner>,
}
impl Service<ServiceRequest> for ResourceFilesService {
type Response = ServiceResponse;
type Error = Error;
type Future = Ready<Result<Self::Response, Self::Error>>;
always_ready!();
fn call(&self, req: ServiceRequest) -> Self::Future {
match *req.method() {
Method::HEAD | Method::GET => (),
_ => {
return ok(ServiceResponse::new(
req.into_parts().0,
HttpResponse::MethodNotAllowed()
.insert_header(ContentType::plaintext())
.insert_header((header::ALLOW, "GET, HEAD"))
.body("This resource only supports GET and HEAD."),
));
}
}
let req_path = req.match_info().unprocessed();
let mut item = self.files.get(req_path);
if item.is_none()
&& self.resolve_defaults
&& (req_path.is_empty() || req_path.ends_with('/'))
{
let index_req_path = req_path.to_string() + INDEX_HTML;
item = self.files.get(index_req_path.trim_start_matches('/'));
}
let (req, response) = if item.is_some() {
let (req, _) = req.into_parts();
let response = respond_to(&req, item);
(req, response)
} else {
let real_path = match get_pathbuf(req_path) {
Ok(item) => item,
Err(e) => return ok(req.error_response(e)),
};
let (req, _) = req.into_parts();
let mut item = self.files.get(real_path.as_str());
if item.is_none() && self.not_found_resolves_to.is_some() {
let not_found_path = self.not_found_resolves_to.as_ref().unwrap();
item = self.files.get(not_found_path.as_str());
}
let response = respond_to(&req, item);
(req, response)
};
ok(ServiceResponse::new(req, response))
}
}
fn respond_to(req: &HttpRequest, item: Option<&Resource>) -> HttpResponse {
if let Some(file) = item {
let etag = Some(header::EntityTag::new_strong(format!(
"{:x}:{:x}",
file.data.len(),
file.modified
)));
let precondition_failed = !any_match(etag.as_ref(), req);
let not_modified = !none_match(etag.as_ref(), req);
let mut resp = HttpResponse::build(StatusCode::OK);
resp.insert_header((header::CONTENT_TYPE, file.mime_type));
if let Some(etag) = etag {
resp.insert_header(header::ETag(etag));
}
if precondition_failed {
return resp.status(StatusCode::PRECONDITION_FAILED).finish();
} else if not_modified {
return resp.status(StatusCode::NOT_MODIFIED).finish();
}
resp.body(file.data)
} else {
HttpResponse::NotFound().body("Not found")
}
}
/// Returns true if `req` has no `If-Match` header or one which matches `etag`.
fn any_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
match req.get_header::<header::IfMatch>() {
None | Some(header::IfMatch::Any) => true,
Some(header::IfMatch::Items(ref items)) => {
if let Some(some_etag) = etag {
for item in items {
if item.strong_eq(some_etag) {
return true;
}
}
}
false
}
}
}
/// Returns true if `req` doesn't have an `If-None-Match` header matching `req`.
fn none_match(etag: Option<&header::EntityTag>, req: &HttpRequest) -> bool {
match req.get_header::<header::IfNoneMatch>() {
Some(header::IfNoneMatch::Any) => false,
Some(header::IfNoneMatch::Items(ref items)) => {
if let Some(some_etag) = etag {
for item in items {
if item.weak_eq(some_etag) {
return false;
}
}
}
true
}
None => true,
}
}
/// Error type representing invalid characters in a URI path segment.
///
/// This enum is used to report specific formatting errors in individual segments of a URI path,
/// such as starting, ending, or containing disallowed characters. Each variant wraps the offending
/// character that caused the error.
#[derive(Debug, PartialEq, Display, Error)]
pub enum UriSegmentError {
/// The segment started with the wrapped invalid character.
#[display(fmt = "The segment started with the wrapped invalid character")]
BadStart(#[error(not(source))] char),
/// The segment contained the wrapped invalid character.
#[display(fmt = "The segment contained the wrapped invalid character")]
BadChar(#[error(not(source))] char),
/// The segment ended with the wrapped invalid character.
#[display(fmt = "The segment ended with the wrapped invalid character")]
BadEnd(#[error(not(source))] char),
}
#[cfg(test)]
mod tests_error_impl {
use super::*;
fn assert_send_and_sync<T: Send + Sync + 'static>() {}
#[test]
fn test_error_impl() {
// ensure backwards compatibility when migrating away from failure
assert_send_and_sync::<UriSegmentError>();
}
}
/// Return `BadRequest` for `UriSegmentError`
impl ResponseError for UriSegmentError {
fn error_response(&self) -> HttpResponse {
HttpResponse::new(StatusCode::BAD_REQUEST)
}
}
fn get_pathbuf(path: &str) -> Result<String, UriSegmentError> {
let mut buf = Vec::new();
for segment in path.split('/') {
if segment == ".." {
buf.pop();
} else if segment.starts_with('.') {
return Err(UriSegmentError::BadStart('.'));
} else if segment.starts_with('*') {
return Err(UriSegmentError::BadStart('*'));
} else if segment.ends_with(':') {
return Err(UriSegmentError::BadEnd(':'));
} else if segment.ends_with('>') {
return Err(UriSegmentError::BadEnd('>'));
} else if segment.ends_with('<') {
return Err(UriSegmentError::BadEnd('<'));
} else if segment.is_empty() {
continue;
} else if cfg!(windows) && segment.contains('\\') {
return Err(UriSegmentError::BadChar('\\'));
} else {
buf.push(segment)
}
}
Ok(buf.join("/"))
}

View file

@ -5,8 +5,8 @@ use std::{
};
use super::resource::{
collect_resources, generate_function_end, generate_function_header, generate_resource_insert,
generate_uses, generate_variable_header, generate_variable_return, DEFAULT_VARIABLE_NAME,
DEFAULT_VARIABLE_NAME, collect_resources, generate_function_end, generate_function_header,
generate_resource_insert, generate_uses, generate_variable_header, generate_variable_return,
};
/// Defines the split strategie.
@ -116,7 +116,7 @@ where
writeln!(
module_file,
"
use ::{crate_name}::StaticResource;
use ::{crate_name}::StaticFile;
use ::std::collections::HashMap;"
)?;
@ -177,7 +177,7 @@ fn create_set_module_file(module_dir: &Path, module_index: usize) -> io::Result<
"#[allow(clippy::wildcard_imports)]
use super::*;
#[allow(clippy::unreadable_literal)]
pub(crate) fn generate({DEFAULT_VARIABLE_NAME}: &mut HashMap<&'static str, StaticResource>) {{",
pub(crate) fn generate({DEFAULT_VARIABLE_NAME}: &mut HashMap<&'static str, StaticFile>) {{",
)?;
Ok(set_module)

8
rustfmt.toml Normal file
View file

@ -0,0 +1,8 @@
edition = "2024"
max_width = 100
hard_tabs = false
tab_spaces = 4
newline_style = "Auto"
# Heurísticas por defecto: evitar reformateo agresivo
use_small_heuristics = "Default"

View file

@ -5,25 +5,21 @@ 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, service, trace, PAGETOP_VERSION};
use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle};
use actix_session::storage::CookieSessionStore;
use actix_session::SessionMiddleware;
use substring::Substring;
use crate::response::page::ErrorPage;
use crate::web::{HttpRequest, Router};
use crate::{PAGETOP_VERSION, global, trace};
use std::future::Future;
use std::io::Error;
use std::sync::LazyLock;
/// Punto de entrada de una aplicación PageTop.
///
/// No almacena datos, **encapsula** el inicio completo de configuración y puesta en marcha. Para
/// instanciarla se puede usar [`new()`](Application::new) o [`prepare()`](Application::prepare).
/// Después sólo hay que llamar a [`run()`](Application::run) para ejecutar la aplicación (o a
/// [`test()`](Application::test) si se está preparando un entorno de pruebas).
/// No almacena datos, **encapsula** el inicio completo de la configuración y puesta en marcha de la
/// aplicación. Para instanciarla se puede usar [`new()`](Application::new) o
/// [`prepare()`](Application::prepare). Después sólo hay que llamar a [`run()`](Application::run)
/// para ejecutar la aplicación (o a [`test()`](Application::test) si se está preparando un entorno
/// de pruebas).
pub struct Application;
impl Default for Application {
@ -33,24 +29,24 @@ impl Default for Application {
}
impl Application {
/// Crea una instancia de la aplicación.
/// Crea una instancia mínima de la aplicación, sin extensión raíz.
///
/// Útil para verificar que el servidor arranca correctamente. Para una aplicación real, usa
/// [`prepare()`](Application::prepare) con una extensión raíz.
pub fn new() -> Self {
Self::internal_prepare(None)
}
/// Prepara una instancia de la aplicación a partir de una extensión raíz.
///
/// Esa extensión suele declarar:
///
/// - Sus propias dependencias (que se habilitarán automáticamente).
/// - Una lista de extensiones que deben deshabilitarse si estuvieran activadas.
///
/// Esto simplifica el arranque en escenarios complejos.
/// Las dependencias se habilitan en orden: primero las que no dependen de ninguna otra, luego
/// las que dependen de extensiones ya habilitadas, y así sucesivamente hasta dejar habilitada
/// la extensión raíz.
pub fn prepare(root_extension: ExtensionRef) -> Self {
Self::internal_prepare(Some(root_extension))
}
/// Método interno para preparar la aplicación, opcionalmente con una extensión.
// Secuencia de arranque común a new() y prepare().
fn internal_prepare(root_extension: Option<ExtensionRef>) -> Self {
// Al arrancar muestra una cabecera para la aplicación.
Self::show_banner();
@ -73,10 +69,10 @@ impl Application {
Self
}
/// Muestra una cabecera para la aplicación basada en la configuración.
// Muestra la cabecera de arranque si está habilitada en la configuración.
fn show_banner() {
use colored::Colorize;
use terminal_size::{terminal_size, Width};
use terminal_size::{Width, terminal_size};
if global::SETTINGS.app.startup_banner != global::StartupBanner::Off {
// Nombre de la aplicación, ajustado al ancho del terminal si es necesario.
@ -85,8 +81,8 @@ impl Application {
if let Some((Width(term_width), _)) = terminal_size() {
if term_width >= 80 {
let maxlen: usize = ((term_width / 10) - 2).into();
let mut app = app_name.substring(0, maxlen).to_string();
if app_name.len() > maxlen {
let mut app: String = app_name.chars().take(maxlen).collect();
if app_name.chars().count() > maxlen {
app = format!("{app}...");
}
if let Some(ff) = figfont::FIGFONT.convert(&app) {
@ -103,7 +99,7 @@ impl Application {
// Descripción de la aplicación.
if !global::SETTINGS.app.description.is_empty() {
println!("{}", global::SETTINGS.app.description.cyan());
};
}
// Versión de PageTop.
println!(
@ -114,72 +110,55 @@ impl Application {
}
}
// Construye el router con las rutas de todas las extensiones habilitadas.
fn build_router() -> Router {
let router = extension::all::configure_routes(Router::new());
router.fallback(route_not_found)
}
/// Arranca el servidor web de la aplicación.
///
/// Devuelve [`std::io::Error`] si el *socket* no puede enlazarse (por puerto en uso, permisos,
/// etc.).
pub fn run(self) -> Result<service::Server, Error> {
// Genera clave secreta para firmar y verificar cookies.
let secret_key = service::cookie::Key::generate();
// Prepara el servidor web.
Ok(service::HttpServer::new(move || {
Self::service_app()
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
.session_lifecycle(match global::SETTINGS.server.session_lifetime {
0 => SessionLifecycle::BrowserSession(BrowserSession::default()),
_ => SessionLifecycle::PersistentSession(
PersistentSession::default().session_ttl(
service::cookie::time::Duration::seconds(
global::SETTINGS.server.session_lifetime,
),
),
),
})
.build(),
)
})
.bind(format!(
/// Enlaza el puerto del servidor web de forma síncrona (puede fallar con [`std::io::Error`] si
/// el puerto ya está en uso o el proceso carece de permisos) y devuelve un [`Future`] que
/// ejecuta el bucle de atención de peticiones. El patrón habitual es:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// struct MyApp;
///
/// impl Extension for MyApp {}
///
/// #[pagetop::main]
/// async fn main() -> std::io::Result<()> {
/// Application::prepare(&MyApp).run()?.await
/// }
/// ```
pub fn run(self) -> Result<impl Future<Output = Result<(), Error>>, Error> {
let addr = format!(
"{}:{}",
&global::SETTINGS.server.bind_address,
&global::SETTINGS.server.bind_port
))?
.run())
global::SETTINGS.server.bind_address,
global::SETTINGS.server.bind_port
);
// Enlaza el puerto de forma síncrona para detectar errores antes del *await*.
let std_listener = std::net::TcpListener::bind(&addr)?;
std_listener.set_nonblocking(true)?;
let router = Self::build_router();
Ok(async move {
let listener = tokio::net::TcpListener::from_std(std_listener)?;
axum::serve(listener, router).await
})
}
/// Prepara el servidor web de la aplicación para pruebas.
pub fn test(
self,
) -> service::App<
impl service::Factory<
service::Request,
Config = (),
Response = service::Response<service::BoxBody>,
Error = service::Error,
InitError = (),
>,
> {
Self::service_app()
}
/// Configura el servicio web de la aplicación.
fn service_app() -> service::App<
impl service::Factory<
service::Request,
Config = (),
Response = service::Response<service::BoxBody>,
Error = service::Error,
InitError = (),
>,
> {
service::App::new()
.configure(extension::all::configure_services)
.default_service(service::web::route().to(service_not_found))
/// Devuelve el servidor web configurado para usarlo en pruebas de integración.
pub fn test(self) -> Router {
Self::build_router()
}
}
async fn service_not_found(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn route_not_found(request: HttpRequest) -> Result<Markup, ErrorPage> {
Err(ErrorPage::NotFound(request))
}

View file

@ -114,7 +114,7 @@ impl Component for Intro {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
cx.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/intro.css").with_version(PAGETOP_VERSION),
StyleSheet::from("/pagetop/css/intro.css").with_version(PAGETOP_VERSION),
));
if *self.opening() == IntroOpening::PageTop {
cx.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx|

View file

@ -2,11 +2,11 @@ use crate::prelude::*;
/// Página de bienvenida de PageTop.
///
/// 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 (`/`).
/// Se registra automáticamente cuando la aplicación arranca sin extensión raíz. Muestra una página
/// de bienvenida de PageTop en la ruta raíz (`/`) usando el componente [`Intro`].
///
/// No obstante, cualquier extensión puede sobrescribir este comportamiento si utiliza estas mismas
/// rutas.
/// También puede incluirse explícitamente como dependencia de la extensión raíz o de cualquier otra
/// extensión dentro de la estructura de la aplicación.
///
/// Resulta útil en demos o para comprobar rápidamente que el servidor ha arrancado correctamente.
pub struct Welcome;
@ -20,12 +20,12 @@ impl Extension for Welcome {
L10n::l("welcome_extension_description")
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(home));
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(home))
}
}
async fn home(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
async fn home(request: HttpRequest) -> Result<Markup, ErrorPage> {
let app = &global::SETTINGS.app.name;
Page::new(request)

View file

@ -13,12 +13,12 @@ impl Extension for Basic {
impl Theme for Basic {
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
StyleSheet::from("/pagetop/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/basic.css")
StyleSheet::from("/pagetop/css/basic.css")
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))

View file

@ -7,11 +7,11 @@
//! **código** de la **configuración**, lo que permite tener configuraciones diferentes para cada
//! despliegue, como *dev*, *staging* o *production*, sin modificar el código fuente.
//!
//!
//! # Orden de carga
//!
//! Si tu aplicación necesita archivos de configuración, crea un directorio `config` en la raíz del
//! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación.
//! proyecto, al mismo nivel que el archivo *Cargo.toml* o que el binario de la aplicación. Puedes
//! cambiar esta ubicación mediante la variable de entorno `CONFIG_DIR`.
//!
//! PageTop carga en este orden, y siempre de forma opcional, los siguientes archivos TOML:
//!
@ -42,7 +42,6 @@
//! Los archivos se combinan en el orden anterior, cada archivo sobrescribe a los anteriores en caso
//! de conflicto.
//!
//!
//! # Cómo añadir opciones de configuración a tu código
//!
//! Añade [*serde*](https://docs.rs/serde) en tu archivo *Cargo.toml* con la *feature* `derive`:
@ -91,7 +90,6 @@
//!
//! Las estructuras de configuración son de **sólo lectura** durante la ejecución.
//!
//!
//! # Usando tus opciones de configuración
//!
//! ```rust,ignore
@ -131,9 +129,14 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
let dir = env::var_os("CONFIG_DIR").unwrap_or_else(|| DEFAULT_CONFIG_DIR.into());
let config_dir = util::resolve_absolute_dir(&dir).unwrap_or_else(|_| PathBuf::from(&dir));
// Modo de ejecución según la variable de entorno PAGETOP_RUN_MODE. Si no está definida, se usa
// por defecto DEFAULT_RUN_MODE (p. ej. PAGETOP_RUN_MODE=production).
let rm = env::var("PAGETOP_RUN_MODE").unwrap_or_else(|_| DEFAULT_RUN_MODE.into());
// Modo de ejecución. Con la *feature* `testing` activa (usada por `cargo ts` y `cargo tw`), se
// fija en "test" en tiempo de compilación, sin manipular el entorno. En caso contrario se lee
// de PAGETOP_RUN_MODE, o se usa DEFAULT_RUN_MODE si la variable no está definida.
let rm = if cfg!(feature = "testing") {
"test".to_string()
} else {
env::var("PAGETOP_RUN_MODE").unwrap_or_else(|_| DEFAULT_RUN_MODE.into())
};
Config::builder()
// 1. Configuración común para todos los entornos (common.toml).
@ -158,7 +161,7 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
/// Hay que añadir en nuestra librería el siguiente código:
///
/// ```rust,ignore
/// include_config!(SETTINGS: Settings => [
/// include_config!(SETTINGS_NAME: SettingsType => [
/// "ruta.clave" => valor,
/// // ...
/// ]);
@ -168,8 +171,8 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
///
/// * **`SETTINGS_NAME`** es el nombre de la variable global que se usará para referenciar los
/// ajustes. Se recomienda usar `SETTINGS`, aunque no es obligatorio.
/// * **`Settings_Type`** es la referencia a la estructura que define los tipos para deserializar la
/// configuración. Debe implementar `Deserialize` (derivable con `#[derive(Deserialize)]`).
/// * **`SettingsType`** es la estructura que define los tipos para deserializar la configuración.
/// Debe implementar `Deserialize` (derivable con `#[derive(Deserialize)]`).
/// * **Lista de pares** con las claves TOML que requieran valores por defecto. Siguen la notación
/// `"seccion.subclave"` para coincidir con el árbol TOML.
///
@ -211,7 +214,7 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
/// * **Secciones únicas**. Agrupa tus claves dentro de una sección exclusiva (p. ej. `[blog]`) para
/// evitar colisiones con otras librerías.
///
/// * **Solo lectura**. La variable generada es inmutable durante toda la vida del programa. Para
/// * **Sólo lectura**. La variable generada es inmutable durante toda la vida del programa. Para
/// configurar distintos entornos (*dev*, *staging*, *prod*) usa los archivos TOML descritos en la
/// documentación de [`config`](crate::config).
///
@ -220,8 +223,8 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
///
/// # Requisitos
///
/// * Dependencia `serde` con la *feature* `derive`.
/// * Las claves deben coincidir con los campos (*snake case*) de tu estructura `Settings_Type`.
/// * Las claves deben coincidir con los campos (*snake case*) de la estructura de ajustes.
/// * Añade `serde` con la *feature* `derive` en *Cargo.toml*:
///
/// ```toml
/// [dependencies]
@ -229,10 +232,10 @@ pub static CONFIG_VALUES: LazyLock<ConfigBuilder<DefaultState>> = LazyLock::new(
/// ```
#[macro_export]
macro_rules! include_config {
( $SETTINGS_NAME:ident : $Settings_Type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => {
( $SETTINGS_NAME:ident : $settings_type:ty => [ $( $k:literal => $v:expr ),* $(,)? ] ) => {
#[doc = concat!(
"Ajustes de configuración y **valores por defecto** para ",
"[`", stringify!($Settings_Type), "`]."
"Carga [`", stringify!($settings_type), "`] ",
"(y aplica **valores por defecto** en claves no definidas)."
)]
#[doc = ""]
#[doc = "Valores predeterminados que se aplican en ausencia de configuración:"]
@ -241,17 +244,18 @@ macro_rules! include_config {
#[doc = concat!($k, " = ", stringify!($v))]
)*
#[doc = "```"]
pub static $SETTINGS_NAME: std::sync::LazyLock<$Settings_Type> =
pub static $SETTINGS_NAME: std::sync::LazyLock<$settings_type> =
std::sync::LazyLock::new(|| {
let mut settings = $crate::config::CONFIG_VALUES.clone();
$(
settings = settings.set_default($k, $v).unwrap();
settings = settings.set_default($k, $v)
.expect(concat!("Failed to set default for key ", $k));
)*
settings
.build()
.expect(concat!("Failed to build config for ", stringify!($Settings_Type)))
.try_deserialize::<$Settings_Type>()
.expect(concat!("Error parsing settings for ", stringify!($Settings_Type)))
.expect(concat!("Failed to build config for ", stringify!($settings_type)))
.try_deserialize::<$settings_type>()
.expect(concat!("Error parsing settings for ", stringify!($settings_type)))
});
};
}

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