(core): Añade Route, RoutePath y Waypoint

`Route` resuelve URLs sensibles al `Context`/idioma en propiedades de
componentes; `RoutePath` (renombrado desde html/route.rs) es el valor
concreto de ruta + query. `Waypoint` transporta la URL de retorno entre
pantallas de listado/alta/edición vía query string.

Ajusta `Context::route()`, `Redirect` y los componentes que aceptan
rutas para integrarse con el nuevo tipo `Route`.
This commit is contained in:
Manuel Cillero 2026-07-19 22:16:21 +02:00
parent 6f82da220b
commit af548b03c9
25 changed files with 819 additions and 216 deletions

View file

@ -168,6 +168,45 @@ pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Opt
}
}
/// Indica si una URL **parece** externa por su prefijo.
///
/// No es una validación de la URL; sólo mira el inicio del texto: `//` (relativa al protocolo),
/// `http://`, `https://`, `mailto:` o `tel:`. La comparación ignora mayúsculas/minúsculas ASCII en
/// los prefijos (`HTTPS://...` o `MAILTO:...` se detectan igual), porque el esquema de una URI es
/// *case-insensitive* según RFC 3986.
///
/// La usan [`RoutePath::is_external()`](crate::html::RoutePath::is_external) y las conversiones
/// `From<&str>`/`From<String>` de [`Route`](crate::core::component::Route) para decidir si una
/// ruta debe evitar [`Context::route()`](crate::core::component::Context::route).
///
/// Cualquier otro código que necesite el mismo criterio (por ejemplo, para decidir si un enlace
/// debe llevar `target="_blank"`) puede usarla en lugar de reimplementar su propia versión.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::util;
/// assert!(util::url_looks_external("https://example.com"));
/// assert!(util::url_looks_external("mailto:info@example.com"));
/// assert!(util::url_looks_external("HTTPS://EXAMPLE.COM"));
/// assert!(!util::url_looks_external("/admin/users"));
/// ```
pub fn url_looks_external(url: &str) -> bool {
starts_with_ignore_ascii_case(url, "//")
|| starts_with_ignore_ascii_case(url, "http://")
|| starts_with_ignore_ascii_case(url, "https://")
|| starts_with_ignore_ascii_case(url, "mailto:")
|| starts_with_ignore_ascii_case(url, "tel:")
}
// Compara si `text` empieza por `prefix` ignorando mayúsculas/minúsculas en ASCII (los esquemas de
// URI son case-insensitive según RFC 3986). No reserva memoria: sólo compara el primer tramo de
// bytes de `text` con `prefix`.
fn starts_with_ignore_ascii_case(text: &str, prefix: &str) -> bool {
text.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
/// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta.
///
/// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de