,
}
impl StaticFilesBundle {
- /// Prepara el conjunto de recursos con los archivos de un directorio. Opcionalmente se puede
- /// aplicar un filtro para seleccionar un subconjunto de los archivos.
+ /// Crea el paquete de recursos con los archivos del directorio indicado.
///
/// # Argumentos
///
- /// * `dir` - Directorio que contiene los archivos.
- /// * `filter` - Una función opcional para aceptar o no un archivo según su ruta.
+ /// * `dir` - Ruta al directorio con los archivos a incluir, normalmente `static/` o un
+ /// directorio dentro de este.
+ /// * `filter` - Función opcional para seleccionar qué archivos incluir en el paquete.
///
/// # Ejemplo
///
@@ -145,124 +178,227 @@ impl StaticFilesBundle {
/// use std::path::Path;
///
/// fn main() -> std::io::Result<()> {
- /// fn only_images(path: &Path) -> bool {
- /// matches!(
- /// path.extension().and_then(|ext| ext.to_str()),
- /// Some("jpg" | "png" | "gif")
- /// )
- /// }
- ///
/// StaticFilesBundle::from_dir("./static", Some(only_images))
/// .with_name("images")
/// .build()
/// }
+ ///
+ /// fn only_images(path: &Path) -> bool {
+ /// matches!(
+ /// path.extension().and_then(|ext| ext.to_str()),
+ /// Some("jpg" | "png" | "gif")
+ /// )
+ /// }
/// ```
pub fn from_dir(dir: P, filter: Option bool>) -> Self
where
P: AsRef,
{
- let dir_path = dir.as_ref();
- let dir_str = dir_path.to_str().unwrap_or_else(|| {
- panic!(
- "Resource directory path is not valid UTF-8: {}",
- dir_path.display()
- );
- });
-
- let mut resource_dir = resource_dir(dir_str);
-
- // Aplica el filtro si está definido.
- if let Some(f) = filter {
- resource_dir.with_filter(f);
+ Self {
+ dir: dir.as_ref().to_path_buf(),
+ filter,
+ name: None,
}
-
- // Identifica el directorio temporal de recursos.
- StaticFilesBundle { resource_dir }
}
- /// Prepara un recurso CSS minimizado a partir de la compilación de un archivo SCSS (que puede a
- /// su vez importar otros archivos SCSS).
+ /// Asigna un nombre al paquete de recursos.
///
- /// # Argumentos
+ /// El nombre debe ser un identificador Rust válido que se convertirá en nombre del módulo y de
+ /// la función del archivo `.rs` generado en `OUT_DIR`. Si no se llama a este método, el nombre
+ /// por defecto será `"bundle"`.
///
- /// * `path` - Archivo SCSS a compilar.
- /// * `target_name` - Nombre para el archivo CSS.
+ /// Este nombre es el que hay que declarar en
+ /// [`serve_static_files!`](https://docs.rs/pagetop/latest/pagetop/macro.serve_static_files.html)
+ /// para configurar la ruta del servicio:
///
- /// # Ejemplo
- ///
- /// ```rust,no_run
- /// use pagetop_build::StaticFilesBundle;
- ///
- /// fn main() -> std::io::Result<()> {
- /// StaticFilesBundle::from_scss("./bootstrap/scss/main.scss", "bootstrap.min.css")
- /// .with_name("bootstrap_css")
- /// .build()
- /// }
+ /// ```rust,ignore
+ /// serve_static_files!(router, ["./static/css", app_css] => "/public/css");
+ /// // ^^^^^^^
+ /// // debe coincidir con .with_name("app_css")
/// ```
- pub fn from_scss(path: P, target_name: &str) -> Self
- where
- P: AsRef,
- {
- // 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 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() {
- remove_dir_all(&temp_dir).unwrap_or_else(|e| {
- panic!(
- "Failed to clean temporary directory `{}`: {e}",
- temp_dir.display()
- );
- });
- }
- create_dir_all(&temp_dir).unwrap_or_else(|e| {
- panic!(
- "Failed to create temporary directory `{}`: {e}",
- temp_dir.display()
- );
- });
-
- // Compila SCSS a CSS.
- let css_content = from_path(
- path.as_ref(),
- &Options::default().style(OutputStyle::Compressed),
- )
- .unwrap_or_else(|e| {
- panic!(
- "Failed to compile SCSS file `{}`: {e}",
- path.as_ref().display(),
- )
- });
-
- // Guarda el archivo CSS compilado en el directorio temporal.
- let css_path = temp_dir.join(target_name);
- File::create(&css_path)
- .unwrap_or_else(|_| panic!("Failed to create CSS file `{}`", css_path.display()))
- .write_all(css_content.as_bytes())
- .unwrap_or_else(|_| panic!("Failed to write CSS content to `{}`", css_path.display()));
-
- // Identifica el directorio temporal de recursos.
- StaticFilesBundle {
- resource_dir: resource_dir(temp_dir.to_str().unwrap()),
- }
- }
-
- /// Asigna un nombre al conjunto de recursos.
pub fn with_name(mut self, name: impl AsRef) -> Self {
- let name = name.as_ref();
- let out_dir = std::env::var("OUT_DIR").unwrap();
- let filename = Path::new(&out_dir).join(format!("{name}.rs"));
- self.resource_dir.with_generated_filename(filename);
- self.resource_dir.with_module_name(format!("bundle_{name}"));
- self.resource_dir.with_generated_fn(name);
+ self.name = Some(name.as_ref().to_string());
self
}
- /// Contruye finalmente el conjunto de recursos para incluir en el binario de la aplicación.
+ /// Genera el archivo `.rs` en `OUT_DIR` para incluir los recursos del directorio en el binario.
pub fn build(self) -> std::io::Result<()> {
- self.resource_dir.build()
+ let out_dir = std::env::var("OUT_DIR").unwrap();
+ let name = self.name.as_deref().unwrap_or("bundle");
+
+ let mut rd = resource_dir(&self.dir);
+ if let Some(f) = self.filter {
+ rd.with_filter(f);
+ }
+
+ let generated_filename = PathBuf::from(&out_dir).join(format!("{name}.rs"));
+ rd.with_generated_filename(generated_filename);
+ rd.with_module_name(format!("bundle_{name}"));
+ rd.with_generated_fn(name);
+ rd.build()
}
}
+
+// **< compile_scss / copy_dir / copy_file / copy_file_replacing / minify_js >**********************
+
+/// Compila un archivo SCSS a CSS minificado y lo escribe en la ruta de destino.
+///
+/// Crea el directorio padre del destino si no existe.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// fn main() -> std::io::Result<()> {
+/// pagetop_build::compile_scss("assets/main.scss", "static/css/main.min.css")
+/// }
+/// ```
+pub fn compile_scss(src: P, dst: Q) -> io::Result<()>
+where
+ P: AsRef,
+ Q: AsRef,
+{
+ let src = src.as_ref();
+ let dst = dst.as_ref();
+
+ if let Some(parent) = dst.parent() {
+ create_dir_all(parent)?;
+ }
+
+ let options = Options::default().style(OutputStyle::Compressed);
+ let css = from_path(src, &options)
+ .map_err(|e| io::Error::other(format!("failed to compile `{}`: {e}", src.display())))?;
+ File::create(dst)?.write_all(css.as_bytes())
+}
+
+/// Copia recursivamente el contenido de un directorio a otro destino.
+///
+/// Crea el directorio destino y todos los subdirectorios necesarios.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// fn main() -> std::io::Result<()> {
+/// pagetop_build::copy_dir("assets", "static")
+/// }
+/// ```
+pub fn copy_dir(src: P, dst: Q) -> io::Result<()>
+where
+ P: AsRef,
+ Q: AsRef,
+{
+ let src = src.as_ref();
+ let dst = dst.as_ref();
+ create_dir_all(dst)?;
+ for entry in read_dir(src)? {
+ let entry = entry?;
+ let src_path = entry.path();
+ let dst_path = dst.join(entry.file_name());
+ if src_path.is_dir() {
+ copy_dir(&src_path, &dst_path)?;
+ } else {
+ fs_copy(&src_path, &dst_path)?;
+ }
+ }
+ Ok(())
+}
+
+/// Copia un archivo a su destino.
+///
+/// Crea el directorio padre del destino si no existe.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// fn main() -> std::io::Result<()> {
+/// pagetop_build::copy_file("assets/fonts/icon.woff2", "static/fonts/icon.woff2")
+/// }
+/// ```
+pub fn copy_file(src: P, dst: Q) -> io::Result<()>
+where
+ P: AsRef,
+ Q: AsRef,
+{
+ let src = src.as_ref();
+ let dst = dst.as_ref();
+
+ if let Some(parent) = dst.parent() {
+ create_dir_all(parent)?;
+ }
+
+ fs_copy(src, dst)?;
+ Ok(())
+}
+
+/// Copia un archivo a su destino con una lista de sustituciones de texto en su contenido.
+///
+/// El archivo fuente se lee como texto UTF-8; no debe usarse con archivos binarios. Las
+/// sustituciones de texto se aplican en orden y de forma encadenada: el resultado de cada
+/// sustitución puede ser entrada de la siguiente.
+///
+/// Crea el directorio padre del destino si no existe.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// fn main() -> std::io::Result<()> {
+/// pagetop_build::copy_file_replacing(
+/// "assets/adminlte.min.js",
+/// "static/js/myapp.min.js",
+/// &[("adminlte.min.js.map", "myapp.min.js.map")],
+/// )
+/// }
+/// ```
+pub fn copy_file_replacing(src: P, dst: Q, replacements: &[(&str, &str)]) -> io::Result<()>
+where
+ P: AsRef,
+ Q: AsRef,
+{
+ let src = src.as_ref();
+ let dst = dst.as_ref();
+
+ if let Some(parent) = dst.parent() {
+ create_dir_all(parent)?;
+ }
+
+ let content = std::fs::read_to_string(src)?;
+ let patched = replacements
+ .iter()
+ .fold(content, |acc, (old, new)| acc.replace(old, new));
+ File::create(dst)?.write_all(patched.as_bytes())
+}
+
+/// Minifica un archivo JavaScript y lo escribe en la ruta de destino.
+///
+/// El archivo se procesa en modo de ámbito global (`TopLevelMode::Global`), adecuado para scripts
+/// sin `import`/`export`. Los archivos con sintaxis de módulo ES deben procesarse con
+/// `TopLevelMode::Module`, que el *crate* subyacente (`minify-js`) también soporta pero esta
+/// función no expone actualmente.
+///
+/// Crea el directorio padre del destino si no existe.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// fn main() -> std::io::Result<()> {
+/// pagetop_build::minify_js("assets/shell.js", "static/js/shell.min.js")
+/// }
+/// ```
+pub fn minify_js(src: P, dst: Q) -> io::Result<()>
+where
+ P: AsRef,
+ Q: AsRef,
+{
+ let src = src.as_ref();
+ let dst = dst.as_ref();
+
+ if let Some(parent) = dst.parent() {
+ create_dir_all(parent)?;
+ }
+
+ let source = std::fs::read(src)?;
+ let session = Session::new();
+ let mut output = Vec::new();
+ minify(&session, TopLevelMode::Global, &source, &mut output)
+ .map_err(|e| io::Error::other(format!("failed to minify `{}`: {e:?}", src.display())))?;
+ File::create(dst)?.write_all(&output)
+}
diff --git a/helpers/pagetop-macros/README.md b/helpers/pagetop-macros/README.md
index 9b0174a6..599f81bb 100644
--- a/helpers/pagetop-macros/README.md
+++ b/helpers/pagetop-macros/README.md
@@ -11,14 +11,13 @@
-## 🧭 Sobre PageTop
+## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
-
-## 📚 Créditos
+## Créditos
Este *crate* incluye entre sus macros una adaptación de
[maud-macros](https://crates.io/crates/maud_macros)
@@ -29,15 +28,13 @@ Este *crate* incluye entre sus macros una adaptación de
necesidad de referenciar `maud` o `smart_default` en las dependencias del archivo `Cargo.toml` de
cada proyecto PageTop.
-
-## 🚧 Advertencia
+## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
-
-## 📜 Licencia
+## Licencia
El código está disponible bajo una doble licencia:
diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs
index 63349aa0..bb9aaa89 100644
--- a/helpers/pagetop-macros/src/lib.rs
+++ b/helpers/pagetop-macros/src/lib.rs
@@ -31,7 +31,7 @@ cada proyecto PageTop.
*/
#![doc(
- html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
+ html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico"
)]
mod maud;
@@ -39,7 +39,7 @@ mod smart_default;
use proc_macro::TokenStream;
use quote::{quote, quote_spanned};
-use syn::{DeriveInput, parse_macro_input, spanned::Spanned};
+use syn::{DeriveInput, ItemFn, parse_macro_input, spanned::Spanned};
/// Macro para escribir plantillas HTML (basada en [Maud](https://docs.rs/maud)).
#[proc_macro]
@@ -126,7 +126,7 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
///
/// Si defines un método `with_` como este:
///
-/// ```rust
+/// ```rust,no_run
/// # use pagetop_macros::builder_fn;
/// # struct Example {value: Option};
/// # impl Example {
@@ -140,7 +140,7 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
///
/// la macro reescribirá el método `with_` y generará un nuevo método `alter_`:
///
-/// ```rust
+/// ```rust,no_run
/// # struct Example {value: Option};
/// # impl Example {
/// #[inline]
@@ -450,19 +450,14 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
/// ```
#[proc_macro_attribute]
pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
- let mut output: TokenStream = (quote! {
- #[::tokio::main]
- })
- .into();
-
- output.extend(item);
- output
+ let input = parse_macro_input!(item as ItemFn);
+ expand_entry(input, false)
}
/// 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.
+/// Usa el mismo *runtime* multi-hilo que [`#[pagetop::main]`](macro@main), para garantizar
+/// compatibilidad con extensiones que ejecutan código asíncrono de forma síncrona.
///
/// # Ejemplo
///
@@ -474,11 +469,43 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream {
/// ```
#[proc_macro_attribute]
pub fn test(_: TokenStream, item: TokenStream) -> TokenStream {
- let mut output: TokenStream = (quote! {
- #[::tokio::test(flavor = "multi_thread")]
- })
- .into();
-
- output.extend(item);
- output
+ let input = parse_macro_input!(item as ItemFn);
+ expand_entry(input, true)
+}
+
+// Genera la función síncrona que envuelve el cuerpo asíncrono original, común a `main` y `test`.
+fn expand_entry(input: ItemFn, is_test: bool) -> TokenStream {
+ if input.sig.asyncness.is_none() {
+ return syn::Error::new_spanned(input.sig.fn_token, "the function must be `async`")
+ .to_compile_error()
+ .into();
+ }
+
+ let ItemFn {
+ attrs,
+ vis,
+ mut sig,
+ block,
+ } = input;
+ sig.asyncness = None;
+
+ // Ruta absoluta para evitar ambigüedad con `pagetop::test` bajo `use pagetop::prelude::*;`.
+ let test_attr = is_test.then(|| quote! { #[::core::prelude::v1::test] });
+
+ let expanded = quote! {
+ #test_attr
+ #(#attrs)*
+ #vis #sig {
+ #[allow(
+ clippy::expect_used,
+ clippy::diverging_sub_expression,
+ clippy::needless_return,
+ clippy::unwrap_in_result
+ )]
+ {
+ return ::pagetop::util::build_runtime().block_on(async move #block);
+ }
+ }
+ };
+ expanded.into()
}
diff --git a/helpers/pagetop-macros/src/maud/ast.rs b/helpers/pagetop-macros/src/maud/ast.rs
index c8309ef5..0b36d64b 100644
--- a/helpers/pagetop-macros/src/maud/ast.rs
+++ b/helpers/pagetop-macros/src/maud/ast.rs
@@ -206,6 +206,7 @@ impl DiagnosticParse for Element {
},
attrs: {
let mut id_pushed = false;
+ let mut splice_pushed = false;
let mut attrs = Vec::new();
while input.peek(Ident::peek_any)
@@ -226,6 +227,16 @@ impl DiagnosticParse for Element {
id_pushed = true;
}
+ if let Attribute::Splice { .. } = attr {
+ if splice_pushed {
+ return Err(Error::new_spanned(
+ attr,
+ "only one spliced attribute value is allowed per element",
+ ));
+ }
+ splice_pushed = true;
+ }
+
attrs.push(attr);
}
diff --git a/helpers/pagetop-macros/src/maud/generate.rs b/helpers/pagetop-macros/src/maud/generate.rs
index ed2fa214..6e4649ba 100644
--- a/helpers/pagetop-macros/src/maud/generate.rs
+++ b/helpers/pagetop-macros/src/maud/generate.rs
@@ -1,6 +1,6 @@
use proc_macro2::{Ident, Span, TokenStream};
use quote::{ToTokens, quote};
-use syn::{Expr, Local, parse_quote, token::Brace};
+use syn::{Expr, LitStr, Local, parse_quote, token::Brace};
use crate::maud::{ast::*, escape};
@@ -71,6 +71,17 @@ impl Generator {
);
}
+ fn splice_attrs(&self, expr: Expr, exclude: &[LitStr], build: &mut Builder) {
+ let output_ident = &self.output_ident;
+ build.push_tokens(quote!(
+ pagetop::html::html_private::render_attrs_to!(
+ &(#expr),
+ &[#(#exclude),*],
+ &mut #output_ident
+ );
+ ));
+ }
+
fn element(&self, element: Element, build: &mut Builder) {
let element_name = element.name.clone().unwrap_or_else(|| parse_quote!(div));
build.push_str("<");
@@ -141,6 +152,21 @@ impl Generator {
fn attrs(&self, attrs: Vec, build: &mut Builder) {
let (classes, id, named_attrs, spliced) = split_attrs(attrs);
+ // Must run before `classes`/`id`/`named_attrs` are consumed below.
+ let literal_attr_names: Vec = {
+ let mut names = Vec::new();
+ if !classes.is_empty() {
+ names.push(LitStr::new("class", Span::call_site()));
+ }
+ if id.is_some() {
+ names.push(LitStr::new("id", Span::call_site()));
+ }
+ for (name, _) in &named_attrs {
+ names.push(LitStr::new(&name.to_string(), Span::call_site()));
+ }
+ names
+ };
+
if !classes.is_empty() {
let mut toggle_class_exprs = vec![];
@@ -185,7 +211,7 @@ impl Generator {
self.attr(name, attr_type, build);
}
for expr in spliced {
- self.splice(expr, build);
+ self.splice_attrs(expr, &literal_attr_names, build);
}
}
diff --git a/helpers/pagetop-minimal/README.md b/helpers/pagetop-minimal/README.md
index 1f8ec148..b7a17bc0 100644
--- a/helpers/pagetop-minimal/README.md
+++ b/helpers/pagetop-minimal/README.md
@@ -11,21 +11,19 @@
-## 🧭 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* proporciona un conjunto básico de macros que se integran en las utilidades de PageTop
para optimizar operaciones habituales relacionadas con la composición estructurada de texto, la
concatenación de cadenas y el uso rápido de colecciones clave-valor.
-
-## 📚 Créditos
+## Créditos
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
@@ -39,15 +37,13 @@ La macro para generar identificadores dinámicos **`paste!`** se reexporta del *
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
-
-## 🚧 Advertencia
+## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
-
-## 📜 Licencia
+## Licencia
El código está disponible bajo una doble licencia:
diff --git a/helpers/pagetop-minimal/src/lib.rs b/helpers/pagetop-minimal/src/lib.rs
index 3b8c9036..eab70c84 100644
--- a/helpers/pagetop-minimal/src/lib.rs
+++ b/helpers/pagetop-minimal/src/lib.rs
@@ -40,7 +40,7 @@ La macro para generar identificadores dinámicos **`paste!`** se reexporta del *
*/
#![doc(
- html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
+ html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico"
)]
#[doc(hidden)]
diff --git a/helpers/pagetop-statics/README.md b/helpers/pagetop-statics/README.md
index 3184f095..92541096 100644
--- a/helpers/pagetop-statics/README.md
+++ b/helpers/pagetop-statics/README.md
@@ -31,15 +31,13 @@ Para ello, adapta el código de [static-files](https://crates.io/crates/static_f
se integra en PageTop para evitar que cada proyecto tenga que declarar `static-files` manualmente
como dependencia en su `Cargo.toml`.
-
-## 🚧 Advertencia
+## Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
-
-## 📜 Licencia
+## Licencia
El código está disponible bajo una doble licencia:
diff --git a/helpers/pagetop-statics/src/lib.rs b/helpers/pagetop-statics/src/lib.rs
index d72176c6..426991d5 100644
--- a/helpers/pagetop-statics/src/lib.rs
+++ b/helpers/pagetop-statics/src/lib.rs
@@ -35,12 +35,14 @@ como dependencia en su `Cargo.toml`.
#![doc(test(no_crate_inject))]
#![doc(
- html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
+ html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/assets/favicon.ico"
)]
#![allow(clippy::needless_doctest_main)]
/// Resource definition and single module based generation.
pub mod resource;
+
+#[doc(inline)]
pub use resource::Resource as StaticFile;
mod resource_dir;
diff --git a/helpers/pagetop-statics/src/resource.rs b/helpers/pagetop-statics/src/resource.rs
index 62a31ee7..f10c1871 100644
--- a/helpers/pagetop-statics/src/resource.rs
+++ b/helpers/pagetop-statics/src/resource.rs
@@ -158,10 +158,10 @@ fn collect_resources_nested>(
let entry = entry?;
let path = entry.path();
- if let Some(ref filter) = filter {
- if !filter(path.as_ref()) {
- continue;
- }
+ if let Some(ref filter) = filter
+ && !filter(path.as_ref())
+ {
+ continue;
}
if path.is_dir() {
diff --git a/src/app.rs b/src/app.rs
index 4a009fb7..7b4d197e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3,51 +3,57 @@
mod figfont;
use crate::core::{extension, extension::ExtensionRef};
-use crate::html::Markup;
use crate::locale::Locale;
-use crate::response::page::ErrorPage;
-use crate::web::{HttpRequest, Router};
+use crate::response::{render_error_pages, response_for_panic, route_not_found};
+use crate::web::Router;
use crate::{PAGETOP_VERSION, global, trace};
-use std::future::Future;
+use tower_http::catch_panic::CatchPanicLayer;
+
use std::io::Error;
use std::sync::LazyLock;
/// Punto de entrada de una aplicación PageTop.
///
-/// 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).
+/// Orquesta el arranque de la aplicación. Primero se instancia con [`Application::new()`] o
+/// [`Application::prepare()`], y después se ejecuta usando [`run()`](Application::run). Si se está
+/// preparando un entorno de pruebas, se usa [`test()`](Application::test).
+///
+/// Los **errores controlados** (403, 404, o un fallo que un handler devuelva explícitamente como
+/// [`ErrorPage`](crate::response::ErrorPage)) se renderizan usando el tema activo (ver
+/// [`Theme::error_403()`](crate::core::theme::Theme::error_403),
+/// [`Theme::error_404()`](crate::core::theme::Theme::error_404) y
+/// [`Theme::error_fatal()`](crate::core::theme::Theme::error_fatal)).
+///
+/// La última capa del router captura cualquier **fallo catastrófico** (`panic!`) de la aplicación
+/// en lugar de abortar la conexión. Devuelve una respuesta mínima HTTP 500 que es independiente del
+/// tema y del ciclo de renderizado de componentes.
pub struct Application;
-impl Default for Application {
- fn default() -> Self {
- Self::new()
- }
-}
-
impl Application {
/// 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)
+ pub async fn new() -> Self {
+ Self::internal_prepare(None).await
}
/// Prepara una instancia de la aplicación a partir de una extensión raíz.
///
- /// 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))
+ /// Inicializa la aplicación habilitando las extensiones en orden de dependencia: primero las
+ /// que no dependen de ninguna otra, luego las que dependen de extensiones ya habilitadas, y así
+ /// hasta habilitar la extensión raíz.
+ ///
+ /// Es `async` porque cada extensión puede realizar operaciones asíncronas en su
+ /// [`initialize()`](crate::core::extension::Extension::initialize) (conexión a base de datos,
+ /// migraciones, semillas de datos...).
+ pub async fn prepare(root_extension: ExtensionRef) -> Self {
+ Self::internal_prepare(Some(root_extension)).await
}
// Secuencia de arranque común a new() y prepare().
- fn internal_prepare(root_extension: Option) -> Self {
+ async fn internal_prepare(root_extension: Option) -> Self {
// Al arrancar muestra una cabecera para la aplicación.
Self::show_banner();
@@ -64,7 +70,7 @@ impl Application {
extension::all::register_actions();
// Inicializa las extensiones.
- extension::all::initialize_extensions();
+ extension::all::initialize_extensions().await;
Self
}
@@ -78,16 +84,16 @@ impl Application {
// Nombre de la aplicación, ajustado al ancho del terminal si es necesario.
let mut app_ff = String::new();
let app_name = &global::SETTINGS.app.name;
- if let Some((Width(term_width), _)) = terminal_size() {
- if term_width >= 80 {
- let maxlen: usize = ((term_width / 10) - 2).into();
- 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) {
- app_ff = ff.to_string();
- }
+ if let Some((Width(term_width), _)) = terminal_size()
+ && term_width >= 80
+ {
+ let maxlen: usize = ((term_width / 10) - 2).into();
+ 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) {
+ app_ff = ff.to_string();
}
}
if app_ff.is_empty() {
@@ -96,11 +102,6 @@ impl Application {
print!("\n{app_ff}");
}
- // Descripción de la aplicación.
- if !global::SETTINGS.app.description.is_empty() {
- println!("{}", global::SETTINGS.app.description.cyan());
- }
-
// Versión de PageTop.
println!(
"{} {}\n",
@@ -110,47 +111,53 @@ impl Application {
}
}
- // Construye el router con las rutas de todas las extensiones habilitadas.
+ // Construye el router con las rutas y el middleware de todas las extensiones habilitadas.
+ //
+ // Con `CatchPanicLayer` en la última capa se capturan incluso los `panic!` que se produzcan
+ // dentro del propio renderizado de una página de error.
fn build_router() -> Router {
let router = extension::all::configure_routes(Router::new());
- router.fallback(route_not_found)
+ let router = extension::all::configure_middleware(router);
+ router
+ .fallback(route_not_found)
+ .layer(axum::middleware::from_fn(render_error_pages))
+ .layer(CatchPanicLayer::custom(response_for_panic))
}
/// Arranca el servidor web de la aplicación.
///
- /// 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:
+ /// Enlaza el puerto del servidor web (puede fallar con [`std::io::Error`] si el puerto ya está
+ /// en uso o el proceso carece de permisos) y ejecuta el bucle de atención de peticiones. El
+ /// patrón habitual es:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// struct MyApp;
///
+ /// #[async_trait]
/// impl Extension for MyApp {}
///
/// #[pagetop::main]
/// async fn main() -> std::io::Result<()> {
- /// Application::prepare(&MyApp).run()?.await
+ /// Application::prepare(&MyApp).await.run().await
/// }
/// ```
- pub fn run(self) -> Result>, Error> {
+ pub async fn run(self) -> Result<(), Error> {
let addr = format!(
"{}:{}",
global::SETTINGS.server.bind_address,
global::SETTINGS.server.bind_port
);
- // Enlaza el puerto de forma síncrona para detectar errores antes del *await*.
+ // Enlaza el puerto de forma síncrona para detectar errores.
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
- })
+ let listener = tokio::net::TcpListener::from_std(std_listener)?;
+ axum::serve(listener, router).await
}
/// Devuelve el servidor web configurado para usarlo en pruebas de integración.
@@ -158,7 +165,3 @@ impl Application {
Self::build_router()
}
}
-
-async fn route_not_found(request: HttpRequest) -> Result {
- Err(ErrorPage::NotFound(request))
-}
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 00000000..393c61e0
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,296 @@
+//! Identidad del usuario y sistema de autorización extensible.
+//!
+//! Define el tipo [`CurrentUser`] que PageTop inyecta en el [`Context`] con la información mínima
+//! sobre el usuario que ejecuta la petición actual ([`HttpRequest`]).
+//!
+//! Incluye la acción [`CheckPermission`] para que las extensiones puedan implementar sus propios
+//! modelos de permisos. Y también las funciones auxiliares [`has_permission()`] y
+//! [`require_permission()`] para validar en el comienzo de cada handler, antes de construir ni
+//! ejecutar nada, si la petición está autorizada.
+//!
+//! La resolución concreta del usuario (sesión en BD, LDAP, OAuth, ...) y la lógica de permisos
+//! (RBAC, grupos LDAP, ...) son responsabilidad de las extensiones de autenticación. Un concepto
+//! como "administrador" que tiene todos los permisos no es responsabilidad de PageTop: cada
+//! extensión decide si existe y, si es así, lo aplica dentro de su propio handler
+//! [`CheckPermission`].
+//!
+//! [`Context`]: crate::core::component::Context
+
+use crate::core::action::{ActionDispatcher, ActionKey, try_dispatch_actions};
+use crate::locale::Lc;
+use crate::response::ErrorPage;
+use crate::web::HttpRequest;
+use crate::{CowStr, UniqueId, Weight};
+
+// **< CurrentUser >********************************************************************************
+
+/// Identidad mínima del usuario que ejecuta la petición actual.
+///
+/// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se
+/// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su
+/// middleware.
+///
+/// Se accede usando [`Contextual::current_user()`].
+///
+/// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son
+/// responsabilidad de la extensión de autenticación y se obtienen a través de
+/// [`HttpRequest::extension`].
+///
+/// [`Context`]: crate::core::component::Context
+/// [`Contextual::current_user()`]: crate::core::component::Contextual::current_user
+/// [`HttpRequest::extension`]: crate::web::HttpRequest::extension
+#[derive(Clone, Debug)]
+pub enum CurrentUser {
+ /// Usuario no autenticado.
+ Anonymous,
+ /// Usuario autenticado con su identificador y nombre visible.
+ Authenticated {
+ /// Identificador único del usuario en el sistema.
+ id: i32,
+ /// Nombre visible del usuario.
+ display_name: String,
+ },
+}
+
+impl CurrentUser {
+ /// Devuelve `true` si el usuario no está autenticado.
+ pub fn is_anonymous(&self) -> bool {
+ matches!(self, CurrentUser::Anonymous)
+ }
+
+ /// Devuelve `true` si el usuario está autenticado.
+ pub fn is_authenticated(&self) -> bool {
+ matches!(self, CurrentUser::Authenticated { .. })
+ }
+
+ /// Devuelve el identificador del usuario, o `None` si es anónimo.
+ pub fn id(&self) -> Option {
+ match self {
+ CurrentUser::Anonymous => None,
+ CurrentUser::Authenticated { id, .. } => Some(*id),
+ }
+ }
+
+ /// Devuelve el nombre visible del usuario, o `None` si es anónimo.
+ pub fn display_name(&self) -> Option<&str> {
+ match self {
+ CurrentUser::Anonymous => None,
+ CurrentUser::Authenticated { display_name, .. } => Some(display_name),
+ }
+ }
+}
+
+// **< Permission >*********************************************************************************
+
+/// Clave tipada de un permiso de acceso.
+///
+/// Cada extensión que lo requiera puede definir su propio enum de permisos e implementar este trait
+/// para obtener la clave textual que finalmente se compara contra su modelo de permisos (RBAC en
+/// base de datos, grupos LDAP, ...).
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// # use pagetop::auth::Permission;
+/// # use pagetop::CowStr;
+/// #[derive(Clone, Copy, Debug)]
+/// pub enum MyPermission {
+/// EditPosts,
+/// DeletePosts,
+/// }
+///
+/// impl Permission for MyPermission {
+/// fn key(&self) -> CowStr {
+/// match self {
+/// Self::EditPosts => "my_extension.edit_posts".into(),
+/// Self::DeletePosts => "my_extension.delete_posts".into(),
+/// }
+/// }
+/// }
+/// ```
+pub trait Permission: Send + Sync {
+ /// Clave única del permiso (p. ej. `"my_extension.edit_posts"`).
+ fn key(&self) -> CowStr;
+
+ /// Descripción breve para humanos (p. ej. en una pantalla de asignación de permisos a roles).
+ ///
+ /// Por defecto devuelve la propia clave; una extensión que registre sus permisos en un catálogo
+ /// visible debería sobrescribirlo con un texto traducible.
+ fn label(&self) -> Lc {
+ Lc::n(self.key())
+ }
+
+ /// Identificador estable de la categoría del permiso, usado para agrupar en un catálogo (p.
+ /// ej. `"administration"`). Por defecto no pertenece a ningún grupo.
+ fn group(&self) -> &'static str {
+ ""
+ }
+
+ /// Título traducible de [`group()`](Self::group), mostrado en la UI de administración.
+ ///
+ /// Por defecto reutiliza el propio identificador del grupo como texto fijo.
+ fn group_label(&self) -> Lc {
+ Lc::n(self.group())
+ }
+}
+
+/// Referencia estática a un permiso de acceso.
+///
+/// Es el tipo que recorre toda la API de autorización ([`has_permission()`],
+/// [`require_permission()`] o [`CheckPermission`]).
+pub type PermissionRef = &'static dyn Permission;
+
+// **< CheckPermission >****************************************************************************
+
+/// Tipo de función para comprobar si el usuario actual tiene un permiso concreto.
+///
+/// Se invoca con:
+///
+/// - `request`: petición HTTP desde la que se accede a los datos inyectados por el middleware de
+/// autenticación.
+/// - `perm`: permiso a comprobar; el handler usará [`Permission::key()`] para identificarlo contra
+/// su propio modelo de permisos.
+/// - `granted`: referencia mutable; el handler debe asignarla a `true` si concede el permiso.
+pub type FnActionCheckPerm = fn(request: &HttpRequest, perm: PermissionRef, granted: &mut bool);
+
+/// Acción para comprobar si el usuario actual tiene un permiso concreto.
+///
+/// Las extensiones de autenticación pueden registrar su handler sobre esta acción para implementar
+/// su modelo de permisos. Los handlers son aditivos de tal forma que si cualquiera de ellos asigna
+/// `granted = true`, el permiso se concede.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// # use pagetop::prelude::*;
+/// fn check_my_permissions(request: &HttpRequest, perm: PermissionRef, granted: &mut bool) {
+/// // Leer los datos extendidos de autenticación inyectados en la petición.
+/// // Comparar `perm.key()` contra el modelo propio.
+/// // Si concede el permiso, asignar `*granted = true`.
+/// }
+///
+/// pub struct MyAuth;
+///
+/// #[async_trait]
+/// impl Extension for MyAuth {
+/// fn actions(&self) -> Vec {
+/// actions![CheckPermission::new(check_my_permissions)]
+/// }
+/// }
+/// ```
+pub struct CheckPermission {
+ f: FnActionCheckPerm,
+ weight: Weight,
+}
+
+impl ActionDispatcher for CheckPermission {
+ fn weight(&self) -> Weight {
+ self.weight
+ }
+}
+
+impl CheckPermission {
+ /// Registra una nueva acción para la comprobación de permisos.
+ pub fn new(f: FnActionCheckPerm) -> Self {
+ CheckPermission { f, weight: 0 }
+ }
+
+ /// Opcional. Acciones con pesos más bajos se aplican antes. Se pueden usar valores negativos.
+ pub fn with_weight(mut self, value: Weight) -> Self {
+ self.weight = value;
+ self
+ }
+
+ // Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso.
+ #[inline]
+ pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool {
+ let mut granted = false;
+ try_dispatch_actions(
+ &ActionKey::new(UniqueId::of::(), None, None),
+ |action: &Self| {
+ (action.f)(request, perm, &mut granted);
+ if granted {
+ std::ops::ControlFlow::Break(())
+ } else {
+ std::ops::ControlFlow::Continue(())
+ }
+ },
+ );
+ granted
+ }
+}
+
+// **< has_permission >*****************************************************************************
+
+/// Comprueba si el usuario actual tiene el permiso indicado.
+///
+/// Despacha la acción [`CheckPermission`]: cualquier extensión registrada puede conceder el permiso
+/// asignando `granted = true` en su handler. Si no hay extensiones de autenticación activas,
+/// devuelve `false` para cualquier usuario, incluido el anónimo.
+///
+/// La decisión de conceder o denegar permisos al usuario anónimo también es responsabilidad de cada
+/// extensión.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// # use pagetop::prelude::*;
+/// # #[derive(Clone, Copy, Debug)]
+/// # enum MyPermission { Edit }
+/// # impl Permission for MyPermission {
+/// # fn key(&self) -> CowStr { "myapp.edit".into() }
+/// # }
+/// async fn my_handler(request: HttpRequest) -> Result {
+/// if !has_permission(&request, &MyPermission::Edit) {
+/// return Err(ErrorPage::NotFound(Some(request)));
+/// }
+/// Page::new(request).render().await
+/// }
+/// ```
+pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
+ CheckPermission::check(request, perm)
+}
+
+// **< require_permission >*************************************************************************
+
+/// Comprueba un permiso y devuelve `Err(ErrorPage::AccessDenied)` si se deniega.
+///
+/// Ejecuta [`has_permission()`] para el caso más habitual: detener un handler con una respuesta 403
+/// en cuanto falta el permiso, sin repetir el `if`/`return` en cada punto de comprobación. Se hace
+/// directamente sobre la petición, antes de construir ni ejecutar nada (`Context`, `Page`,
+/// consultas a datos, etc.), para no hacer ningún trabajo si la petición no está autorizada.
+///
+/// Si la aplicación necesita ocultar la existencia del recurso a quien no tiene permiso (devolver
+/// un 404 en vez de un 403), no se puede reutilizar esta función: hay que llamar a
+/// `has_permission()` directamente, como en su propio ejemplo.
+///
+/// # Ejemplo
+///
+/// ```rust,no_run
+/// # use pagetop::prelude::*;
+/// # #[derive(Clone, Copy, Debug)]
+/// # enum MyPermission { Edit }
+/// # impl Permission for MyPermission {
+/// # fn key(&self) -> CowStr { "myapp.edit".into() }
+/// # }
+/// async fn my_handler(request: HttpRequest) -> Result {
+/// // Comprueba si la petición está autorizada.
+/// require_permission(&request, &MyPermission::Edit)?;
+///
+/// // Ejecuta las instrucciones propias de la petición.
+/// Page::new(request)
+/// .with_child(Html::with(|_| html! { p { "You have permission!" } }))
+/// .render()
+/// .await
+/// }
+/// ```
+// `ErrorPage` incluye `Option` en cada variante y es el tipo de error ya establecido
+// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa.
+#[allow(clippy::result_large_err)]
+pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> {
+ if has_permission(request, perm) {
+ Ok(())
+ } else {
+ Err(ErrorPage::AccessDenied(Some(request.clone())))
+ }
+}
diff --git a/src/base/action/component/after_render_component.rs b/src/base/action/component/after_render_component.rs
index 0778e9c8..3a9901fc 100644
--- a/src/base/action/component/after_render_component.rs
+++ b/src/base/action/component/after_render_component.rs
@@ -6,23 +6,20 @@ use super::FnActionWithComponent;
pub struct AfterRender {
f: FnActionWithComponent,
referer_type_id: Option,
- referer_id: AttrId,
+ referer_id: Option,
weight: Weight,
}
-/// Filtro para despachar [`FnActionWithComponent`] después de renderizar un componente `C`.
+// Filtro para despachar `FnActionWithComponent` después de renderizar un componente `C`.
impl ActionDispatcher for AfterRender {
- /// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option