diff --git a/.gitignore b/.gitignore index ed088133..06e70689 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Ignora directorios de compilación **/target +# Ignora directorios de archivos estáticos +**/static + # Archivos de log **/log/*.log* diff --git a/Cargo.lock b/Cargo.lock index 433ce60d..1ad66c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -933,7 +942,7 @@ version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.4", "bstr", "log", "regex-automata", @@ -965,6 +974,16 @@ dependencies = [ "rand", ] +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", + "bumpalo", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1495,6 +1514,17 @@ dependencies = [ "unicase", ] +[[package]] +name = "minify-js" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fa5546ee8bd66024113e506cabe4230e76635a094c06ea2051b66021dda92e" +dependencies = [ + "aho-corasick 0.7.20", + "lazy_static", + "parse-js", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1721,7 +1751,6 @@ version = "0.1.0" dependencies = [ "pagetop", "pagetop-build", - "tokio", ] [[package]] @@ -1731,7 +1760,6 @@ dependencies = [ "pagetop", "pagetop-build", "serde", - "tokio", ] [[package]] @@ -1739,9 +1767,18 @@ name = "pagetop-build" version = "0.3.2" dependencies = [ "grass", + "minify-js", "pagetop-statics", ] +[[package]] +name = "pagetop-htmx" +version = "0.1.0" +dependencies = [ + "pagetop", + "pagetop-build", +] + [[package]] name = "pagetop-macros" version = "0.3.0" @@ -1812,6 +1849,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-js" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2742b5e32dcb5930447ed9f9e401a7dfd883867fc079c4fac44ae8ba3593710e" +dependencies = [ + "aho-corasick 0.7.20", + "bumpalo", + "hashbrown 0.13.2", + "lazy_static", + "memchr", +] + [[package]] name = "pastey" version = "0.2.2" @@ -2094,7 +2144,7 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.4", "memchr", "regex-syntax", ] diff --git a/Cargo.toml b/Cargo.toml index 269f752f..03a5944a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ # Extensions "extensions/pagetop-aliner", "extensions/pagetop-bootsier", + "extensions/pagetop-htmx", "extensions/pagetop-seaorm", ] @@ -35,6 +36,7 @@ indexmap = "2.14" indoc = "2.0" itoa = "1.0" mime_guess = "2.0" +minify-js = "0.6" parking_lot = "0.12" pastey = "0.2" path-slash = "0.2" @@ -62,6 +64,7 @@ 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-htmx = { version = "0.1", path = "extensions/pagetop-htmx" } pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" } # PageTop pagetop = { version = "0.5", path = "." } diff --git a/README.md b/README.md index 89fade06..53a68c9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
- +

PageTop

@@ -11,9 +11,8 @@ [![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) -
-
+ 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. @@ -29,8 +28,7 @@ según las necesidades de cada proyecto, incluyendo: * **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y componentes. - -## ⚡️ Guía rápida +## Guía rápida La aplicación más sencilla de PageTop se ve así: @@ -39,43 +37,43 @@ use pagetop::prelude::*; #[pagetop::main] async fn main() -> std::io::Result<()> { - Application::new().run()?.await + Application::new().await.run().await } ``` Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`. -Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera: +Para personalizar el servicio, puedes crear una extensión de PageTop de la siguiente manera: ```rust,no_run use pagetop::prelude::*; struct HelloWorld; +#[async_trait] 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) -> Result { Page::new(request) - .add_child(Html::with(|_| html! { h1 { "Hello World!" } })) - .render() + .with_child(Html::with(|_| html! { h1 { "Hello World!" } })) + .render().await } #[pagetop::main] async fn main() -> std::io::Result<()> { - Application::prepare(&HelloWorld).run()?.await + Application::prepare(&HelloWorld).await.run().await } ``` Este programa implementa una extensión llamada `HelloWorld` que sirve una página web en la ruta raíz -(`/`) mostrando el texto "Hello world!" dentro de un elemento HTML `

`. +(`/`) mostrando el texto "Hello World!" dentro de un elemento HTML `

`. - -## 📂 Proyecto +## Proyecto El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos: @@ -113,8 +111,7 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui * **[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 +## 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: @@ -134,15 +131,13 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C > * Los alias suprimen las trazas del registro de eventos. Para activarlas usa directamente > `cargo test`. - -## 🚧 Advertencia +## Advertencia **PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos hasta que se libere la versión **1.0.0**. - -## 📜 Licencia +## Licencia El código está disponible bajo una doble licencia: @@ -155,8 +150,7 @@ El código está disponible bajo una doble licencia: Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en el ecosistema Rust. - -## ✨ Contribuir +## Contribuir PageTop mantiene **un único repositorio oficial**: diff --git a/static/banner.png b/assets/banner.png similarity index 100% rename from static/banner.png rename to assets/banner.png diff --git a/assets/css/basic.css b/assets/css/basic.css new file mode 100644 index 00000000..cfb3d107 --- /dev/null +++ b/assets/css/basic.css @@ -0,0 +1,520 @@ +:root { + /* Font families */ + --val-font-sans: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif; + --val-font-serif: Georgia,"Times New Roman",serif; + --val-font-monospace: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; + --val-font-family: var(--val-font-sans); + /* Font size */ + --val-fs--base: 1rem; + /* Font weight */ + --val-fw--base: 400; + /* Line height */ + --val-lh--base: 1.5; + /* Core Colors */ + --val-color--primary: #0d6efd; + --val-color--secondary: #6c757d; + --val-color--success: #198754; + --val-color--info: #0dcaf0; + --val-color--warning: #ffc107; + --val-color--danger: #dc3545; + /* Other Colors */ + --val-color--bg: #fafafa; + --val-color--bg--muted: #dee2e6; + --val-color--text: #212529; + --val-color--text--muted: color-mix(in srgb, var(--val-color--text) 50%, var(--val-color--bg)); + --val-color--switch-off: #adb5bd; + --val-color--border: #212529; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--val-font-family); + font-size: var(--val-fs--base); + font-weight: var(--val-fw--base); + line-height: var(--val-lh--base); + color: var(--val-color--text); + background-color: var(--val-color--bg); + -webkit-tap-highlight-color: transparent; +} + +/* + * Form components + */ + +.form-required { + color: var(--val-color--danger); + margin-left: 0.25rem; +} + +.form-label { + display: block; + margin-top: 1em; +} + +.form-text { + font-size: 0.9375rem; + color: var(--val-color--text--muted); +} + +.form-field > select.form-select, +.form-field > input.form-control, +.form-field > input.form-range, +.form-field > textarea.form-control { + display: block; + padding: 0.25rem 0.5rem; + width: 100%; +} + +fieldset { + margin: 2rem 0 1rem; + background: var(--val-color--bg); +} +fieldset > legend { + background-color: var(--val-color--bg); + border: 2px groove threedface; + padding: 0 0.5em; +} + +.form-check { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 1.5rem; + margin-bottom: 0.25rem; +} +.form-check-inline { + display: inline-flex; + margin-right: 1rem; +} +.form-check-reverse { + flex-direction: row-reverse; + justify-content: flex-start; + text-align: right; +} +.form-check-input { + flex-shrink: 0; + cursor: pointer; +} +.form-check-label { + cursor: pointer; +} +.form-switch .form-check-input { + appearance: none; + -webkit-appearance: none; + width: 2em; + height: 1em; + background-color: var(--val-color--switch-off); + border-radius: 1em; + position: relative; + cursor: pointer; + transition: background-color .15s ease-in-out; +} +.form-switch .form-check-input::after { + content: ""; + position: absolute; + width: 1em; + height: 1em; + background-color: white; + border-radius: 50%; + top: 0; + left: 0; + transition: transform .15s ease-in-out; + box-shadow: 0 0 0 1px rgba(0, 0, 0, .25); +} +.form-switch .form-check-input:checked { + background-color: var(--val-color--primary); +} +.form-switch .form-check-input:checked::after { + transform: translateX(1em); +} + +input:disabled, +input:disabled + label { + cursor: default !important; +} +input:disabled + label { + color: var(--val-color--text--muted); +} + +/* + * Dropdown component + */ + +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-button, +.dropdown-toggle { + padding: 0.5rem 0.75rem; + font: inherit; + color: var(--val-color--text); + background-color: transparent; + border: 1px solid var(--val-color--border); + border-radius: 0.375rem; + cursor: pointer; +} +.dropdown-toggle::after { + content: ""; + display: inline-block; + width: 0.4em; + height: 0.4em; + margin-left: 0.35em; + border-right: 0.125em solid currentColor; + border-bottom: 0.125em solid currentColor; + transform: rotate(45deg); + transition: transform .15s ease-in-out; +} +.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after { + transform: rotate(-135deg); +} + +.dropdown-menu { + position: absolute; + z-index: 1000; + top: 100%; + left: 0; + min-width: 12rem; + margin: 0.25rem 0 0; + padding: 0.5rem 0; + list-style: none; + background-color: var(--val-color--bg); + border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} +.dropdown-menu.hide { + display: none; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.375rem 1rem; + text-align: left; + color: var(--val-color--text); + text-decoration: none; + background-color: transparent; + border: none; + font: inherit; + cursor: pointer; +} +.dropdown-item:hover { + background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent); +} +.dropdown-item.active { + color: var(--val-color--primary); + font-weight: 600; +} +.dropdown-item.disabled { + color: var(--val-color--text--muted); + pointer-events: none; +} + +.dropdown-item-text { + display: block; + padding: 0.375rem 1rem; + color: var(--val-color--text--muted); +} + +.dropdown-header { + display: block; + padding: 0.5rem 1rem 0.25rem; + margin: 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--val-color--text--muted); +} + +.dropdown-divider { + height: 0; + margin: 0.5rem 0; + border: none; + border-top: 1px solid var(--val-color--border); +} + +/* Utility: visually hidden but still accessible to assistive technology */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* + * Messages component + */ + +.message { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 0.375rem; +} + +.message-info { + color: color-mix(in srgb, var(--val-color--info) 40%, black); + background-color: color-mix(in srgb, var(--val-color--info) 15%, var(--val-color--bg)); + border-color: color-mix(in srgb, var(--val-color--info) 40%, var(--val-color--bg)); +} + +.message-warning { + color: color-mix(in srgb, var(--val-color--warning) 40%, black); + background-color: color-mix(in srgb, var(--val-color--warning) 15%, var(--val-color--bg)); + border-color: color-mix(in srgb, var(--val-color--warning) 40%, var(--val-color--bg)); +} + +.message-error { + color: color-mix(in srgb, var(--val-color--danger) 40%, black); + background-color: color-mix(in srgb, var(--val-color--danger) 15%, var(--val-color--bg)); + border-color: color-mix(in srgb, var(--val-color--danger) 40%, var(--val-color--bg)); +} + +/* + * Table component + */ + +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.table th, +.table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); +} + +.table thead th { + border-bottom-width: 2px; + font-weight: 600; +} + +.table-sort { + color: inherit; + text-decoration: none; +} +.table-sort:hover { + text-decoration: underline; +} +.table-sort::after { + content: "\25BC"; + margin-left: 0.25rem; + font-size: 0.8em; + opacity: 0.15; +} +.table-sort-asc::after { + content: "\25B2"; + font-size: 0.8em; + opacity: 1; +} +.table-sort-desc::after { + content: "\25BC"; + font-size: 0.8em; + opacity: 1; +} + +.table-empty { + text-align: center; + font-style: italic; + color: var(--val-color--text--muted); +} + +/* + * Pager component + */ + +.pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin: 1rem 0; + padding: 0 1rem; +} +.pager-align-start { + justify-content: flex-start; +} +.pager-align-center { + justify-content: center; +} +.pager-align-end { + justify-content: flex-end; +} +.pager-summary { + color: var(--val-color--text--muted); + font-size: 0.8125rem; + align-self: flex-start; + margin-inline-end: auto; +} +.pager-align-start .pager-summary { + order: 1; + margin-inline-end: 0; + margin-inline-start: auto; +} +.pager-align-center .pager-summary { + order: 1; + flex-basis: 100%; + text-align: center; + margin-inline-end: 0; +} + +.pagination { + display: flex; + align-items: center; + gap: 0.25rem; + list-style: none; + margin: 0; + padding: 0; +} +.pagination a { + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + padding: 0.375rem 0.625rem; + border-radius: 0.375rem; + color: var(--val-color--text); + text-decoration: none; +} +.pagination a:hover { + background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent); +} +.pagination .active a { + color: #fff; + background-color: var(--val-color--primary); +} +.pagination .disabled a { + color: var(--val-color--text--muted); + pointer-events: none; + cursor: default; +} + +.page-ellipsis { + padding: 0.375rem 0.25rem; + color: var(--val-color--text--muted); +} + +/* .page-link-icon muestra el texto de navegación de los botones anterior/siguiente. El texto + accesible completo va aparte, en el aria-label del enlace. Por eso sustituir el texto visible por + otro contenido no afecta a la accesibilidad, p.ej. para usar "‹"/"›" se puede aplicar: + +.page-link-icon { + display: inline-block; + font-size: 0; +} +.page-previous .page-link-icon::before { + content: "‹"; + font-size: 1rem; + display: inline-block; + transform: translateY(-0.15em) scale(1.5); +} +.page-next .page-link-icon::before { + content: "›"; + font-size: 1rem; + display: inline-block; + transform: translateY(-0.15em) scale(1.5); +} +*/ + +.pager-jump { + display: flex; + align-items: center; +} +.pager-jump:focus-within { + border-radius: 0.375rem; + box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent); +} +.pager-jump-input > input.form-control:focus, +.pager-jump-button:focus { + outline: none; + box-shadow: none; +} +.pager-jump-input > input.form-control { + width: calc(var(--pager-jump-width, 1ch) + 2.5rem); + padding: 0.375rem 0.625rem; + line-height: var(--val-lh--base); + border: 0; + border-start-end-radius: 0; + border-end-end-radius: 0; + background-color: var(--val-color--light); +} +.pager-jump-input > input.form-control { + text-align: center; + appearance: textfield; + -moz-appearance: textfield; +} +.pager-jump-input > input.form-control::-webkit-outer-spin-button, +.pager-jump-input > input.form-control::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.pager-jump-button { + padding: 0.375rem 0.625rem; + border: 0; + border-start-start-radius: 0; + border-end-start-radius: 0; +} +.pager-jump-button:hover { + background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); +} + +/* + * Breadcrumb component + */ + +.breadcrumb { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 1rem; + padding: 0.75rem 1rem; + font-size: 0.875rem; +} +.breadcrumb-item + .breadcrumb-item { + padding-left: 0.5rem; +} +.breadcrumb-item + .breadcrumb-item::before { + float: left; + padding-right: 0.5rem; + color: var(--val-color--text--muted); + content: "/"; +} +.breadcrumb-item.active { + color: var(--val-color--text--muted); +} +.breadcrumb-item a { + color: var(--val-color--text); + text-decoration: none; +} +.breadcrumb-item a:hover { + text-decoration: underline; +} + +/* + * Region Footer + */ + +.region-footer { + padding: .75rem 0 3rem; + text-align: center; +} diff --git a/static/css/intro.css b/assets/css/intro.css similarity index 94% rename from static/css/intro.css rename to assets/css/intro.css index 00fe0d21..bceacb68 100644 --- a/static/css/intro.css +++ b/assets/css/intro.css @@ -389,10 +389,10 @@ body { .intro-text-body .block { position: relative; } -.intro-text-body .block__title { +.intro-text-body .block-title { margin: 1em 0 .8em; } -.intro-text-body .block__title span { +.intro-text-body .block-title span { display: inline-block; padding: 10px 30px 14px; margin: 30px 20px 0; @@ -403,7 +403,7 @@ body { border-color: orangered; transform: rotate(-3deg) translateY(-25%); } -.intro-text-body .block__title:before { +.intro-text-body .block-title:before { content: ""; height: 5px; position: absolute; @@ -416,7 +416,7 @@ body { transform: rotate(2deg) translateY(-50%); transform-origin: top left; } -.intro-text-body .block__title:after { +.intro-text-body .block-title:after { content: ""; height: 120%; position: absolute; @@ -427,31 +427,35 @@ body { background: var(--intro-bg-block-1); transform: rotate(2deg); } -.intro-text-body .block:nth-of-type(6n+1) .block__title:after { +.intro-text-body .block:nth-of-type(6n+1) .block-title:after { background: var(--intro-bg-block-1); } -.intro-text-body .block:nth-of-type(6n+2) .block__title:after { +.intro-text-body .block:nth-of-type(6n+2) .block-title:after { background: var(--intro-bg-block-2); } -.intro-text-body .block:nth-of-type(6n+3) .block__title:after { +.intro-text-body .block:nth-of-type(6n+3) .block-title:after { background: var(--intro-bg-block-3); } -.intro-text-body .block:nth-of-type(6n+4) .block__title:after { +.intro-text-body .block:nth-of-type(6n+4) .block-title:after { background: var(--intro-bg-block-4); } -.intro-text-body .block:nth-of-type(6n+5) .block__title:after { +.intro-text-body .block:nth-of-type(6n+5) .block-title:after { background: var(--intro-bg-block-5); } -.intro-text-body .block:nth-of-type(6n+6) .block__title:after { +.intro-text-body .block:nth-of-type(6n+6) .block-title:after { background: var(--intro-bg-block-6); } #intro-badges { - display: none; + min-height: 3.2rem; text-align: center; + visibility: hidden; } #intro-badges img { - margin-bottom: 1.1rem; + margin-bottom: 0.25rem; +} +#intro-release:not([src]) { + display: none; } /* diff --git a/static/css/normalize.css b/assets/css/normalize.css similarity index 100% rename from static/css/normalize.css rename to assets/css/normalize.css diff --git a/static/favicon.ico b/assets/favicon.ico similarity index 100% rename from static/favicon.ico rename to assets/favicon.ico diff --git a/static/img/intro-header-sm.avif b/assets/img/intro-header-sm.avif similarity index 100% rename from static/img/intro-header-sm.avif rename to assets/img/intro-header-sm.avif diff --git a/static/img/intro-header-sm.jpg b/assets/img/intro-header-sm.jpg similarity index 100% rename from static/img/intro-header-sm.jpg rename to assets/img/intro-header-sm.jpg diff --git a/static/img/intro-header-sm.webp b/assets/img/intro-header-sm.webp similarity index 100% rename from static/img/intro-header-sm.webp rename to assets/img/intro-header-sm.webp diff --git a/static/img/intro-header.avif b/assets/img/intro-header.avif similarity index 100% rename from static/img/intro-header.avif rename to assets/img/intro-header.avif diff --git a/static/img/intro-header.jpg b/assets/img/intro-header.jpg similarity index 100% rename from static/img/intro-header.jpg rename to assets/img/intro-header.jpg diff --git a/static/img/intro-header.webp b/assets/img/intro-header.webp similarity index 100% rename from static/img/intro-header.webp rename to assets/img/intro-header.webp diff --git a/assets/js/basic.menu.min.js b/assets/js/basic.menu.min.js new file mode 100644 index 00000000..fa2c9f72 --- /dev/null +++ b/assets/js/basic.menu.min.js @@ -0,0 +1,3 @@ +var TopLinkDisclosureMenu=(function(){"use strict";function c(n,e){n===""||n.length===0||(typeof n=="string"?e.classList.add(n):e.classList.add(...n))}function d(n,e){n===""||n.length===0||(typeof n=="string"?e.classList.remove(n):e.classList.remove(...n))}function b(n,e){try{if(typeof e!="object"){const t=typeof e;throw new TypeError(`Elements given to isValidInstance() must be inside of an object. "${t}" given.`)}for(const t in e)if(!(e[t]instanceof n)){const s=typeof e[t];throw new TypeError(`${t} must be an instance of ${n.name}. "${s}" given.`)}return{status:!0,error:null}}catch(t){return{status:!1,error:t}}}function o(n,e){try{if(typeof e!="object"){const t=typeof e;throw new TypeError(`Values given to isValidType() must be inside of an object. "${t}" given.`)}for(const t in e){const s=typeof e[t];if(s!==n)throw new TypeError(`${t} must be a ${n}. "${s}" given.`)}return{status:!0,error:null}}catch(t){return{status:!1,error:t}}}function T(n){try{if(typeof n!="object"){const e=typeof n;throw new TypeError(`Values given to isQuerySelector() must be inside of an object. "${e}" given.`)}for(const e in n)try{if(n[e]===null)throw new Error;document.querySelector(n[e])}catch{throw new TypeError(`${e} must be a valid query selector. "${n[e]}" given.`)}return{status:!0,error:null}}catch(e){return{status:!1,error:e}}}function f(n){try{if(typeof n!="object"||Array.isArray(n)){const e=typeof n;throw new TypeError(`Values given to isValidClassList() must be inside of an object. "${e}" given.`)}for(const e in n){const t=typeof n[e];if(t!=="string")if(Array.isArray(n[e]))n[e].forEach(s=>{if(typeof s!="string")throw new TypeError(`${e} must be a string or an array of strings. An array containing non-strings given.`)});else throw new TypeError(`${e} must be a string or an array of strings. "${t}" given.`);else{const s={};s[e]=n[e],T(s)}}return{status:!0,error:null}}catch(e){return{status:!1,error:e}}}function K(n){try{if(typeof n!="object"){const t=typeof n;throw new TypeError(`Values given to isValidState() must be inside of an object. "${t}" given.`)}const e=["none","self","child"];for(const t in n)if(!e.includes(n[t]))throw new TypeError(`${t} must be one of the following values: ${e.join(", ")}. "${n[t]}" given.`);return{status:!0,error:null}}catch(e){return{status:!1,error:e}}}function q(n){try{if(typeof n!="object"){const t=typeof n;throw new TypeError(`Values given to isValidEvent() must be inside of an object. "${t}" given.`)}const e=["none","mouse","keyboard","character"];for(const t in n)if(!e.includes(n[t]))throw new TypeError(`${t} must be one of the following values: ${e.join(", ")}. "${n[t]}" given.`);return{status:!0,error:null}}catch(e){return{status:!1,error:e}}}function O(n){try{if(typeof n!="object"){const t=typeof n;throw new TypeError(`Values given to isValidHoverType() must be inside of an object. "${t}" given.`)}const e=["off","on","dynamic"];for(const t in n)if(!e.includes(n[t]))throw new TypeError(`${t} must be one of the following values: ${e.join(", ")}. "${n[t]}" given.`);return{status:!0,error:null}}catch(e){return{status:!1,error:e}}}function x(n,e){if(o("string",{tagName:n}).status&&b(HTMLElement,e).status){const t=n.toLowerCase();let s=!0;for(const i in e)e[i].tagName.toLowerCase()!==t&&(s=!1);return s}else return!1}class j{_dom={toggle:null,parent:null};_elements={controlledMenu:null,parentMenu:null};_open=!1;_expandEvent=new CustomEvent("accessibleMenuExpand",{bubbles:!0,detail:{toggle:this}});_collapseEvent=new CustomEvent("accessibleMenuCollapse",{bubbles:!0,detail:{toggle:this}});constructor({menuToggleElement:e,parentElement:t,controlledMenu:s,parentMenu:i=null}){this._dom.toggle=e,this._dom.parent=t,this._elements.controlledMenu=s,this._elements.parentMenu=i}initialize(){this._setIds(),this._setAriaAttributes(),this._collapse(!1)}get dom(){return this._dom}get elements(){return this._elements}get isOpen(){return this._open}set isOpen(e){o("boolean",{isOpen:e}),this._open=e}_setIds(){if(this.dom.toggle.id===""||this.elements.controlledMenu.dom.menu.id===""){let e=this.dom.toggle.innerText?.replace(/[^a-zA-Z0-9\s]/g,"")||"",t=this.elements.controlledMenu.key;!e.replace(/\s/g,"").length&&this.dom.toggle.getAttribute("aria-label")&&(e=this.dom.toggle.getAttribute("aria-label").replace(/[^a-zA-Z0-9\s]/g,"")),e.replace(/\s/g,"").length>0&&(e=e.toLowerCase().replace(/\s+/g,"-"),e.startsWith("-")&&(e=e.substring(1)),e.endsWith("-")&&(e=e.slice(0,-1)),t=`${e}-${t}`),this.dom.toggle.id=this.dom.toggle.id||`menu-button-${t}`,this.elements.controlledMenu.dom.menu.id=this.elements.controlledMenu.dom.menu.id||`menu-${t}`}}_setAriaAttributes(){this.dom.toggle.setAttribute("aria-expanded","false"),this.elements.controlledMenu.dom.menu.setAttribute("aria-labelledby",this.dom.toggle.id)}_expand(e=!0){const{closeClass:t,openClass:s,transitionClass:i,openDuration:r}=this.elements.controlledMenu;this.dom.toggle.setAttribute("aria-expanded","true"),this.elements.controlledMenu.elements.rootMenu.hasOpened=!0,i!==""?(c(i,this.elements.controlledMenu.dom.menu),requestAnimationFrame(()=>{d(t,this.elements.controlledMenu.dom.menu),requestAnimationFrame(()=>{c(s,this.elements.controlledMenu.dom.menu),requestAnimationFrame(()=>{setTimeout(()=>{d(i,this.elements.controlledMenu.dom.menu)},r)})})})):(c(s,this.elements.controlledMenu.dom.menu),d(t,this.elements.controlledMenu.dom.menu)),e&&this.dom.toggle.dispatchEvent(this._expandEvent)}_collapse(e=!0){const{closeClass:t,openClass:s,transitionClass:i,closeDuration:r}=this.elements.controlledMenu;this.dom.toggle.setAttribute("aria-expanded","false"),i!==""?(c(i,this.elements.controlledMenu.dom.menu),requestAnimationFrame(()=>{d(s,this.elements.controlledMenu.dom.menu),requestAnimationFrame(()=>{c(t,this.elements.controlledMenu.dom.menu),requestAnimationFrame(()=>{setTimeout(()=>{d(i,this.elements.controlledMenu.dom.menu)},r)})})})):(c(t,this.elements.controlledMenu.dom.menu),d(s,this.elements.controlledMenu.dom.menu)),e&&this.dom.toggle.dispatchEvent(this._collapseEvent)}open(){this.elements.controlledMenu.focusState="self",this.isOpen||(this._expand(),this.isOpen=!0)}preview(){this.elements.parentMenu&&(this.elements.parentMenu.focusState="self"),this.isOpen||(this._expand(),this.isOpen=!0)}close(){this.isOpen&&(this.elements.controlledMenu.blur(),this.elements.parentMenu&&(this.elements.parentMenu.focusState="self"),this._collapse(),this.isOpen=!1)}toggle(){this.isOpen?this.close():this.open()}closeSiblings(){this.elements.parentMenu&&this.elements.parentMenu.elements.submenuToggles.forEach(e=>{e!==this&&e.close()})}closeChildren(){this.elements.controlledMenu.elements.submenuToggles.forEach(e=>e.close())}}class F{_dom={item:null,link:null};_elements={parentMenu:null,childMenu:null,toggle:null};_submenu=!1;constructor({menuItemElement:e,menuLinkElement:t,parentMenu:s,isSubmenuItem:i=!1,childMenu:r=null,toggle:l=null}){this._dom.item=e,this._dom.link=t,this._elements.parentMenu=s,this._elements.childMenu=r,this._elements.toggle=l,this._submenu=i}initialize(){}get dom(){return this._dom}get elements(){return this._elements}get isSubmenuItem(){return this._submenu}focus(){this.elements.parentMenu.shouldFocus&&requestAnimationFrame(()=>{this.dom.link.focus()})}blur(){this.elements.parentMenu.shouldFocus&&requestAnimationFrame(()=>{this.dom.link.blur()})}}function C(n){try{const e=n.key||n.keyCode,t={Enter:e==="Enter"||e===13,Space:e===" "||e==="Spacebar"||e===32,Escape:e==="Escape"||e==="Esc"||e===27,ArrowUp:e==="ArrowUp"||e==="Up"||e===38,ArrowRight:e==="ArrowRight"||e==="Right"||e===39,ArrowDown:e==="ArrowDown"||e==="Down"||e===40,ArrowLeft:e==="ArrowLeft"||e==="Left"||e===37,Home:e==="Home"||e===36,End:e==="End"||e===35,Character:isNaN(e)&&!!e.match(/^[a-zA-Z]{1}$/),Tab:e==="Tab"||e===9,Asterisk:e==="*"||e===56};return Object.keys(t).find(s=>t[s]===!0)||""}catch{return""}}function a(n){n.preventDefault(),n.stopPropagation()}class M{_MenuType=M;_MenuItemType=F;_MenuToggleType=j;_dom={menu:null,menuItems:[],submenuItems:[],submenuToggles:[],submenus:[],controller:null,container:null};_selectors={menuItems:"",menuLinks:"",submenuItems:"",submenuToggles:"",submenus:""};_elements={menuItems:[],submenuToggles:[],controller:null,parentMenu:null,rootMenu:null};_openClass="show";_closeClass="hide";_transitionClass="transitioning";_transitionDuration=250;_openDuration=-1;_closeDuration=-1;_root=!0;_currentChild=0;_focusState="none";_currentEvent="none";_hoverType="off";_hoverDelay=250;_enterDelay=-1;_leaveDelay=-1;_prefix="am-";_hoverTimeout=null;_hasOpened=!1;_key="";_errors=[];constructor({menuElement:e,menuItemSelector:t="li",menuLinkSelector:s="a",submenuItemSelector:i="li:has(ul)",submenuToggleSelector:r="a",submenuSelector:l="ul",controllerElement:h=null,containerElement:m=null,openClass:p="show",closeClass:g="hide",transitionClass:_="transitioning",transitionDuration:y=250,openDuration:u=-1,closeDuration:v=-1,isTopLevel:E=!0,parentMenu:w=null,hoverType:k="off",hoverDelay:S=250,enterDelay:L=-1,leaveDelay:I=-1,prefix:A="am-",key:$=null}){this._dom.menu=e,this._dom.controller=h,this._dom.container=m,this._selectors.menuItems=t,this._selectors.menuLinks=s,this._selectors.submenuItems=i,this._selectors.submenuToggles=r,this._selectors.submenus=l,this._elements.menuItems=[],this._elements.submenuToggles=[],this._elements.controller=null,this._elements.parentMenu=w,this._elements.rootMenu=E?this:null,this._openClass=p||"",this._closeClass=g||"",this._transitionClass=_||"",this._transitionDuration=y,this._openDuration=u,this._closeDuration=v,this._prefix=A||"",this._key=$||"",this._root=E,this._hoverType=k,this._hoverDelay=S,this._enterDelay=L,this._leaveDelay=I}initialize(){if(!this._validate())throw new Error(`AccessibleMenu: cannot initialize menu. The following errors have been found: + - ${this.errors.join(` + - `)}`);if(this.elements.rootMenu===null&&this._findRootMenu(this),this._generateKey(),this._setDOMElements(),this.isTopLevel){if(this._setIds(),this.dom.controller&&this.dom.container){const e=new this._MenuToggleType({menuToggleElement:this.dom.controller,parentElement:this.dom.container,controlledMenu:this});x("button",{toggle:e.dom.toggle})||e.dom.toggle.setAttribute("role","button"),e.dom.toggle.setAttribute("aria-controls",this.dom.menu.id),this._elements.controller=e}window.AccessibleMenu=window.AccessibleMenu||{menus:{}},typeof window.AccessibleMenu.menus!="object"&&(window.AccessibleMenu.menus={}),window.AccessibleMenu.menus[this.dom.menu.id]=this}this._createChildElements(),this._setTransitionDurations()}get dom(){return this._dom}get selectors(){return this._selectors}get elements(){return this._elements}get isTopLevel(){return this._root}get key(){if(this.isTopLevel)return this._key;const e=this.elements.parentMenu.dom.submenus.indexOf(this.dom.menu)||0;return`${this.elements.parentMenu.key}-${e}`}get errors(){return this._errors}get openClass(){return this.isTopLevel?this._openClass:this.elements.rootMenu.openClass}set openClass(e){f({openClass:e}),this._openClass!==e&&(this._openClass=e)}get closeClass(){return this.isTopLevel?this._closeClass:this.elements.rootMenu.closeClass}set closeClass(e){f({closeClass:e}),this._closeClass!==e&&(this._closeClass=e)}get transitionClass(){return this.isTopLevel?this._transitionClass:this.elements.rootMenu.transitionClass}set transitionClass(e){f({transitionClass:e}),this._transitionClass!==e&&(this._transitionClass=e)}get transitionDuration(){return this.isTopLevel?this._transitionDuration:this.elements.rootMenu.transitionDuration}set transitionDuration(e){o("number",{transitionDuration:e}),this._transitionDuration!==e&&(this._transitionDuration=e,this._setTransitionDurations())}get openDuration(){return this._openDuration===-1?this.transitionDuration:this.isTopLevel?this._openDuration:this.elements.rootMenu.openDuration}set openDuration(e){o("number",{openDuration:e}),this._openDuration!==e&&(this._openDuration=e,this._setTransitionDurations())}get closeDuration(){return this._closeDuration===-1?this.transitionDuration:this.isTopLevel?this._closeDuration:this.elements.rootMenu.closeDuration}set closeDuration(e){o("number",{closeDuration:e}),this._closeDuration!==e&&(this._closeDuration=e,this._setTransitionDurations())}get currentChild(){return this._currentChild}set currentChild(e){o("number",{currentChild:e});function t(s){if(["mouse","character"].includes(s.currentEvent)&&s.elements.parentMenu){let r=0,l=!1;for(;!l&&r=this.elements.menuItems.length?(this._currentChild=this.elements.menuItems.length-1,t(this)):this.focusChild!==e&&(this._currentChild=e,t(this))}get focusState(){return this._focusState}set focusState(e){K({focusState:e}),this._focusState!==e&&(this._focusState=e),this.elements.submenuToggles.length>0&&(e==="self"||e==="none")&&this.elements.submenuToggles.forEach(t=>{t.elements.controlledMenu.focusState="none"}),this.elements.parentMenu&&(e==="self"||e==="child")&&(this.elements.parentMenu.focusState="child")}get currentEvent(){return this._currentEvent}set currentEvent(e){q({currentEvent:e}),this._currentEvent!==e&&(this._currentEvent=e,this.elements.submenuToggles.length>0&&this.elements.submenuToggles.forEach(t=>{t.elements.controlledMenu.currentEvent=e}))}get currentMenuItem(){return this.elements.menuItems[this.currentChild]}get hoverType(){return this._root?this._hoverType:this.elements.rootMenu.hoverType}set hoverType(e){O({hoverType:e}),this._hoverType!==e&&(this._hoverType=e)}get hoverDelay(){return this._root?this._hoverDelay:this.elements.rootMenu.hoverDelay}set hoverDelay(e){o("number",{hoverDelay:e}),this._hoverDelay!==e&&(this._hoverDelay=e)}get enterDelay(){return this._enterDelay===-1?this.hoverDelay:this._root?this._enterDelay:this.elements.rootMenu.enterDelay}set enterDelay(e){o("number",{enterDelay:e}),this._enterDelay!==e&&(this._enterDelay=e)}get leaveDelay(){return this._leaveDelay===-1?this.hoverDelay:this._root?this._leaveDelay:this.elements.rootMenu.leaveDelay}set leaveDelay(e){o("number",{leaveDelay:e}),this._leaveDelay!==e&&(this._leaveDelay=e)}get prefix(){return this._root?this._prefix:this.elements.rootMenu.prefix}set prefix(e){o("string",{prefix:e}),this._prefix!==e&&(this._prefix=e)}get shouldFocus(){let e=!1;return(this.currentEvent==="keyboard"||this.currentEvent==="character")&&(e=!0),this.currentEvent==="mouse"&&this.hoverType==="dynamic"&&(e=!0),e}get hasOpened(){return this._root?this._hasOpened:this.elements.rootMenu.hasOpened}set hasOpened(e){o("boolean",{hasOpened:e}),this._hasOpened!==e&&(this._hasOpened=e)}_validate(){let e=!0,t;this._dom.container!==null||this._dom.controller!==null?t=b(HTMLElement,{menuElement:this._dom.menu,controllerElement:this._dom.controller,containerElement:this._dom.container}):t=b(HTMLElement,{menuElement:this._dom.menu}),t.status||(this._errors.push(t.error.message),e=!1);let s;if(this._selectors.submenuItems!==""?s=T({menuItemSelector:this._selectors.menuItems,menuLinkSelector:this._selectors.menuLinks,submenuItemSelector:this._selectors.submenuItems,submenuToggleSelector:this._selectors.submenuToggles,submenuSelector:this._selectors.submenus}):s=T({menuItemSelector:this._selectors.menuItems,menuLinkSelector:this._selectors.menuLinks}),s.status||(this._errors.push(s.error.message),e=!1),this._openClass!==""){const u=f({openClass:this._openClass});u.status||(this._errors.push(u.error.message),e=!1)}if(this._closeClass!==""){const u=f({closeClass:this._closeClass});u.status||(this._errors.push(u.error.message),e=!1)}if(this._transitionClass!==""){const u=f({transitionClass:this._transitionClass});u.status||(this._errors.push(u.error.message),e=!1)}const i=o("number",{transitionDuration:this._transitionDuration});i.status||(this._errors.push(i.error.message),e=!1);const r=o("number",{openDuration:this._openDuration});r.status||(this._errors.push(r.error.message),e=!1);const l=o("number",{closeDuration:this._closeDuration});l.status||(this._errors.push(l.error.message),e=!1);const h=o("boolean",{isTopLevel:this._root});if(h.status||(this._errors.push(h.error.message),e=!1),this._elements.parentMenu!==null){const u=b(M,{parentMenu:this._elements.parentMenu});u.status||(this._errors.push(u.error.message),e=!1)}const m=O({hoverType:this._hoverType});m.status||(this._errors.push(m.error.message),e=!1);const p=o("number",{hoverDelay:this._hoverDelay});p.status||(this._errors.push(p.error.message),e=!1);const g=o("number",{enterDelay:this._enterDelay});g.status||(this._errors.push(g.error.message),e=!1);const _=o("number",{leaveDelay:this._leaveDelay});if(_.status||(this._errors.push(_.error.message),e=!1),this._key!==""){const u=o("string",{key:this._key});u.status||(this._errors.push(u.error.message),e=!1)}const y=o("string",{prefix:this._prefix});return y.status||(this._errors.push(y.error.message),e=!1),e}_setDOMElementType(e,t=this.dom.menu,s=!0){if(typeof this.selectors[e]=="string"){if(!Array.isArray(this.dom[e]))throw new Error(`AccessibleMenu: The "${e}" element cannot be set through _setDOMElementType.`);t!==this.dom.menu&&b(HTMLElement,{base:t});const r=Array.from(t.querySelectorAll(this.selectors[e])).filter(l=>l.parentElement===t);s?this._dom[e]=r:this._dom[e]=[...this._dom[e],...r]}else throw new Error(`AccessibleMenu: "${e}" is not a valid element type within the menu.`)}_resetDOMElementType(e){if(typeof this.dom[e]<"u"){if(!Array.isArray(this.dom[e]))throw new Error(`AccessibleMenu: The "${e}" element cannot be reset through _resetDOMElementType.`);this._dom[e]=[]}else throw new Error(`AccessibleMenu: "${e}" is not a valid element type within the menu.`)}_setDOMElements(){this._setDOMElementType("menuItems"),this.selectors.submenuItems!==""&&(this._setDOMElementType("submenuItems"),this._resetDOMElementType("submenuToggles"),this._resetDOMElementType("submenus"),this.dom.submenuItems.forEach(e=>{this._setDOMElementType("submenuToggles",e,!1),this._setDOMElementType("submenus",e,!1)}))}_generateKey(e=!1){(this.key===""||e)&&(this._key=Math.random().toString(36).replace(/[^a-z]+/g,"").substring(0,10))}_setIds(){this.dom.menu.id=this.dom.menu.id||`menu-${this.key}`,this.dom.container&&(this.dom.container.id=this.dom.container.id||`menu-container-${this.key}`),this.dom.controller&&(this.dom.controller.id=this.dom.controller.id||`menu-controller-${this.key}`)}_findRootMenu(e){if(e.isTopLevel)this._elements.rootMenu=e;else if(e.elements.parentMenu!==null)this._findRootMenu(e.elements.parentMenu);else throw new Error("Cannot find root menu.")}_createChildElements(){this.dom.menuItems.forEach(e=>{let t;if(this.dom.submenuItems.includes(e)){const s=e.querySelector(this.selectors.submenuToggles),i=e.querySelector(this.selectors.submenus),r=new this._MenuType({menuElement:i,menuItemSelector:this.selectors.menuItems,menuLinkSelector:this.selectors.menuLinks,submenuItemSelector:this.selectors.submenuItems,submenuToggleSelector:this.selectors.submenuToggles,submenuSelector:this.selectors.submenus,openClass:this.openClass,closeClass:this.closeClass,transitionClass:this.transitionClass,transitionDuration:this.transitionDuration,openDuration:this.openDuration,closeDuration:this.closeDuration,isTopLevel:!1,parentMenu:this,hoverType:this.hoverType,hoverDelay:this.hoverDelay,enterDelay:this.enterDelay,leaveDelay:this.leaveDelay}),l=new this._MenuToggleType({menuToggleElement:s,parentElement:e,controlledMenu:r,parentMenu:this});this._elements.submenuToggles.push(l),t=new this._MenuItemType({menuItemElement:e,menuLinkElement:s,parentMenu:this,isSubmenuItem:!0,childMenu:r,toggle:l})}else{const s=e.querySelector(this.selectors.menuLinks);t=new this._MenuItemType({menuItemElement:e,menuLinkElement:s,parentMenu:this})}this._elements.menuItems.push(t)})}_clearTimeout(){clearTimeout(this._hoverTimeout)}_setTimeout(e,t){o("function",{callback:e}),o("number",{delay:t}),this._hoverTimeout=setTimeout(e,t)}_handleFocus(){this.elements.menuItems.forEach((e,t)=>{e.dom.link.addEventListener("focus",()=>{this.focusState="self",this.currentChild=t})}),this.dom.menu.addEventListener("focusout",e=>{this.currentEvent!=="keyboard"||e.relatedTarget===null||this.dom.menu.contains(e.relatedTarget)||(this.focusState="none",this.closeChildren())})}_handleClick(){function e(t,s,i){a(i),i.button===0&&(s.toggle(),s.isOpen&&(t.focusState="self",s.elements.controlledMenu.focusState="none"))}this.elements.menuItems.forEach((t,s)=>{t.dom.link.addEventListener("click",()=>{this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this._clearTimeout(),this.focusChild(s)},{passive:!0}),t.isSubmenuItem&&t.elements.toggle.dom.toggle.addEventListener("click",i=>{this.currentEvent="mouse",e(this,t.elements.toggle,i)})}),this.isTopLevel&&this.elements.controller&&this.elements.controller.dom.toggle.addEventListener("click",t=>{this.currentEvent="mouse",e(this,this.elements.controller,t)}),document.addEventListener("click",t=>{this.focusState!=="none"&&(this.currentEvent="mouse",!this.dom.menu.contains(t.target)&&!this.dom.menu!==t.target&&(this.elements.rootMenu.hasOpened=this.elements.submenuToggles.some(s=>s.isOpen)))})}_handleHover(){this.elements.menuItems.forEach((e,t)=>{e.dom.link.addEventListener("pointerenter",s=>{s.pointerType==="pen"||s.pointerType==="touch"||(this.hoverType==="on"?(this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this.focusChild(t),e.isSubmenuItem&&(this.enterDelay>0?(this._clearTimeout(),this._setTimeout(()=>{e.elements.toggle.preview()},this.enterDelay)):e.elements.toggle.preview())):this.hoverType==="dynamic"&&(this.currentChild=t,(!this.isTopLevel||this.focusState!=="none")&&(this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this.focusCurrentChild()),(!this.isTopLevel||this.hasOpened)&&(this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this.focusCurrentChild(),e.isSubmenuItem?this.enterDelay>0?(this._clearTimeout(),this._setTimeout(()=>{e.elements.toggle.preview()},this.enterDelay)):e.elements.toggle.preview():this.enterDelay>0?(this._clearTimeout(),this._setTimeout(()=>{this.closeChildren()},this.enterDelay)):this.closeChildren())))}),e.isSubmenuItem&&(e.dom.item.addEventListener("pointerleave",s=>{s.pointerType==="pen"||s.pointerType==="touch"||(this.hoverType==="on"?this.leaveDelay>0?(this._clearTimeout(),this._setTimeout(()=>{this.currentEvent="mouse",e.elements.toggle.close()},this.leaveDelay)):(this.currentEvent="mouse",e.elements.toggle.close()):this.hoverType==="dynamic"&&(this.leaveDelay>0?(this._clearTimeout(),this._setTimeout(()=>{this.currentEvent="mouse"},this.leaveDelay)):this.currentEvent="mouse"))}),e.dom.item.addEventListener("pointerenter",s=>{s.pointerType==="pen"||s.pointerType==="touch"||e.isSubmenuItem&&(this.hoverType==="on"||this.hoverType==="dynamic")&&this.leaveDelay>0&&this._clearTimeout()}))})}_handleKeydown(){this.isTopLevel&&this.elements.controller&&this.elements.controller.dom.toggle.addEventListener("keydown",e=>{this.currentEvent="keyboard";const t=C(e);(t==="Space"||t==="Enter")&&a(e)})}_handleKeyup(){this.isTopLevel&&this.elements.controller&&this.elements.controller.dom.toggle.addEventListener("keyup",e=>{this.currentEvent="keyboard";const t=C(e);(t==="Space"||t==="Enter")&&(a(e),this.elements.controller.toggle(),this.elements.controller.isOpen&&this.focusFirstChild())})}_setTransitionDurations(){this.dom.menu.style.setProperty(`--${this.prefix}transition-duration`,`${this.transitionDuration}ms`),this.dom.menu.style.setProperty(`--${this.prefix}open-transition-duration`,`${this.openDuration}ms`),this.dom.menu.style.setProperty(`--${this.prefix}close-transition-duration`,`${this.closeDuration}ms`)}focus(){this.focusState="self",this.shouldFocus&&this.dom.menu.focus()}blur(){this.focusState="none",this.shouldFocus&&this.dom.menu.blur()}focusCurrentChild(){this.focusState="self",this.currentChild!==-1&&this.currentMenuItem.focus()}focusChild(e){this.blurCurrentChild(),this.currentChild=e,this.focusCurrentChild()}focusFirstChild(){this.focusChild(0)}focusLastChild(){this.focusChild(this.elements.menuItems.length-1)}focusNextChild(){this.currentChild0?this.focusChild(this.currentChild-1):this.focusCurrentChild()}blurCurrentChild(){this.focusState="none",this.currentChild!==-1&&this.currentMenuItem.blur()}focusController(){this.dom.controller&&(this.shouldFocus&&this.dom.controller.focus(),this.focusState="none")}focusContainer(){this.dom.container&&(this.shouldFocus&&this.dom.container.focus(),this.focusState="none")}closeChildren(){this.elements.submenuToggles.forEach(e=>e.close())}blurChildren(){this.elements.menuItems.forEach(e=>{e.blur(),e.isSubmenuItem&&e.elements.childMenu.blurChildren()})}}class V extends F{_elements={parentMenu:null,childMenu:null,toggle:null,sibling:null};constructor({menuItemElement:e,menuLinkElement:t,parentMenu:s,isSubmenuItem:i=!1,childMenu:r=null,toggle:l=null,initialize:h=!0,submenuSibling:m=null}){super({menuItemElement:e,menuLinkElement:t,parentMenu:s,isSubmenuItem:i,childMenu:r,toggle:l}),this._elements.parentMenu=s,this._elements.childMenu=r,this._elements.toggle=l,this._elements.sibling=m,h&&this.initialize()}}class z extends j{constructor({menuToggleElement:e,parentElement:t,controlledMenu:s,parentMenu:i=null,initialize:r=!0}){super({menuToggleElement:e,parentElement:t,controlledMenu:s,parentMenu:i}),r&&this.initialize()}_setAriaAttributes(){super._setAriaAttributes(),x("button",{toggle:this.dom.toggle})||this.dom.toggle.setAttribute("role","button"),this.dom.toggle.setAttribute("aria-controls",this.elements.controlledMenu.dom.menu.id)}open(){this.closeSiblings(),super.open()}preview(){this.closeSiblings(),super.preview()}close(){this.isOpen&&this.closeChildren(),super.close()}}class D extends M{_MenuType=D;_MenuItemType=V;_MenuToggleType=z;_currentChild=-1;_selectors={menuItems:"",menuLinks:"",submenuItems:"",submenuToggles:"",submenus:"",submenuSubtoggles:""};_optionalSupport=!1;constructor({menuElement:e,menuItemSelector:t="li",menuLinkSelector:s="a",submenuItemSelector:i="li:has(ul)",submenuToggleSelector:r="button",submenuSelector:l="ul",submenuSubtoggleSelector:h="a",controllerElement:m=null,containerElement:p=null,openClass:g="show",closeClass:_="hide",transitionClass:y="transitioning",transitionDuration:u=250,isTopLevel:v=!0,parentMenu:E=null,hoverType:w="off",hoverDelay:k=250,enterDelay:S=-1,leaveDelay:L=-1,optionalKeySupport:I=!1,prefix:A="am-",key:$=null,initialize:H=!0}){super({menuElement:e,menuItemSelector:t,menuLinkSelector:s,submenuItemSelector:i,submenuSelector:l,submenuToggleSelector:r,controllerElement:m,containerElement:p,openClass:g,closeClass:_,transitionClass:y,transitionDuration:u,isTopLevel:v,parentMenu:E,hoverType:w,hoverDelay:k,enterDelay:S,leaveDelay:L,prefix:A,key:$}),this._optionalSupport=I,this._selectors.menuItems=t,this._selectors.submenuItems=i,this._selectors.submenuToggles=r,this._selectors.submenus=l,this._selectors.submenuSubtoggles=h,this._selectors.menuLinks=[...new Set([s,r])].join(","),H&&this.initialize()}initialize(){try{super.initialize(),this._handleFocus(),this._handleClick(),this._handleHover(),this._handleKeydown(),this._handleKeyup()}catch(e){console.error(e)}}get optionalKeySupport(){return this.isTopLevel?this._optionalSupport:this.elements.rootMenu.optionalKeySupport}set optionalKeySupport(e){o("boolean",{optionalKeySupport:e}),this._optionalSupport=e}_createChildElements(){this.dom.menuItems.forEach(e=>{let t,s;const i=e.querySelector(this.selectors.menuLinks);if(this.dom.submenuItems.includes(e)){const r=e.querySelector(this.selectors.submenuToggles),l=e.querySelector(this.selectors.submenus),h=new this._MenuType({menuElement:l,menuItemSelector:this.selectors.menuItems,menuLinkSelector:this.selectors.menuLinks,submenuItemSelector:this.selectors.submenuItems,submenuToggleSelector:this.selectors.submenuSubtoggles,submenuSelector:this.selectors.submenus,submenuSubtoggleSelector:this.selectors.submenuSubtoggles,openClass:this.openClass,closeClass:this.closeClass,transitionClass:this.transitionClass,isTopLevel:!1,parentMenu:this,hoverType:this.hoverType,hoverDelay:this.hoverDelay,enterDelay:this.enterDelay,leaveDelay:this.leaveDelay}),m=new this._MenuToggleType({menuToggleElement:r,parentElement:e,controlledMenu:h,parentMenu:this});this._elements.submenuToggles.push(m),r!==i?(s=new this._MenuItemType({menuItemElement:e,menuLinkElement:r,parentMenu:this,isSubmenuItem:!0,childMenu:h,toggle:m}),t=new this._MenuItemType({menuItemElement:e,menuLinkElement:i,parentMenu:this,submenuSibling:s})):t=new this._MenuItemType({menuItemElement:e,menuLinkElement:i,parentMenu:this,isSubmenuItem:!0,childMenu:h,toggle:m})}else t=new this._MenuItemType({menuItemElement:e,menuLinkElement:i,parentMenu:this});this._elements.menuItems.push(t),typeof s<"u"&&this._elements.menuItems.push(s)})}_validate(){let e=super._validate();const t=T({submenuSubtoggleSelector:this._selectors.submenuSubtoggles});t.status||(this._errors.push(t.error.message),e=!1);const s=o("boolean",{optionalKeySupport:this._optionalSupport});return s.status||(this._errors.push(s.error.message),e=!1),e}_handleClick(){super._handleClick(),document.addEventListener("click",e=>{this.focusState!=="none"&&(this.currentEvent="mouse",!this.dom.menu.contains(e.target)&&!this.dom.menu!==e.target&&(this.closeChildren(),this.blur(),this.elements.controller&&this.elements.controller.close(),this.elements.rootMenu.hasOpened=!1))})}_handleHover(){this.elements.menuItems.forEach((e,t)=>{e.dom.link.addEventListener("pointerenter",s=>{if(!(s.pointerType==="pen"||s.pointerType==="touch")){if(this.hoverType==="on"){this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this.focusChild(t);let i=e.isSubmenuItem?e.elements.toggle:null;if(e.elements.sibling!==null&&(i=e.elements.sibling.elements.toggle),i===null)return;this.enterDelay>0?(this._clearTimeout(),this._setTimeout(()=>{i.preview()},this.enterDelay)):i.preview()}else if(this.hoverType==="dynamic"&&(this.currentChild=t,(!this.isTopLevel||this.focusState!=="none")&&(this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this.focusCurrentChild()),!this.isTopLevel||this.hasOpened)){this.currentEvent="mouse",this.elements.rootMenu.blurChildren(),this.focusCurrentChild();let i=e.isSubmenuItem?e.elements.toggle:null;e.elements.sibling!==null&&(i=e.elements.sibling.elements.toggle),i!==null?this.enterDelay>0?(this._clearTimeout(),this._setTimeout(()=>{i.preview()},this.enterDelay)):i.preview():this.enterDelay>0?(this._clearTimeout(),this._setTimeout(()=>{this.closeChildren()},this.enterDelay)):this.closeChildren()}}}),e.isSubmenuItem&&(e.dom.item.addEventListener("pointerleave",s=>{s.pointerType==="pen"||s.pointerType==="touch"||(this.hoverType==="on"?this.leaveDelay>0?(this._clearTimeout(),this._setTimeout(()=>{this.currentEvent="mouse",e.elements.toggle.close()},this.leaveDelay)):(this.currentEvent="mouse",e.elements.toggle.close()):this.hoverType==="dynamic"&&(this.leaveDelay>0?(this._clearTimeout(),this._setTimeout(()=>{this.currentEvent="mouse"},this.leaveDelay)):this.currentEvent="mouse"))}),e.dom.item.addEventListener("pointerenter",s=>{s.pointerType==="pen"||s.pointerType==="touch"||e.isSubmenuItem&&(this.hoverType==="on"||this.hoverType==="dynamic")&&this.leaveDelay>0&&this._clearTimeout()}))})}_handleKeydown(){super._handleKeydown(),this.dom.menu.addEventListener("keydown",e=>{this.currentEvent="keyboard";const t=C(e);if(this.focusState==="self"){const s=["Space","Enter"],i=["Escape"],r=["Escape"],l=["ArrowUp","ArrowRight","ArrowDown","ArrowLeft","Home","End"];(s.includes(t)||this.optionalKeySupport&&l.includes(t)||this.elements.controller&&i.includes(t)||this.elements.parentMenu&&r.includes(t))&&a(e)}})}_handleKeyup(){super._handleKeyup(),this.dom.menu.addEventListener("keyup",e=>{this.currentEvent="keyboard";const t=C(e);this.focusState==="self"&&(t==="Space"||t==="Enter"?(a(e),this.currentMenuItem.isSubmenuItem?this.currentMenuItem.elements.toggle.isOpen?this.currentMenuItem.elements.toggle.close():this.currentMenuItem.elements.toggle.preview():this.currentMenuItem.dom.link.click()):t==="Escape"?this.elements.submenuToggles.some(i=>i.isOpen)?(a(e),this.closeChildren()):this.elements.parentMenu?(a(e),this.elements.parentMenu.currentEvent=this.currentEvent,this.elements.parentMenu.closeChildren(),this.elements.parentMenu.focusCurrentChild()):this.isTopLevel&&this.elements.controller&&this.elements.controller.isOpen&&(this.elements.controller.close(),this.focusController()):this.optionalKeySupport&&(t==="ArrowDown"||t==="ArrowRight"?(a(e),this.currentMenuItem.isSubmenuItem&&this.currentMenuItem.elements.toggle.isOpen?(this.currentMenuItem.elements.childMenu.currentEvent="keyboard",this.currentMenuItem.elements.childMenu.focusFirstChild()):this.focusNextChild()):t==="ArrowUp"||t==="ArrowLeft"?(a(e),this.focusPreviousChild()):t==="Home"?(a(e),this.focusFirstChild()):t==="End"&&(a(e),this.focusLastChild())))})}}return D})(); diff --git a/assets/js/dropdown.init.js b/assets/js/dropdown.init.js new file mode 100644 index 00000000..8af103f8 --- /dev/null +++ b/assets/js/dropdown.init.js @@ -0,0 +1,26 @@ +// Conecta cada `Dropdown` (standalone o embebido en un `Nav`) con `accessible-menu` (ver +// basic.menu.min.js), que añade los roles ARIA, el `aria-expanded` y la navegación por teclado +// sobre el marcado ya generado en el servidor. Selecciona por clase, no por etiqueta, porque el +// disparador es un `" + ); +} + +#[pagetop::test] +async fn props_single_attr_in_html_macro() { + let p = Props::new("hx-get", "/api"); + assert_eq!( + html! { button (p) { "Load" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_multiple_attrs_preserve_order_in_html_macro() { + let p = Props::new("hx-get", "/api") + .with_prop(PropsOp::set("hx-target", "#result")) + .with_prop(PropsOp::set("hx-swap", "outerHTML")); + assert_eq!( + html! { button (p) {} }.into_string(), + r##""## + ); +} + +#[pagetop::test] +async fn props_alongside_class_and_id_in_html_macro() { + // The splice is always emitted after class and id, regardless of the order they are written in. + let p = Props::new("hx-get", "/api"); + assert_eq!( + html! { button #mybtn .btn (p) { "Go" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_alongside_named_attr_renders_after_it() { + let p = Props::new("hx-get", "/api"); + assert_eq!( + html! { button type="button" (p) {} }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_combined_via_chaining_instead_of_multiple_splices() { + // An element accepts only a single attribute splice (a second `(props)` on the same element + // is a compile error); values from separate sources are combined by chaining `with_prop()` + // on one `Props`, not by splicing two of them. + let p = Props::new("hx-get", "/api").with_prop(PropsOp::set("hx-swap", "outerHTML")); + assert_eq!( + html! { button (p) {} }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_inline_construction_in_html_macro() { + assert_eq!( + html! { button (Props::new("hx-get", "/api")) { "Go" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_conditional_expression_in_html_macro() { + for (active, expected) in [ + (true, r#""#), + (false, ""), + ] { + let markup = html! { + button (if active { Props::new("hx-get", "/api") } else { Props::default() }) { "x" } + }; + assert_eq!(markup.into_string(), expected); + } +} + +#[pagetop::test] +async fn props_splice_empty_string_emits_nothing() { + // An empty splice emits no attribute nor extra space. + assert_eq!(html! { span ("") { "x" } }.into_string(), "x"); +} + +// **< RenderAttrs: literal attribute collisions >************************************************** + +#[pagetop::test] +async fn props_id_collision_with_literal_omits_props_id() { + // A literal `#id` on the element takes precedence; `Props`'s own id is silently omitted instead + // of producing a duplicate `id` attribute. + let p = Props::default().with_id("from-props"); + assert_eq!( + html! { div #fixed (p) {} }.into_string(), + r#"
"# + ); +} + +#[pagetop::test] +async fn props_class_collision_with_literal_omits_props_classes() { + let p = Props::classes("from-props-a from-props-b"); + assert_eq!( + html! { div.fixed (p) {} }.into_string(), + r#"
"# + ); +} + +#[pagetop::test] +async fn props_style_collision_with_literal_omits_props_styles() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")); + assert_eq!( + html! { div style="color: blue" (p) {} }.into_string(), + r#"
"# + ); +} + +#[pagetop::test] +async fn props_named_attr_collision_with_literal_omits_props_value() { + let p = Props::default().with_prop(PropsOp::set("title", "from-props")); + assert_eq!( + html! { span title="literal" (p) {} }.into_string(), + r#""# + ); +} + +// **< is_attrs_empty / is_empty >****************************************************************** + +#[pagetop::test] +async fn props_is_attrs_empty_on_default() { + assert!(Props::default().is_attrs_empty()); +} + +#[pagetop::test] +async fn props_is_attrs_empty_false_after_set() { + assert!(!Props::new("hx-get", "/api").is_attrs_empty()); +} + +#[pagetop::test] +async fn props_is_attrs_empty_true_after_removing_last_attr() { + let p = Props::new("only", "one").with_prop(PropsOp::remove("only")); + assert!(p.is_attrs_empty()); +} + +#[pagetop::test] +async fn props_is_empty_on_default() { + assert!(Props::default().is_empty()); +} + +#[pagetop::test] +async fn props_is_empty_false_with_id() { + assert!(!Props::default().with_id("main").is_empty()); +} + +#[pagetop::test] +async fn props_is_empty_false_with_attr() { + assert!(!Props::new("hx-get", "/api").is_empty()); +} + +#[pagetop::test] +async fn props_is_empty_false_with_class() { + assert!(!Props::classes("btn").is_empty()); +} + +// **< get_prop("id") >***************************************************************************** + +#[pagetop::test] +async fn get_prop_id_returns_none_by_default() { + assert_eq!(Props::default().get_prop("id"), None); +} + +#[pagetop::test] +async fn get_prop_id_returns_normalized_value() { + let p = Props::default().with_id("My Button"); + assert_eq!(p.get_prop("id"), Some("my_button".to_string())); +} + +#[pagetop::test] +async fn get_prop_id_matches_get_id() { + let p = Props::default().with_id("Header"); + assert_eq!(p.get_prop("id"), p.get_id()); +} + +// **< Regression & edge cases >******************************************************************** + +#[pagetop::test] +async fn props_hx_target_value_with_hash_renders_correctly() { + // Regression: r#"..."# used to close prematurely when it found `"#list"`. + let p = Props::new("hx-target", "#list"); + assert_eq!( + html! { button (p) {} }.into_string(), + r##""## + ); +} + +#[pagetop::test] +async fn props_with_empty_value_renders_attr_with_empty_value() { + let p = Props::new("data-expanded", ""); + assert_eq!( + html! { span (p) {} }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_chained_set_and_remove_yields_expected_state() { + let p = Props::new("a", "1") + .with_prop(PropsOp::set("b", "2")) + .with_prop(PropsOp::set("c", "3")) + .with_prop(PropsOp::remove("b")) + .with_prop(PropsOp::set("a", "updated")); + assert_eq!(p.get_prop("a"), Some("updated".to_string())); + assert_eq!(p.get_prop("b"), None); + assert_eq!(p.get_prop("c"), Some("3".to_string())); + assert_eq!( + html! { span (p) {} }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_with_empty_attr_name_renders_without_validation() { + // Documented behavior: names are not validated; the resulting HTML is not standard. + let p = Props::new("", "val"); + assert_eq!( + html! { span (p) {} }.into_string(), + r#""# + ); +} diff --git a/tests/html_props_classes.rs b/tests/html_props_classes.rs new file mode 100644 index 00000000..cf06484a --- /dev/null +++ b/tests/html_props_classes.rs @@ -0,0 +1,327 @@ +use pagetop::prelude::*; + +fn assert_classes(p: &Props, expected: Option<&str>) { + let got = p.get_classes(); + assert_eq!( + got.as_deref(), + expected, + "Expected {:?}, got {:?}", + expected, + got + ); +} + +// **< Props::classes (construction) >************************************************************** + +#[pagetop::test] +async fn classes_new_empty_and_whitespace_is_empty() { + assert_classes(&Props::classes(""), None); + assert_classes(&Props::classes(" "), None); + assert_classes(&Props::classes("\t\n\r "), None); +} + +#[pagetop::test] +async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order() { + let p = Props::classes("Btn btn BTN btn-primary BTN-PRIMARY"); + assert_classes(&p, Some("btn btn-primary")); + assert!(p.has_all_classes("BTN")); + assert!(p.has_all_classes("btn-primary")); +} + +// **< PropsOp::add_classes >*********************************************************************** + +#[pagetop::test] +async fn classes_add_appends_unique_and_normalizes() { + let p = Props::classes("a b").with_prop(PropsOp::add_classes("C b D")); + assert_classes(&p, Some("a b c d")); +} + +#[pagetop::test] +async fn classes_add_ignores_empty_input() { + let p = Props::classes("a b").with_prop(PropsOp::add_classes(" \t")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_add_same_tokens_is_noop() { + let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_add_rejects_non_ascii_is_noop() { + let p = Props::classes("a b").with_prop(PropsOp::add_classes("c ñ d")); + assert_classes(&p, Some("a b")); +} + +// **< PropsOp::prepend_classes >******************************************************************* + +#[pagetop::test] +async fn classes_prepend_inserts_at_front_preserving_new_order() { + let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b")); + assert_classes(&p, Some("a b c d")); +} + +#[pagetop::test] +async fn classes_prepend_inserts_new_tokens_skipping_duplicates() { + let p = Props::classes("b c").with_prop(PropsOp::prepend_classes("a b d")); + assert_classes(&p, Some("a d b c")); +} + +#[pagetop::test] +async fn classes_prepend_ignores_empty_input() { + let p = Props::classes("a b").with_prop(PropsOp::prepend_classes("")); + assert_classes(&p, Some("a b")); +} + +// **< PropsOp::replace_classes >******************************************************************** + +#[pagetop::test] +async fn classes_replace_removes_targets_and_inserts_new_at_min_position() { + let p = Props::classes("a b c d").with_prop(PropsOp::replace_classes("c a", "x y")); + assert_classes(&p, Some("x y b d")); +} + +#[pagetop::test] +async fn classes_replace_when_none_found_does_nothing() { + let p = Props::classes("a b").with_prop(PropsOp::replace_classes("x y", "c d")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_replace_is_case_insensitive_on_targets_and_new_values_are_normalized() { + let p = Props::classes("btn btn-primary active") + .with_prop(PropsOp::replace_classes("BTN-PRIMARY", "Btn-Secondary")); + assert_classes(&p, Some("btn btn-secondary active")); +} + +#[pagetop::test] +async fn classes_replace_with_empty_new_removes_only() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_classes("b", " ")); + assert_classes(&p, Some("a c")); +} + +#[pagetop::test] +async fn classes_replace_dedups_against_existing_items() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_classes("b", "c d")); + assert_classes(&p, Some("a d c")); +} + +#[pagetop::test] +async fn classes_replace_ignores_target_whitespace_and_repetition() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_classes(" b b ", "x y")); + assert_classes(&p, Some("a x y c")); +} + +#[pagetop::test] +async fn classes_replace_rejects_non_ascii_targets_is_noop() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_classes("b ñ", "x")); + assert_classes(&p, Some("a b c")); +} + +// **< PropsOp::replace_all_classes >*************************************************************** + +#[pagetop::test] +async fn classes_replace_all_removes_targets_and_inserts_new_at_min_position() { + let p = Props::classes("a b c d").with_prop(PropsOp::replace_all_classes("c a", "x y")); + assert_classes(&p, Some("x y b d")); +} + +#[pagetop::test] +async fn classes_replace_all_when_missing_one_does_nothing_even_to_existing_one() { + let p = Props::classes("a b").with_prop(PropsOp::replace_all_classes("a x", "c d")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_replace_all_when_none_found_does_nothing() { + let p = Props::classes("a b").with_prop(PropsOp::replace_all_classes("x y", "c d")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_replace_all_is_case_insensitive_on_targets_and_new_values_are_normalized() { + let p = Props::classes("btn btn-primary active") + .with_prop(PropsOp::replace_all_classes("BTN ACTIVE", "Btn-Secondary")); + assert_classes(&p, Some("btn-secondary btn-primary")); +} + +#[pagetop::test] +async fn classes_replace_all_with_empty_new_removes_only() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("a b", " ")); + assert_classes(&p, Some("c")); +} + +#[pagetop::test] +async fn classes_replace_all_dedups_against_existing_items() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("a b", "c d")); + assert_classes(&p, Some("d c")); +} + +#[pagetop::test] +async fn classes_replace_all_ignores_target_whitespace_and_repetition() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes(" b b ", "x y")); + assert_classes(&p, Some("a x y c")); +} + +#[pagetop::test] +async fn classes_replace_all_rejects_non_ascii_targets_is_noop() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("b ñ", "x")); + assert_classes(&p, Some("a b c")); +} + +// **< PropsOp::set / remove ("class") >************************************************************ + +#[pagetop::test] +async fn classes_reset_replaces_entire_list_and_dedups() { + let p = Props::classes("a b c").with_prop(PropsOp::set("class", "X y y Z")); + assert_classes(&p, Some("x y z")); +} + +#[pagetop::test] +async fn classes_reset_with_empty_input_clears() { + let p = Props::classes("a b").with_prop(PropsOp::set("class", " \n ")); + assert_classes(&p, None); +} + +#[pagetop::test] +async fn classes_reset_with_non_ascii_is_noop() { + let p = Props::classes("a b").with_prop(PropsOp::set("class", "ñ")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_remove_attr_clears_all_classes() { + let p = Props::classes("btn primary").with_prop(PropsOp::remove("class")); + assert_classes(&p, None); +} + +// **< PropsOp::remove_classes >******************************************************************** + +#[pagetop::test] +async fn classes_remove_is_case_insensitive() { + let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes("B D")); + assert_classes(&p, Some("a c")); +} + +#[pagetop::test] +async fn classes_remove_non_existing_is_noop() { + let p = Props::classes("a b c").with_prop(PropsOp::remove_classes("x y z")); + assert_classes(&p, Some("a b c")); +} + +#[pagetop::test] +async fn classes_remove_with_extra_whitespace() { + let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes(" b\t\t \n d ")); + assert_classes(&p, Some("a c")); +} + +// **< has_classes / has_all_classes >************************************************************** + +#[pagetop::test] +async fn classes_contains_single() { + let p = Props::classes("btn btn-primary"); + assert!(p.has_all_classes("btn")); + assert!(p.has_all_classes("BTN")); + assert!(!p.has_all_classes("missing")); +} + +#[pagetop::test] +async fn classes_contains_all_and_any() { + let p = Props::classes("btn btn-primary active"); + assert!(p.has_classes("missing active")); + assert!(p.has_classes("BTN-PRIMARY missing")); + assert!(!p.has_classes("missing other")); + assert!(p.has_all_classes("btn active")); + assert!(p.has_all_classes("BTN BTN-PRIMARY")); + assert!(!p.has_all_classes("btn missing")); +} + +#[pagetop::test] +async fn classes_contains_empty_and_whitespace_is_false() { + let p = Props::classes("a b"); + assert!(!p.has_classes("")); + assert!(!p.has_classes(" \n ")); + assert!(!p.has_all_classes("")); + assert!(!p.has_all_classes(" \t")); +} + +#[pagetop::test] +async fn classes_contains_non_ascii_is_false() { + let p = Props::classes("a b"); + assert!(!p.has_classes("a ñ")); + assert!(!p.has_all_classes("ñ")); +} + +// **< is_classes_empty >*************************************************************************** + +#[pagetop::test] +async fn props_is_classes_empty_on_default() { + assert!(Props::default().is_classes_empty()); +} + +#[pagetop::test] +async fn props_is_classes_empty_false_after_add_classes() { + assert!(!Props::classes("btn").is_classes_empty()); +} + +#[pagetop::test] +async fn props_is_classes_empty_true_after_remove_class() { + let p = Props::classes("btn").with_prop(PropsOp::remove("class")); + assert!(p.is_classes_empty()); +} + +// **< get_classes / get_prop("class") >************************************************************ + +#[pagetop::test] +async fn classes_get_returns_none_when_empty_some_when_not() { + assert_classes(&Props::classes(" "), None); + assert_classes(&Props::classes("a"), Some("a")); +} + +#[pagetop::test] +async fn get_prop_class_returns_joined_classes() { + let p = Props::classes("btn btn-primary").with_prop(PropsOp::add_classes("active")); + assert_eq!( + p.get_prop("class"), + Some("btn btn-primary active".to_string()) + ); +} + +#[pagetop::test] +async fn get_prop_class_matches_get_classes() { + let p = Props::classes("btn active"); + assert_eq!(p.get_prop("class"), p.get_classes()); +} + +// **< HTML rendering >***************************************************************************** + +#[pagetop::test] +async fn props_classes_renders_class_attribute() { + let p = Props::classes("btn btn-primary"); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_classes_can_be_extended_with_add_classes() { + let p = Props::classes("btn").with_prop(PropsOp::add_classes("active")); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +// **< Combined sequences >************************************************************************* + +#[pagetop::test] +async fn classes_order_is_stable_for_existing_items() { + let p = Props::classes("a b c") + .with_prop(PropsOp::add_classes("d")) // a b c d + .with_prop(PropsOp::prepend_classes("x")) // x a b c d + .with_prop(PropsOp::remove_classes("b")) // x a c d + .with_prop(PropsOp::add_classes("b")); // x a c d b + assert_classes(&p, Some("x a c d b")); +} diff --git a/tests/html_props_extras.rs b/tests/html_props_extras.rs new file mode 100644 index 00000000..dd8ffad5 --- /dev/null +++ b/tests/html_props_extras.rs @@ -0,0 +1,120 @@ +use pagetop::prelude::*; + +// **< PropsOp::set_extra / remove_extra >********************************************************** + +#[pagetop::test] +async fn set_and_read_extra() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert_eq!(*props.extra::("ext.flag").unwrap(), true); +} + +#[pagetop::test] +async fn overwrite_extra() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.count", 1_u32)) + .with_prop(PropsOp::set_extra("ext.count", 2_u32)); + assert_eq!(*props.extra::("ext.count").unwrap(), 2); +} + +#[pagetop::test] +async fn remove_extra() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.flag", true)) + .with_prop(PropsOp::remove_extra("ext.flag")); + assert_eq!( + props.extra::("ext.flag"), + Err(PropsError::ExtraNotFound { key: "ext.flag" }) + ); +} + +// **< Props::extra >******************************************************************************* + +#[pagetop::test] +async fn extra_not_found() { + let props = Props::default(); + assert_eq!( + props.extra::("ext.missing"), + Err(PropsError::ExtraNotFound { key: "ext.missing" }) + ); +} + +#[pagetop::test] +async fn extra_type_mismatch() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert!(matches!( + props.extra::("ext.flag"), + Err(PropsError::ExtraTypeMismatch { .. }) + )); +} + +// **< Props::extra_or >**************************************************************************** + +#[pagetop::test] +async fn extra_or_returns_value_when_found() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert_eq!(props.extra_or("ext.flag", false), true); +} + +#[pagetop::test] +async fn extra_or_returns_default_on_missing() { + let props = Props::default(); + assert_eq!(props.extra_or("ext.flag", false), false); +} + +#[pagetop::test] +async fn extra_or_returns_default_on_type_mismatch() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert_eq!(props.extra_or("ext.flag", 0_u32), 0_u32); +} + +// **< Props::extra_or_default >******************************************************************** + +#[pagetop::test] +async fn extra_or_default_returns_type_default_on_missing() { + let props = Props::default(); + assert_eq!(props.extra_or_default::("ext.flag"), false); + assert_eq!(props.extra_or_default::("ext.count"), 0); +} + +// **< Props::extra_or_else >*********************************************************************** + +#[pagetop::test] +async fn extra_or_else_calls_closure_on_missing() { + let props = Props::default(); + assert_eq!( + props.extra_or_else("ext.label", || "fallback".to_string()), + "fallback" + ); +} + +// **< Clone and mixed types >********************************************************************** + +#[pagetop::test] +async fn clone_preserves_extras() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + let cloned = props.clone(); + assert_eq!(*cloned.extra::("ext.flag").unwrap(), true); +} + +#[pagetop::test] +async fn multiple_extras_with_different_types() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.flag", true)) + .with_prop(PropsOp::set_extra("ext.count", 42_u32)) + .with_prop(PropsOp::set_extra("ext.label", "hello".to_string())); + + assert_eq!(*props.extra::("ext.flag").unwrap(), true); + assert_eq!(*props.extra::("ext.count").unwrap(), 42); + assert_eq!(props.extra::("ext.label").unwrap(), "hello"); +} + +#[pagetop::test] +async fn extras_not_emitted_in_html() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.flag", true)) + .with_prop(PropsOp::add_classes("btn")); + assert_eq!( + html! { button (props) { "OK" } }.into_string(), + r#""# + ); +} diff --git a/tests/html_props_styles.rs b/tests/html_props_styles.rs new file mode 100644 index 00000000..c01ae00d --- /dev/null +++ b/tests/html_props_styles.rs @@ -0,0 +1,317 @@ +use pagetop::prelude::*; + +fn assert_styles(p: &Props, expected: Option<&str>) { + let got = p.get_styles(); + assert_eq!( + got.as_deref(), + expected, + "Expected {:?}, got {:?}", + expected, + got + ); +} + +// **< PropsOp::add_style >************************************************************************* + +#[pagetop::test] +async fn add_style_basic_adds_declaration() { + let p = Props::default().with_prop(PropsOp::add_style("color", "red")); + assert_styles(&p, Some("color: red")); +} + +#[pagetop::test] +async fn add_style_multiple_calls_accumulate_in_order() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")); + assert_styles(&p, Some("color: red; font-weight: bold")); +} + +#[pagetop::test] +async fn add_style_overrides_existing_property_preserving_position() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")) + .with_prop(PropsOp::add_style("color", "blue")); + assert_styles(&p, Some("color: blue; font-weight: bold")); +} + +#[pagetop::test] +async fn add_style_property_name_is_case_insensitive() { + let p = Props::default() + .with_prop(PropsOp::add_style("Color", "red")) + .with_prop(PropsOp::add_style("COLOR", "blue")); + assert_styles(&p, Some("color: blue")); +} + +#[pagetop::test] +async fn add_style_value_preserves_case_and_non_ascii() { + let p = Props::default().with_prop(PropsOp::add_style("font-family", "'Alegreya Sans', ARIAL")); + assert_styles(&p, Some("font-family: 'Alegreya Sans', ARIAL")); + + let p = Props::default().with_prop(PropsOp::add_style("content", "'Ñoño'")); + assert_styles(&p, Some("content: 'Ñoño'")); +} + +#[pagetop::test] +async fn add_style_value_may_contain_semicolons() { + // Unlike PropsOp::Set("style", ...), which interprets the string as declarations separated by + // ";", AddStyle receives the property and the value already separated, so a ";" inside the + // value (e.g. a data URI) is not a problem. + let p = Props::default().with_prop(PropsOp::add_style( + "background", + "url(data:image/png;base64,AAAA)", + )); + assert_styles(&p, Some("background: url(data:image/png;base64,AAAA)")); +} + +#[pagetop::test] +async fn add_style_trims_whitespace() { + let p = Props::default().with_prop(PropsOp::add_style(" color ", " red ")); + assert_styles(&p, Some("color: red")); +} + +#[pagetop::test] +async fn add_style_ignores_empty_property_or_value() { + let p = Props::default() + .with_prop(PropsOp::add_style("", "red")) + .with_prop(PropsOp::add_style("color", "")) + .with_prop(PropsOp::add_style(" ", " ")); + assert_styles(&p, None); +} + +// **< PropsOp::remove_style >********************************************************************** + +#[pagetop::test] +async fn remove_style_removes_indicated_property() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")) + .with_prop(PropsOp::remove_style("font-weight")); + assert_styles(&p, Some("color: red")); +} + +#[pagetop::test] +async fn remove_style_is_case_insensitive() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::remove_style("COLOR")); + assert_styles(&p, None); +} + +#[pagetop::test] +async fn remove_style_trims_whitespace() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::remove_style(" color ")); + assert_styles(&p, None); +} + +#[pagetop::test] +async fn remove_style_non_existing_is_noop() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::remove_style("font-weight")); + assert_styles(&p, Some("color: red")); +} + +// **< PropsOp::set / remove ("style") >************************************************************ + +#[pagetop::test] +async fn styles_reset_replaces_entire_list() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")) + .with_prop(PropsOp::set("style", "margin: 0")); + assert_styles(&p, Some("margin: 0")); +} + +#[pagetop::test] +async fn styles_reset_parses_multiple_declarations() { + let p = Props::default().with_prop(PropsOp::set("style", "color: red; font-weight: bold")); + assert_styles(&p, Some("color: red; font-weight: bold")); +} + +#[pagetop::test] +async fn styles_reset_ignores_declaration_without_colon() { + let p = Props::default().with_prop(PropsOp::set( + "style", + "color: red; not-a-declaration; font-weight: bold", + )); + assert_styles(&p, Some("color: red; font-weight: bold")); +} + +#[pagetop::test] +async fn styles_reset_ignores_empty_property_or_value() { + let p = Props::default().with_prop(PropsOp::set("style", ": red; color: ; ok: yes")); + assert_styles(&p, Some("ok: yes")); +} + +#[pagetop::test] +async fn styles_reset_trims_whitespace_around_declarations() { + let p = Props::default().with_prop(PropsOp::set( + "style", + " color : red ; font-weight:bold ", + )); + assert_styles(&p, Some("color: red; font-weight: bold")); +} + +#[pagetop::test] +async fn styles_reset_with_empty_input_clears() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::set("style", "")); + assert_styles(&p, None); +} + +#[pagetop::test] +async fn styles_reset_does_not_split_semicolon_inside_parens() { + let p = Props::default().with_prop(PropsOp::set( + "style", + "background: url(data:image/png;base64,AAAA)", + )); + assert_styles(&p, Some("background: url(data:image/png;base64,AAAA)")); +} + +#[pagetop::test] +async fn styles_reset_does_not_split_semicolon_inside_quotes() { + let p = Props::default().with_prop(PropsOp::set("style", r#"content: "a;b""#)); + assert_styles(&p, Some(r#"content: "a;b""#)); +} + +#[pagetop::test] +async fn styles_reset_mixes_declarations_with_and_without_parens() { + let p = Props::default().with_prop(PropsOp::set( + "style", + "color: red; background: url(a;b); margin: 0", + )); + assert_styles(&p, Some("color: red; background: url(a;b); margin: 0")); +} + +// Known limitation of PropsOp::Set("style", ...): it is not a full CSS parser. Unclosed parentheses +// drag the rest of the string into the same declaration. This test pins the current behavior so +// that a future change is deliberate, not accidental. +#[pagetop::test] +async fn styles_reset_unbalanced_parens_swallows_rest_of_string() { + let p = Props::default().with_prop(PropsOp::set( + "style", + "background: url(data:image/png; margin: 0", + )); + assert_styles(&p, Some("background: url(data:image/png; margin: 0")); +} + +#[pagetop::test] +async fn styles_remove_attr_clears_all_styles() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::remove("style")); + assert_styles(&p, None); +} + +// **< is_styles_empty >**************************************************************************** + +#[pagetop::test] +async fn props_is_styles_empty_on_default() { + assert!(Props::default().is_styles_empty()); +} + +#[pagetop::test] +async fn props_is_styles_empty_false_after_add_style() { + let p = Props::default().with_prop(PropsOp::add_style("color", "red")); + assert!(!p.is_styles_empty()); +} + +#[pagetop::test] +async fn props_is_styles_empty_true_after_remove_style() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::remove("style")); + assert!(p.is_styles_empty()); +} + +// **< get_styles / get_style / get_prop("style") >************************************************* + +#[pagetop::test] +async fn styles_get_returns_none_when_empty_some_when_not() { + assert_styles(&Props::default(), None); + let p = Props::default().with_prop(PropsOp::add_style("color", "red")); + assert_styles(&p, Some("color: red")); +} + +#[pagetop::test] +async fn get_style_returns_value_for_existing_property() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")); + assert_eq!(p.get_style("color"), Some("red".to_string())); + assert_eq!(p.get_style("font-weight"), Some("bold".to_string())); +} + +#[pagetop::test] +async fn get_style_is_case_insensitive_and_trims_input() { + let p = Props::default().with_prop(PropsOp::add_style("color", "red")); + assert_eq!(p.get_style("COLOR"), Some("red".to_string())); + assert_eq!(p.get_style(" Color "), Some("red".to_string())); +} + +#[pagetop::test] +async fn get_style_returns_none_for_missing_property() { + let p = Props::default().with_prop(PropsOp::add_style("color", "red")); + assert_eq!(p.get_style("margin"), None); +} + +#[pagetop::test] +async fn get_prop_style_matches_get_styles() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")); + assert_eq!(p.get_prop("style"), p.get_styles()); +} + +// **< HTML rendering >***************************************************************************** + +#[pagetop::test] +async fn props_styles_renders_style_attribute() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::add_style("font-weight", "bold")); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_styles_render_after_class_and_before_other_attrs() { + let p = Props::default() + .with_id("main") + .with_prop(PropsOp::add_classes("btn")) + .with_prop(PropsOp::add_style("color", "red")) + .with_prop(PropsOp::set("data-x", "1")); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_styles_escapes_double_quotes_in_value() { + let p = Props::default().with_prop(PropsOp::add_style("content", r#""hi""#)); + assert_eq!( + html! { span (p) {} }.into_string(), + r#""# + ); +} + +// **< Combined sequences >************************************************************************* + +#[pagetop::test] +async fn styles_sequence_preserves_position_through_updates_and_removals() { + let p = Props::default() + .with_prop(PropsOp::add_style("color", "red")) // color: red + .with_prop(PropsOp::add_style("font-weight", "bold")) // color: red; font-weight: bold + .with_prop(PropsOp::add_style("margin", "0")) // color: red; font-weight: bold; margin: 0 + .with_prop(PropsOp::remove_style("font-weight")) // color: red; margin: 0 + .with_prop(PropsOp::add_style("color", "blue")); // color: blue; margin: 0 + assert_styles(&p, Some("color: blue; margin: 0")); +} diff --git a/tests/html_unit.rs b/tests/html_unit.rs index 6acae935..9016a1df 100644 --- a/tests/html_unit.rs +++ b/tests/html_unit.rs @@ -8,7 +8,7 @@ async fn unit_value_empty_and_auto_and_zero_without_unit() { assert_eq!(UnitValue::from_str("auto").unwrap(), UnitValue::Auto); assert_eq!(UnitValue::from_str("AUTO").unwrap(), UnitValue::Auto); - // Cero sin unidad. + // Zero without a unit. assert_eq!(UnitValue::from_str("0").unwrap(), UnitValue::Zero); assert_eq!(UnitValue::from_str("+0").unwrap(), UnitValue::Zero); assert_eq!(UnitValue::from_str("-0").unwrap(), UnitValue::Zero); @@ -16,7 +16,7 @@ async fn unit_value_empty_and_auto_and_zero_without_unit() { #[pagetop::test] async fn unit_value_absolute_integers_with_signs_and_spaces_and_case() { - // Positivos, negativos y con espacios. + // Positive, negative, and with spaces. assert_eq!(UnitValue::from_str("12px").unwrap(), UnitValue::Px(12)); assert_eq!(UnitValue::from_str("-5pt").unwrap(), UnitValue::Pt(-5)); assert_eq!(UnitValue::from_str(" 7 cm ").unwrap(), UnitValue::Cm(7)); @@ -24,7 +24,7 @@ async fn unit_value_absolute_integers_with_signs_and_spaces_and_case() { assert_eq!(UnitValue::from_str(" 13 mm ").unwrap(), UnitValue::Mm(13)); assert_eq!(UnitValue::from_str("4 pc").unwrap(), UnitValue::Pc(4)); - // Insensibilidad a mayúsculas. + // Case insensitivity. assert_eq!(UnitValue::from_str("10PX").unwrap(), UnitValue::Px(10)); assert_eq!(UnitValue::from_str("15Pt").unwrap(), UnitValue::Pt(15)); } @@ -55,7 +55,7 @@ async fn unit_value_relative_floats_with_signs_and_spaces_and_case() { #[pagetop::test] async fn unit_value_whitespace_between_number_and_unit_is_allowed() { - // Hay espacio entre número y unidad (la implementación actual lo admite). + // There is a space between number and unit (the current implementation allows it). assert_eq!(UnitValue::from_str("12 px").unwrap(), UnitValue::Px(12)); assert_eq!( UnitValue::from_str("1.5 rem").unwrap(), @@ -113,7 +113,7 @@ async fn unit_value_percentage_trimming_and_signs() { ); } -// ERRORES ESPERADOS (no cambiar los mensajes; con is_err() basta). +// EXPECTED ERRORS (don't change the messages; is_err() is enough). #[pagetop::test] async fn unit_value_errors_missing_unit_for_non_zero() { @@ -136,10 +136,10 @@ async fn unit_value_errors_decimals_in_absolute_units() { #[pagetop::test] async fn unit_value_errors_unknown_units_or_bad_percentages() { - // Unidad no soportada. + // Unsupported unit. assert!(UnitValue::from_str("10ch").is_err()); assert!(UnitValue::from_str("2q").is_err()); - // Falta número. + // Missing number. assert!(UnitValue::from_str("%").is_err()); assert!(UnitValue::from_str(" % ").is_err()); } @@ -147,7 +147,7 @@ async fn unit_value_errors_unknown_units_or_bad_percentages() { #[pagetop::test] async fn unit_value_errors_non_numeric_numbers() { assert!(UnitValue::from_str("NaNem").is_err()); - // Decimal no permitido por FromStr. + // Decimal not allowed by FromStr. assert!(UnitValue::from_str("1,5rem").is_err()); } @@ -190,22 +190,22 @@ async fn unit_value_serde_deserialize_struct_and_array() { #[pagetop::test] async fn unit_value_accepts_dot5_and_1dot_shorthand_for_relatives() { - // `.5` y `1.` se parsean correctamente en relativas. + // `.5` and `1.` parse correctly for relative units. assert_eq!(UnitValue::from_str(".5em").unwrap(), UnitValue::RelEm(0.5)); assert_eq!( UnitValue::from_str("1.rem").unwrap(), UnitValue::RelRem(1.0) ); assert_eq!(UnitValue::from_str("1.vh").unwrap(), UnitValue::RelVh(1.0)); - // Sin unidad debe seguir fallando. + // Without a unit it must keep failing. assert!(UnitValue::from_str("1.").is_err()); } #[pagetop::test] async fn unit_value_display_keeps_minus_zero_for_relatives() { - // Comportamiento actual: f32 Display muestra "-0" si el valor es -0.0. + // Current behavior: f32 Display shows "-0" if the value is -0.0. let v = UnitValue::RelEm(-0.0); - // Se acepta cualquiera de los dos formatos como válidos. + // Either of the two formats is accepted as valid. let s = v.to_string(); assert!( s == "-0em" || s == "0em", @@ -215,9 +215,9 @@ async fn unit_value_display_keeps_minus_zero_for_relatives() { #[pagetop::test] async fn unit_value_rejects_non_decimal_notations() { - // Octal, los ceros a la izquierda (p. ej. `"020px"`) se interpretan en **base 10** (`20px`). + // Leading zeros (e.g. `"020px"`) are interpreted in **base 10** (`20px`), not octal. assert_eq!(UnitValue::from_str("020px").unwrap(), UnitValue::Px(20)); - // Notación científica y bases no decimales (p. ej., `"1e3vw"`, `"0x10px"`) no están soportadas. + // Scientific notation and non-decimal bases (e.g., `"1e3vw"`, `"0x10px"`) are not supported. assert!(UnitValue::from_str("1e3vw").is_err()); assert!(UnitValue::from_str("0x10px").is_err()); } diff --git a/tests/locale.rs b/tests/locale.rs index 51ff1863..804c70e3 100644 --- a/tests/locale.rs +++ b/tests/locale.rs @@ -1,58 +1,87 @@ use pagetop::prelude::*; +async fn setup() { + Application::new().await; +} + #[pagetop::test] async fn literal_text() { - let _app = web::test::init_router(Application::new().test()); + setup().await; - let l10n = L10n::n("© 2025 PageTop"); - assert_eq!(l10n.get(), Some("© 2025 PageTop".to_string())); + let lc = Lc::n("© 2025 PageTop"); + assert_eq!(lc.get(), Some("© 2025 PageTop".to_string())); } #[pagetop::test] async fn translation_without_args() { - let _app = web::test::init_router(Application::new().test()); + setup().await; - let l10n = L10n::l("test_hello_world"); - let translation = l10n.lookup(&Locale::resolve("es-ES")); + let lc = Lc::l("test_hello_world"); + let translation = lc.lookup(&Locale::resolve("es-ES")); assert_eq!(translation, Some("¡Hola mundo!".to_string())); } #[pagetop::test] async fn translation_with_args() { - let _app = web::test::init_router(Application::new().test()); + setup().await; - let l10n = L10n::l("test_hello_user").with_arg("userName", "Manuel"); - let translation = l10n.lookup(&Locale::resolve("es-ES")); + let lc = Lc::l("test_hello_user").with_arg("userName", "Manuel"); + let translation = lc.lookup(&Locale::resolve("es-ES")); assert_eq!(translation, Some("¡Hola, Manuel!".to_string())); } #[pagetop::test] async fn translation_with_plural_and_select() { - let _app = web::test::init_router(Application::new().test()); + setup().await; - let l10n = L10n::l("test_shared_photos").with_args(vec![ + let lc = Lc::l("test_shared_photos").with_args(vec![ ("userName", "Roberto"), ("photoCount", "3"), ("userGender", "male"), ]); - let translation = l10n.lookup(&Locale::resolve("es-ES")).unwrap(); + let translation = lc.lookup(&Locale::resolve("es-ES")).unwrap(); assert!(translation.contains("añadido 3 nuevas fotos de él")); } #[pagetop::test] async fn check_fallback_language() { - let _app = web::test::init_router(Application::new().test()); + setup().await; - let l10n = L10n::l("test_hello_world"); - let translation = l10n.lookup(&Locale::resolve("xx-YY")); // Retrocede a "en-US". + let lc = Lc::l("test_hello_world"); + let translation = lc.lookup(&Locale::resolve("xx-YY")); // Retrocede a "en-US". assert_eq!(translation, Some("Hello world!".to_string())); } #[pagetop::test] async fn check_unknown_key() { - let _app = web::test::init_router(Application::new().test()); + setup().await; - let l10n = L10n::l("non-existent-key"); - let translation = l10n.lookup(&Locale::resolve("en-US")); + let lc = Lc::l("non-existent-key"); + let translation = lc.lookup(&Locale::resolve("en-US")); assert_eq!(translation, None); } + +// `using()` renders literal text (`Lc::n()`) as HTML-escaped `Markup`, because it may come from +// runtime data (e.g. a menu title or a role label) rather than developer-authored content. +#[pagetop::test] +async fn literal_text_is_escaped_when_rendered_as_markup() { + setup().await; + + let lc = Lc::n(""); + let markup = lc.using(&Locale::default()); + assert_eq!( + markup.into_string(), + "<script>alert(1)</script>" + ); +} + +// Translation keys (`Lc::l()`/`Lc::t()`) are developer-authored `.ftl` content that may embed +// HTML on purpose (e.g. ``), so `using()` must keep rendering them unescaped. +#[pagetop::test] +async fn translated_text_is_not_escaped_when_rendered_as_markup() { + setup().await; + + let lc = Lc::l("test_hello_world"); + let markup = lc.using(&Locale::resolve("en-US")); + assert_eq!(markup.into_string(), "Hello world!"); +} diff --git a/tests/response_page.rs b/tests/response_page.rs new file mode 100644 index 00000000..947670e4 --- /dev/null +++ b/tests/response_page.rs @@ -0,0 +1,38 @@ +use pagetop::prelude::*; + +fn request() -> HttpRequest { + web::test::TestRequest::get().to_http_request() +} + +#[pagetop::test] +async fn title_and_description_are_absent_by_default() { + let page = Page::new(request()); + + assert_eq!(page.title(), None); + assert_eq!(page.description(), None); +} + +#[pagetop::test] +async fn title_and_description_reflect_the_values_set() { + let page = Page::new(request()) + .with_title(Lc::n("Dashboard")) + .with_description(Lc::n("Overview of recent activity")); + + assert_eq!(page.title(), Some("Dashboard".to_string())); + assert_eq!( + page.description(), + Some("Overview of recent activity".to_string()) + ); +} + +#[pagetop::test] +async fn lc_none_clears_a_previously_set_title_and_description() { + let page = Page::new(request()) + .with_title(Lc::n("Dashboard")) + .with_description(Lc::n("Overview of recent activity")) + .with_title(Lc::none()) + .with_description(Lc::none()); + + assert_eq!(page.title(), None); + assert_eq!(page.description(), None); +} diff --git a/tests/route.rs b/tests/route.rs new file mode 100644 index 00000000..363e6bbf --- /dev/null +++ b/tests/route.rs @@ -0,0 +1,124 @@ +use pagetop::prelude::*; + +// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in +// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes. +fn cx_with_lang(lang: &str) -> Context { + Context::default().with_langid(&Locale::resolve(lang)) +} + +// **< Route - automatic detection of external URLs >*********************************************** + +#[pagetop::test] +async fn route_from_str_detects_external_urls_by_prefix() { + let cx = cx_with_lang("es-ES"); + + for url in [ + "http://example.com", + "https://example.com", + "//example.com", + "mailto:user@example.com", + "tel:+123456789", + ] { + let route: Route = url.into(); + assert_eq!( + route.resolve(&cx).to_string(), + url, + "expected {url:?} to be detected as external and left unmodified" + ); + } +} + +#[pagetop::test] +async fn route_from_string_behaves_like_from_str() { + let cx = cx_with_lang("es-ES"); + + let external: Route = String::from("https://example.com").into(); + assert_eq!(external.resolve(&cx).to_string(), "https://example.com"); + + let local: Route = String::from("/local/path").into(); + assert_eq!(local.resolve(&cx).to_string(), "/local/path?lang=es-ES"); +} + +#[pagetop::test] +async fn route_local_path_goes_through_context_route_and_preserves_lang() { + let cx = cx_with_lang("es-ES"); + + let route: Route = "/local/path".into(); + assert_eq!(route.resolve(&cx).to_string(), "/local/path?lang=es-ES"); +} + +#[pagetop::test] +async fn route_unrecognized_scheme_is_not_detected_as_external() { + // `ftp://` is not among the prefixes recognized by the heuristic, so the implicit conversion + // treats it as a local path and goes through `Context::route()`; for a URL like this, + // `Route::external()` is still necessary (see the test below). + let cx = cx_with_lang("es-ES"); + + let route: Route = "ftp://files.example.com".into(); + assert_eq!( + route.resolve(&cx).to_string(), + "ftp://files.example.com?lang=es-ES" + ); +} + +// **< Route::external() / From (share the same fixed resolution) >********************** + +#[pagetop::test] +async fn route_external_ignores_the_rendering_context() { + let route = Route::external("/fixed/path"); + + let cx_es = cx_with_lang("es-ES"); + let cx_en = cx_with_lang("en-US"); + + assert_eq!(route.resolve(&cx_es).to_string(), "/fixed/path"); + assert_eq!(route.resolve(&cx_en).to_string(), "/fixed/path"); +} + +#[pagetop::test] +async fn route_from_route_path_ignores_the_rendering_context() { + let route: Route = RoutePath::new("/fixed/path").with_param("q", "rust").into(); + + let cx_es = cx_with_lang("es-ES"); + let cx_en = cx_with_lang("en-US"); + + assert_eq!(route.resolve(&cx_es).to_string(), "/fixed/path?q=rust"); + assert_eq!(route.resolve(&cx_en).to_string(), "/fixed/path?q=rust"); +} + +#[pagetop::test] +async fn route_external_never_adds_lang_even_for_unrecognized_schemes() { + let cx = cx_with_lang("es-ES"); + + let route = Route::external("ftp://files.example.com"); + assert_eq!(route.resolve(&cx).to_string(), "ftp://files.example.com"); +} + +// **< Route::with() >****************************************************************************** + +#[pagetop::test] +async fn route_with_captures_dynamic_values_from_the_environment() { + let cx = cx_with_lang("es-ES"); + + let user_id = 42; + let route = Route::with(move |cx| cx.route(format!("/users/{user_id}"))); + + assert_eq!(route.resolve(&cx).to_string(), "/users/42?lang=es-ES"); +} + +#[pagetop::test] +async fn route_default_resolves_to_an_empty_path() { + let cx = Context::default(); + assert_eq!(Route::default().resolve(&cx).to_string(), ""); +} + +// **< Context::route() - direct protection against external URLs >********************************* + +#[pagetop::test] +async fn context_route_never_adds_lang_to_an_external_looking_url() { + let cx = cx_with_lang("es-ES"); + + assert_eq!( + cx.route("https://example.com/help").to_string(), + "https://example.com/help" + ); +} diff --git a/tests/service.rs b/tests/service.rs index 5f2ca87b..1c3442f2 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -2,7 +2,7 @@ use pagetop::prelude::*; #[pagetop::test] async fn homepage_returns_404() { - let app = web::test::init_router(Application::new().test()); + let app = web::test::init_router(Application::new().await.test()); let req = web::test::TestRequest::get().uri("/").to_request(); let resp = web::test::send_request(&app, req).await; diff --git a/tests/util.rs b/tests/util.rs index 6dafce8a..0730a2c4 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -3,6 +3,10 @@ use pagetop::prelude::*; use std::{borrow::Cow, fs, io}; use tempfile::TempDir; +async fn setup() { + Application::new().await; +} + // **< Testing normalize_ascii() >****************************************************************** fn assert_err(input: &str, expected: util::NormalizeAsciiError) { @@ -41,15 +45,15 @@ fn assert_owned(input: &str, expected: &str) { #[pagetop::test] async fn normalize_errors() { - // Caso especial: cadena vacía. + // Special case: empty string. assert_err("", util::NormalizeAsciiError::IsEmpty); - // Sólo separadores ASCII: tras el recorte no queda nada. + // Only ASCII separators: nothing is left after trimming. for input in [" ", " ", "\t", "\n", "\r", "\t \n\r "] { assert_err(input, util::NormalizeAsciiError::EmptyAfterTrimming); } - // Cualquier byte no-ASCII debe fallar, aunque el resto pueda normalizarse. + // Any non-ASCII byte must fail, even if the rest could be normalized. for input in [ "©", "á", @@ -66,7 +70,7 @@ async fn normalize_errors() { #[pagetop::test] async fn normalize_borrowed_trim_and_already_normalized() { - // Sólo recorte (incluyendo separadores al final). + // Trimming only (including trailing separators). for (input, expected) in [ (" a", "a"), ("a ", "a"), @@ -85,7 +89,7 @@ async fn normalize_borrowed_trim_and_already_normalized() { assert_borrowed(input, expected); } - // Ya normalizado (minúsculas y un único espacio entre tokens). + // Already normalized (lowercase and a single space between tokens). for input in [ "a", "a b", @@ -103,12 +107,12 @@ async fn normalize_borrowed_trim_and_already_normalized() { "path/to/resource", "foo+bar=baz", "a-._:/+=", - "a\x1Bb", // Byte de control ASCII: se conserva tal cual. + "a\x1Bb", // ASCII control byte: preserved as-is. ] { assert_borrowed(input, input); } - // Separador "raro" al final de la cadena: se recorta y se devuelve porción. + // "Unusual" separator at the end of the string: it is trimmed and a slice is returned. for (input, expected) in [ ("foo bar\t", "foo bar"), ("foo bar\r\n", "foo bar"), @@ -120,7 +124,7 @@ async fn normalize_borrowed_trim_and_already_normalized() { #[pagetop::test] async fn normalize_owned_due_to_uppercase() { - // Sólo por mayúsculas (y otros ASCII que se preservan). + // Only due to uppercase (and other ASCII that is preserved). for (input, expected) in [ ("A", "a"), ("Foo", "foo"), @@ -137,7 +141,7 @@ async fn normalize_owned_due_to_uppercase() { ("ETag:W/\"XYZ\"", "etag:w/\"xyz\""), ("Foo+Bar=Baz", "foo+bar=baz"), ("A-._:/+=", "a-._:/+="), - ("A\x1BB", "a\x1bb"), // Sólo letras en minúsculas; el byte de control se conserva. + ("A\x1BB", "a\x1bb"), // Only letters get lowercased; the control byte is preserved. ] { assert_owned(input, expected); } @@ -145,12 +149,12 @@ async fn normalize_owned_due_to_uppercase() { #[pagetop::test] async fn normalize_owned_due_to_internal_whitespace() { - // Espacios consecutivos (deben colapsar a un único espacio). + // Consecutive spaces (must collapse to a single space). for (input, expected) in [("a b", "a b"), ("a b", "a b")] { assert_owned(input, expected); } - // Separadores ASCII distintos de ' ' entre tokens (tab, newline, CR, CRLF). + // ASCII separators other than ' ' between tokens (tab, newline, CR, CRLF). for (input, expected) in [ ("a\tb", "a b"), ("a\nb", "a b"), @@ -164,7 +168,7 @@ async fn normalize_owned_due_to_internal_whitespace() { assert_owned(input, expected); } - // Mezclas de separadores. + // Mixed separators. for (input, expected) in [ ("a \t \n b", "a b"), ("a\t \n b", "a b"), @@ -176,7 +180,7 @@ async fn normalize_owned_due_to_internal_whitespace() { assert_owned(input, expected); } - // El resultado nunca debe tener espacios al inicio/fin (tras normalizar). + // The result must never have leading/trailing spaces (after normalizing). for (input, expected) in [ (" a b ", "a b"), (" a\tb ", "a b"), @@ -188,7 +192,7 @@ async fn normalize_owned_due_to_internal_whitespace() { #[pagetop::test] async fn normalize_owned_due_to_mixed_causes() { - // Combinaciones de mayúsculas y separador no normalizado. + // Combinations of uppercase and non-normalized separators. for (input, expected) in [ (" Foo BAR\tbaz ", "foo bar baz"), ("\nFOO\rbar\tBAZ\n", "foo bar baz"), @@ -205,17 +209,17 @@ async fn normalize_owned_due_to_mixed_causes() { #[pagetop::test] async fn normalize_borrowed_vs_owned_edge_cases() { - // Un sólo token con separador al final. + // A single token with a trailing separator. for (input, expected) in [("x ", "x"), ("x\t", "x"), ("x\n", "x"), ("x\r\n", "x")] { assert_borrowed(input, expected); } - // Dos tokens con separador no normalizado. + // Two tokens with a non-normalized separator. for input in ["x y", "x\t\ty", "x \t y", "x\r\ny"] { assert_owned(input, "x y"); } - // Dos tokens con separación limpia. + // Two tokens with a clean separator. for (input, expected) in [("x y ", "x y"), ("x y\t", "x y"), ("x y\r\n", "x y")] { assert_borrowed(input, expected); } @@ -223,7 +227,7 @@ async fn normalize_borrowed_vs_owned_edge_cases() { #[pagetop::test] async fn normalize_is_idempotent() { - // La normalización debe ser idempotente: normalizar el resultado no cambia nada. + // Normalization must be idempotent: normalizing the result changes nothing. let cases = [ "a", "a b c", @@ -239,7 +243,7 @@ async fn normalize_is_idempotent() { ]; for &input in &cases { - // Todos son ASCII, pero se deja este control por si se amplía la lista en el futuro. + // All are ASCII, but this check is kept in case the list is expanded in the future. if !input.is_ascii() { continue; } @@ -265,7 +269,7 @@ mod unix { #[pagetop::test] async fn ok_absolute_dir() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + setup().await; // /tmp//sub let td = TempDir::new()?; @@ -279,7 +283,7 @@ mod unix { #[pagetop::test] async fn ok_relative_dir_with_manifest() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + setup().await; let td = TempDir::new()?; let sub = td.path().join("sub"); @@ -293,7 +297,7 @@ mod unix { #[pagetop::test] async fn error_not_a_directory() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + setup().await; let td = TempDir::new()?; let file = td.path().join("foo.txt"); @@ -311,7 +315,7 @@ mod windows { #[pagetop::test] async fn ok_absolute_dir() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + setup().await; // C:\Users\...\Temp\... let td = TempDir::new()?; @@ -325,7 +329,7 @@ mod windows { #[pagetop::test] async fn ok_relative_dir_with_manifest() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + setup().await; let td = TempDir::new()?; let sub = td.path().join("sub"); @@ -339,7 +343,7 @@ mod windows { #[pagetop::test] async fn error_not_a_directory() -> io::Result<()> { - let _app = web::test::init_router(Application::new().test()); + setup().await; let td = TempDir::new()?; let file = td.path().join("foo.txt"); diff --git a/tests/waypoint.rs b/tests/waypoint.rs new file mode 100644 index 00000000..76422e4e --- /dev/null +++ b/tests/waypoint.rs @@ -0,0 +1,63 @@ +use pagetop::prelude::*; + +// **< Waypoint - only accepts local paths >******************************************************** + +#[pagetop::test] +async fn waypoint_accepts_local_paths() { + for path in ["/", "/admin/users", "/admin/users?page=2"] { + let w = Waypoint::from(path.to_owned()); + assert_eq!(w.as_str(), Some(path)); + } +} + +#[pagetop::test] +async fn waypoint_rejects_open_redirect_targets() { + // Targets that a malicious client could slip into `?waypoint=...` to redirect off-site: they + // must be discarded just as if no waypoint had been provided. + for target in [ + "", + "https://evil.example", + "//evil.example", + "/\\evil.example", + "javascript:alert(1)", + ] { + let w = Waypoint::from(target.to_owned()); + assert_eq!(w.as_str(), None, "expected {target:?} to be rejected"); + } +} + +#[pagetop::test] +async fn waypoint_deserialize_rejects_open_redirect_targets() { + // Reproduces the real entry point (`web::Query`): the value arrives via + // deserialization, not through `Waypoint::from(String)` as in the previous test. + let w: Waypoint = serde_json::from_str(r#"{"waypoint":"https://evil.example"}"#).unwrap(); + assert_eq!(w.as_str(), None); +} + +// **< Waypoint::or() >***************************************************************************** + +#[pagetop::test] +async fn waypoint_or_returns_transported_local_path() { + let w = Waypoint::from("/admin/users?page=2".to_owned()); + assert_eq!(w.or("/fallback").to_string(), "/admin/users?page=2"); +} + +#[pagetop::test] +async fn waypoint_or_falls_back_when_target_is_not_local() { + let w = Waypoint::from("https://evil.example".to_owned()); + assert_eq!(w.or("/admin/users").to_string(), "/admin/users"); +} + +// **< Waypoint::append_to() >********************************************************************** + +#[pagetop::test] +async fn waypoint_append_to_adds_param_only_when_present() { + let w = Waypoint::from("/admin/users".to_owned()); + assert_eq!( + w.append_to("/items").to_string(), + "/items?waypoint=%2Fadmin%2Fusers" + ); + + let empty = Waypoint::default(); + assert_eq!(empty.append_to("/items").to_string(), "/items"); +} diff --git a/tools/changelog.sh b/tools/changelog.sh index fc2a8a0c..81c85747 100755 --- a/tools/changelog.sh +++ b/tools/changelog.sh @@ -65,6 +65,7 @@ case "$CRATE" in # Extensions --exclude-path "extensions/pagetop-aliner/**/*" --exclude-path "extensions/pagetop-bootsier/**/*" + --exclude-path "extensions/pagetop-htmx/**/*" --exclude-path "extensions/pagetop-seaorm/**/*" ) ;; @@ -76,6 +77,10 @@ case "$CRATE" in CHANGELOG_FILE="extensions/pagetop-bootsier/CHANGELOG.md" PATH_FLAGS=(--include-path "extensions/pagetop-bootsier/**/*") ;; + pagetop-htmx) + CHANGELOG_FILE="extensions/pagetop-htmx/CHANGELOG.md" + PATH_FLAGS=(--include-path "extensions/pagetop-htmx/**/*") + ;; pagetop-seaorm) CHANGELOG_FILE="extensions/pagetop-seaorm/CHANGELOG.md" PATH_FLAGS=(--include-path "extensions/pagetop-seaorm/**/*")