diff --git a/Cargo.lock b/Cargo.lock index 5eca0cae..ec5d2f1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1659,6 +1659,7 @@ dependencies = [ "fluent-templates", "itoa", "nom", + "pagetop-build", "pagetop-macros", "paste", "serde", diff --git a/README.md b/README.md index df5b2429..96049b9d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ impl PackageTrait for HelloWorld { async fn hello_world(request: HttpRequest) -> ResultPage { Page::new(request) - .with_component(Html::with(html! { h1 { "Hello World!" } })) + .with_body(PrepareMarkup::With(html! { h1 { "Hello World!" } })) .render() } diff --git a/helpers/pagetop-build/src/lib.rs b/helpers/pagetop-build/src/lib.rs index 0e36f2dc..1b2fb39a 100644 --- a/helpers/pagetop-build/src/lib.rs +++ b/helpers/pagetop-build/src/lib.rs @@ -1,5 +1,4 @@ -//! **`StaticFilesBundle`** uses [static_files](https://docs.rs/static-files/latest/static_files/) -//! to provide an easy way to embed static files or compiled SCSS files into your binary at compile +//! Provide an easy way to embed static files or compiled SCSS files into your binary at compile //! time. //! //! ## Adding to your project @@ -24,9 +23,9 @@ //! use pagetop_build::StaticFilesBundle; //! //! fn main() -> std::io::Result<()> { -//! StaticFilesBundle::from_dir("./static", None) // Include all files. -//! .with_name("guides") // Name the generated module. -//! .build() // Build the bundle. +//! StaticFilesBundle::from_dir("./static", None) +//! .with_name("guides") +//! .build() //! } //! ``` //! @@ -67,7 +66,7 @@ //! //! ## Generated module //! -//! `StaticFilesBundle` generates a file in the standard directory +//! [`StaticFilesBundle`] generates a file in the standard directory //! [OUT_DIR](https://doc.rust-lang.org/cargo/reference/environment-variables.html) where all //! intermediate and output artifacts are placed during compilation. For example, if you use //! `with_name("guides")`, it generates a file named `guides.rs`: @@ -78,15 +77,15 @@ //! ```rust#ignore //! use pagetop::prelude::*; //! -//! static_files!(guides); +//! include_files!(guides); //! ``` //! -//! Or, access the entire bundle as a static `HashMap`: +//! Or, access the entire bundle as a global static `HashMap`: //! //! ```rust#ignore //! use pagetop::prelude::*; //! -//! static_files!(guides => BUNDLE_GUIDES); +//! include_files!(guides => BUNDLE_GUIDES); //! ``` //! //! You can build more than one resources file to compile with your project. @@ -98,6 +97,8 @@ use std::fs::{create_dir_all, remove_dir_all, File}; use std::io::Write; use std::path::Path; +/// Generates the resources to embed at compile time using +/// [static_files](https://docs.rs/static-files/latest/static_files/). pub struct StaticFilesBundle { resource_dir: ResourceDir, } diff --git a/helpers/pagetop-macros/src/lib.rs b/helpers/pagetop-macros/src/lib.rs index 006b92d4..947327e7 100644 --- a/helpers/pagetop-macros/src/lib.rs +++ b/helpers/pagetop-macros/src/lib.rs @@ -3,8 +3,110 @@ mod smart_default; use proc_macro::TokenStream; use proc_macro_error::proc_macro_error; -use quote::quote; -use syn::{parse_macro_input, DeriveInput}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{parse_macro_input, parse_str, DeriveInput, ItemFn}; + +/// Macro attribute to generate builder methods from `set_` methods. +/// +/// This macro takes a method with the `set_` prefix and generates a corresponding method with the +/// `with_` prefix to use in the builder pattern. +/// +/// # Panics +/// +/// This function will panic if a parameter identifier is not found in the argument list. +/// +/// # Examples +/// +/// ``` +/// #[fn_builder] +/// pub fn set_example(&mut self) -> &mut Self { +/// // implementation +/// } +/// ``` +/// +/// Will generate: +/// +/// ``` +/// pub fn with_example(mut self) -> Self { +/// self.set_example(); +/// self +/// } +/// ``` +#[proc_macro_attribute] +pub fn fn_builder(_: TokenStream, item: TokenStream) -> TokenStream { + let fn_set = parse_macro_input!(item as ItemFn); + let fn_set_name = fn_set.sig.ident.to_string(); + + if !fn_set_name.starts_with("set_") { + let expanded = quote_spanned! { + fn_set.sig.ident.span() => + compile_error!("expected a \"pub fn set_...() -> &mut Self\" method"); + }; + return expanded.into(); + } + + let fn_with_name = fn_set_name.replace("set_", "with_"); + let fn_with_generics = if fn_set.sig.generics.params.is_empty() { + fn_with_name.clone() + } else { + let g = &fn_set.sig.generics; + format!("{fn_with_name}{}", quote! { #g }.to_string()) + }; + + let where_clause = fn_set + .sig + .generics + .where_clause + .as_ref() + .map_or(String::new(), |where_clause| { + format!("{} ", quote! { #where_clause }.to_string()) + }); + + let args: Vec = fn_set + .sig + .inputs + .iter() + .skip(1) + .map(|arg| arg.to_token_stream().to_string()) + .collect(); + + let params: Vec = args + .iter() + .map(|arg| { + arg.split_whitespace() + .next() + .unwrap() + .trim_end_matches(':') + .to_string() + }) + .collect(); + + #[rustfmt::skip] + let fn_with = parse_str::(format!(r#" + pub fn {fn_with_generics}(mut self, {}) -> Self {where_clause} {{ + self.{fn_set_name}({}); + self + }} + "#, args.join(", "), params.join(", ") + ).as_str()).unwrap(); + + #[rustfmt::skip] + let fn_set_doc = format!(r##" +

Use + pub fn {fn_with_name}(self, …) -> Self + for the builder pattern. +

+ "##); + + let expanded = quote! { + #[doc(hidden)] + #fn_with + #[inline] + #[doc = #fn_set_doc] + #fn_set + }; + expanded.into() +} #[proc_macro] #[proc_macro_error] diff --git a/packages/pagetop-aliner/src/lib.rs b/packages/pagetop-aliner/src/lib.rs index 414e1a5a..166a9c23 100644 --- a/packages/pagetop-aliner/src/lib.rs +++ b/packages/pagetop-aliner/src/lib.rs @@ -5,9 +5,9 @@ use tera::Tera; use std::sync::LazyLock; -static_locales!(LOCALES_ALINER); +include_locales!(LOCALES_ALINER); -static_files!(aliner); +include_files!(aliner); // ALINER THEME ************************************************************************************ @@ -50,7 +50,7 @@ impl PackageTrait for Aliner { } fn configure_service(&self, scfg: &mut service::web::ServiceConfig) { - static_files_service!(scfg, aliner => "/aliner"); + include_files_service!(scfg, aliner => "/aliner"); } } diff --git a/packages/pagetop-bootsier/src/lib.rs b/packages/pagetop-bootsier/src/lib.rs index 7c78c2b1..bdab71d4 100644 --- a/packages/pagetop-bootsier/src/lib.rs +++ b/packages/pagetop-bootsier/src/lib.rs @@ -1,8 +1,8 @@ use pagetop::prelude::*; -static_locales!(LOCALES_BOOTSIER); +include_locales!(LOCALES_BOOTSIER); -//static_files!(bootsier); +//include_files!(bootsier); pub struct Bootsier; @@ -26,7 +26,7 @@ impl PackageTrait for Bootsier { } fn configure_service(&self, scfg: &mut service::web::ServiceConfig) { - static_files_service!(scfg, bootsier => "/bootsier"); + include_files_service!(scfg, bootsier => "/bootsier"); } */ } diff --git a/pagetop/Cargo.toml b/pagetop/Cargo.toml index 1093e405..d5fed213 100644 --- a/pagetop/Cargo.toml +++ b/pagetop/Cargo.toml @@ -19,15 +19,15 @@ license = { workspace = true } name = "pagetop" [dependencies] -colored = "2.1.0" +colored = "2.1.0" concat-string = "1.0.1" -figlet-rs = "0.1.5" -itoa = "1.0.11" -nom = "7.1.3" -paste = "1.0.15" -substring = "1.4.5" +figlet-rs = "0.1.5" +itoa = "1.0.11" +nom = "7.1.3" +paste = "1.0.15" +substring = "1.4.5" terminal_size = "0.4.0" -toml = "0.8.19" +toml = "0.8.19" tracing = "0.1.40" tracing-appender = "0.2.3" @@ -47,3 +47,6 @@ serde.workspace = true static-files.workspace = true pagetop-macros.workspace = true + +[build-dependencies] +pagetop-build.workspace = true diff --git a/pagetop/build.rs b/pagetop/build.rs new file mode 100644 index 00000000..1450422c --- /dev/null +++ b/pagetop/build.rs @@ -0,0 +1,7 @@ +use pagetop_build::StaticFilesBundle; + +fn main() -> std::io::Result<()> { + StaticFilesBundle::from_dir("../static", None) + .with_name("assets") + .build() +} diff --git a/pagetop/examples/hello-name.rs b/pagetop/examples/hello-name.rs index 3a03e8b1..8dba5cec 100644 --- a/pagetop/examples/hello-name.rs +++ b/pagetop/examples/hello-name.rs @@ -15,7 +15,7 @@ async fn hello_name( ) -> ResultPage { let name = path.into_inner(); Page::new(request) - .with_component(Html::with(html! { h1 { "Hello " (name) "!" } })) + .with_body(PrepareMarkup::With(html! { h1 { "Hello " (name) "!" } })) .render() } diff --git a/pagetop/examples/hello-world.rs b/pagetop/examples/hello-world.rs index 17c1e9d3..c904eb07 100644 --- a/pagetop/examples/hello-world.rs +++ b/pagetop/examples/hello-world.rs @@ -10,7 +10,7 @@ impl PackageTrait for HelloWorld { async fn hello_world(request: HttpRequest) -> ResultPage { Page::new(request) - .with_component(Html::with(html! { h1 { "Hello World!" } })) + .with_body(PrepareMarkup::With(html! { h1 { "Hello World!" } })) .render() } diff --git a/pagetop/src/app.rs b/pagetop/src/app.rs index 2678b79a..aaac3234 100644 --- a/pagetop/src/app.rs +++ b/pagetop/src/app.rs @@ -3,6 +3,8 @@ mod figfont; use crate::core::{package, package::PackageRef}; +use crate::html::Markup; +use crate::response::page::{ErrorPage, ResultPage}; use crate::{global, locale, service, trace}; use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle}; @@ -154,12 +156,12 @@ impl Application { InitError = (), >, > { - service::App::new().configure(package::all::configure_services) - // .default_service(service::web::route().to(service_not_found)) + service::App::new() + .configure(package::all::configure_services) + .default_service(service::web::route().to(service_not_found)) } } -/* -async fn service_not_found(request: HttpRequest) -> ResultPage { + +async fn service_not_found(request: service::HttpRequest) -> ResultPage { Err(ErrorPage::NotFound(request)) } -*/ diff --git a/pagetop/src/core/package/all.rs b/pagetop/src/core/package/all.rs index f374f3fd..c21fed8f 100644 --- a/pagetop/src/core/package/all.rs +++ b/pagetop/src/core/package/all.rs @@ -1,7 +1,7 @@ use crate::core::action::add_action; use crate::core::package::{welcome, PackageRef}; use crate::core::theme::all::THEMES; -use crate::{service, trace}; +use crate::{include_files, include_files_service, service, trace}; use std::sync::{LazyLock, RwLock}; @@ -23,8 +23,6 @@ pub fn register_packages(root_package: Option) { if let Some(package) = root_package { add_to_enabled(&mut enabled_list, package); } - // Reverse the order to ensure packages are sorted from none to most dependencies. - enabled_list.reverse(); // Save the final list of enabled packages. ENABLED_PACKAGES.write().unwrap().append(&mut enabled_list); @@ -41,16 +39,14 @@ pub fn register_packages(root_package: Option) { fn add_to_enabled(list: &mut Vec, package: PackageRef) { // Check if the package is not already in the enabled list to avoid duplicates. if !list.iter().any(|p| p.type_id() == package.type_id()) { - // Add the package to the enabled list. - list.push(package); - - // Reverse dependencies to add them in correct order (dependencies first). - let mut dependencies = package.dependencies(); - dependencies.reverse(); - for d in &dependencies { + // Add the package dependencies in reverse order first. + for d in package.dependencies().iter().rev() { add_to_enabled(list, *d); } + // Add the package itself to the enabled list. + list.push(package); + // Check if the package has an associated theme to register. if let Some(theme) = package.theme() { let mut registered_themes = THEMES.write().unwrap(); @@ -119,10 +115,14 @@ pub fn init_packages() { // CONFIGURE SERVICES ****************************************************************************** +include_files!(assets); + pub fn configure_services(scfg: &mut service::web::ServiceConfig) { for m in ENABLED_PACKAGES.read().unwrap().iter() { m.configure_service(scfg); } // Default welcome homepage. scfg.route("/", service::web::get().to(welcome::homepage)); + // Default assets. + include_files_service!(scfg, assets => "/"); } diff --git a/pagetop/src/core/package/definition.rs b/pagetop/src/core/package/definition.rs index 3506a929..b1d0a8c9 100644 --- a/pagetop/src/core/package/definition.rs +++ b/pagetop/src/core/package/definition.rs @@ -13,7 +13,7 @@ pub trait PackageTrait: AnyBase + Send + Sync { } fn description(&self) -> L10n { - L10n::none() + L10n::default() } fn theme(&self) -> Option { diff --git a/pagetop/src/core/package/welcome.rs b/pagetop/src/core/package/welcome.rs index 2a939d8a..1c3d41c6 100644 --- a/pagetop/src/core/package/welcome.rs +++ b/pagetop/src/core/package/welcome.rs @@ -1,67 +1,63 @@ -use crate::html::{html, Markup}; +use crate::html::{html, Markup, PrepareMarkup, StyleSheet}; use crate::locale::L10n; -use crate::{concat_string, global}; +use crate::response::page::{AssetsOp, ErrorPage, Page, ResultPage}; +use crate::{global, service}; -pub async fn homepage() -> Markup { - html! { - head { - meta charset="UTF-8" {} - meta name="viewport" content="width=device-width, initial-scale=1" {} - title { (concat_string!( - &global::SETTINGS.app.name, " | ", L10n::l("welcome_page").to_string() - )) } - style { r#" - body { - background-color: #f3d060; - font-size: 20px; - } - .wrapper { - max-width: 1200px; - width: 100%; - margin: 0 auto; - padding: 0; - } - .container { - padding: 0 16px; - } - .title { - font-size: clamp(3rem, 10vw, 10rem); - letter-spacing: -0.05em; - line-height: 1.2; - margin: 0; - } - .subtitle { - font-size: clamp(1.8rem, 2vw, 3rem); - letter-spacing: -0.02em; - line-height: 1.2; - margin: 0; - } - .powered { - margin: .5em 0 1em; - } - .box-container { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: stretch; - gap: 1.5em; - } - .box { - flex: 1 1 280px; - border: 3px solid #25282a; - box-shadow: 5px 5px 0px #25282a; - box-sizing: border-box; - padding: 0 16px; - } - footer { - margin-top: 5em; - font-size: 14px; - font-weight: 500; - color: #a5282c; - } - "# } - } - body style="font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;" { +pub async fn homepage(request: service::HttpRequest) -> ResultPage { + Page::new(request) + .with_title(L10n::l("welcome_page")) + .with_assets(AssetsOp::AddStyleSheet(StyleSheet::inline("styles", r#" + body { + background-color: #f3d060; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 20px; + } + .wrapper { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0; + } + .container { + padding: 0 16px; + } + .title { + font-size: clamp(3rem, 10vw, 10rem); + letter-spacing: -0.05em; + line-height: 1.2; + margin: 0; + } + .subtitle { + font-size: clamp(1.8rem, 2vw, 3rem); + letter-spacing: -0.02em; + line-height: 1.2; + margin: 0; + } + .powered { + margin: .5em 0 1em; + } + .box-container { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: stretch; + gap: 1.5em; + } + .box { + flex: 1 1 280px; + border: 3px solid #25282a; + box-shadow: 5px 5px 0px #25282a; + box-sizing: border-box; + padding: 0 16px; + } + footer { + margin-top: 5em; + font-size: 14px; + font-weight: 500; + color: #a5282c; + } + "#))) + .with_body(PrepareMarkup::With(html! { div class="wrapper" { div class="container" { h1 class="title" { (L10n::l("welcome_title").markup()) } @@ -115,6 +111,6 @@ pub async fn homepage() -> Markup { footer { "[ " (L10n::l("welcome_have_fun").markup()) " ]" } } } - } - } + })) + .render() } diff --git a/pagetop/src/core/theme/all.rs b/pagetop/src/core/theme/all.rs index 4c3d7d0e..b27bf1e5 100644 --- a/pagetop/src/core/theme/all.rs +++ b/pagetop/src/core/theme/all.rs @@ -1,4 +1,6 @@ -use crate::core::theme::ThemeRef; +use crate::core::package::PackageTrait; +use crate::core::theme::{ThemeRef, ThemeTrait}; +use crate::global; use std::sync::{LazyLock, RwLock}; @@ -6,7 +8,7 @@ use std::sync::{LazyLock, RwLock}; pub static THEMES: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); -/* DEFAULT THEME *********************************************************************************** +// DEFAULT THEME *********************************************************************************** pub struct NoTheme; @@ -16,8 +18,7 @@ impl PackageTrait for NoTheme { } } -impl ThemeTrait for NoTheme { -} +impl ThemeTrait for NoTheme {} pub static DEFAULT_THEME: LazyLock = LazyLock::new(|| match theme_by_short_name(&global::SETTINGS.app.theme) { @@ -39,4 +40,3 @@ pub fn theme_by_short_name(short_name: &str) -> Option { _ => None, } } -*/ diff --git a/pagetop/src/core/theme/definition.rs b/pagetop/src/core/theme/definition.rs index 70c178f2..126e7731 100644 --- a/pagetop/src/core/theme/definition.rs +++ b/pagetop/src/core/theme/definition.rs @@ -1,4 +1,8 @@ use crate::core::package::PackageTrait; +use crate::html::{html, PrepareMarkup}; +use crate::locale::L10n; +use crate::response::page::Page; +use crate::{global, service}; pub type ThemeRef = &'static dyn ThemeTrait; @@ -15,69 +19,16 @@ pub trait ThemeTrait: PackageTrait + Send + Sync { ("sidebar_right", L10n::l("sidebar_right")), ("footer", L10n::l("footer")), ] - } + } */ - #[allow(unused_variables)] - fn before_prepare_body(&self, page: &mut Page) {} - - fn prepare_body(&self, page: &mut Page) -> PrepareMarkup { - let skip_to_id = page.body_skip_to().get().unwrap_or("content".to_owned()); - - PrepareMarkup::With(html! { - body id=[page.body_id().get()] class=[page.body_classes().get()] { - @if let Some(skip) = L10n::l("skip_to_content").using(page.context().langid()) { - div class="skip__to_content" { - a href=(concat_string!("#", skip_to_id)) { (skip) } - } - } - (flex::Container::new() - .with_id("body__wrapper") - .with_direction(flex::Direction::Column(BreakPoint::None)) - .with_align(flex::Align::Center) - .add_item(flex::Item::region().with_id("header")) - .add_item(flex::Item::region().with_id("pagetop")) - .add_item( - flex::Item::with( - flex::Container::new() - .with_direction(flex::Direction::Row(BreakPoint::None)) - .add_item( - flex::Item::region() - .with_id("sidebar_left") - .with_grow(flex::Grow::Is1), - ) - .add_item( - flex::Item::region() - .with_id("content") - .with_grow(flex::Grow::Is3), - ) - .add_item( - flex::Item::region() - .with_id("sidebar_right") - .with_grow(flex::Grow::Is1), - ), - ) - .with_id("flex__wrapper"), - ) - .add_item(flex::Item::region().with_id("footer")) - .render(page.context())) - } - }) - } - - fn after_prepare_body(&self, page: &mut Page) { - page.set_assets(AssetsOp::SetFaviconIfNone( - Favicon::new().with_icon("/base/favicon.ico"), - )); - } - - fn prepare_head(&self, page: &mut Page) -> PrepareMarkup { + fn prepare_page_head(&self, page: &mut Page) -> PrepareMarkup { let viewport = "width=device-width, initial-scale=1, shrink-to-fit=no"; PrepareMarkup::With(html! { head { meta charset="utf-8"; @if let Some(title) = page.title() { - title { (global::SETTINGS.app.name) (" - ") (title) } + title { (global::SETTINGS.app.name) (" | ") (title) } } @else { title { (global::SETTINGS.app.name) } } @@ -100,5 +51,32 @@ pub trait ThemeTrait: PackageTrait + Send + Sync { } }) } - */ + + fn prepare_page_body(&self, page: &mut Page) -> PrepareMarkup { + PrepareMarkup::With(html! { + body id=[page.body_id().get()] class=[page.body_classes().get()] { + (page.body_content().render()) + } + }) + } + + fn error_403(&self, request: service::HttpRequest) -> Page { + Page::new(request) + .with_title(L10n::n("Error FORBIDDEN")) + .with_body(PrepareMarkup::With(html! { + div { + h1 { ("FORBIDDEN ACCESS") } + } + })) + } + + fn error_404(&self, request: service::HttpRequest) -> Page { + Page::new(request) + .with_title(L10n::n("Error RESOURCE NOT FOUND")) + .with_body(PrepareMarkup::With(html! { + div { + h1 { ("RESOURCE NOT FOUND") } + } + })) + } } diff --git a/pagetop/src/global.rs b/pagetop/src/global.rs index 49c0cdba..e538958b 100644 --- a/pagetop/src/global.rs +++ b/pagetop/src/global.rs @@ -1,10 +1,10 @@ //! Global settings. -use crate::static_config; +use crate::include_config; use serde::Deserialize; -static_config!(SETTINGS: Settings => [ +include_config!(SETTINGS: Settings => [ // [app] "app.name" => "My App", "app.description" => "Developed with the amazing PageTop framework.", diff --git a/pagetop/src/html.rs b/pagetop/src/html.rs index f81273bf..f9e70cd2 100644 --- a/pagetop/src/html.rs +++ b/pagetop/src/html.rs @@ -2,3 +2,46 @@ mod maud; pub use maud::{html, html_private, Markup, PreEscaped, DOCTYPE}; + +mod assets; +pub use assets::favicon::Favicon; +pub use assets::javascript::JavaScript; +pub use assets::stylesheet::{StyleSheet, TargetMedia}; +pub(crate) use assets::Assets; + +mod opt_id; +pub use opt_id::OptionId; + +mod opt_name; +pub use opt_name::OptionName; + +mod opt_string; +pub use opt_string::OptionString; + +mod opt_translated; +pub use opt_translated::OptionTranslated; + +mod opt_classes; +pub use opt_classes::{ClassesOp, OptionClasses}; + +pub mod unit; + +use crate::AutoDefault; + +#[derive(AutoDefault)] +pub enum PrepareMarkup { + #[default] + None, + Escaped(String), + With(Markup), +} + +impl PrepareMarkup { + pub fn render(&self) -> Markup { + match self { + PrepareMarkup::None => html! {}, + PrepareMarkup::Escaped(string) => html! { (PreEscaped(string)) }, + PrepareMarkup::With(markup) => html! { (markup) }, + } + } +} diff --git a/pagetop/src/html/assets.rs b/pagetop/src/html/assets.rs new file mode 100644 index 00000000..4c8f27ce --- /dev/null +++ b/pagetop/src/html/assets.rs @@ -0,0 +1,53 @@ +pub mod favicon; +pub mod javascript; +pub mod stylesheet; + +use crate::html::{html, Markup}; +use crate::{AutoDefault, Weight}; + +pub trait AssetsTrait { + fn name(&self) -> &String; + + fn weight(&self) -> Weight; + + fn prepare(&self) -> Markup; +} + +#[derive(AutoDefault)] +pub(crate) struct Assets(Vec); + +impl Assets { + pub fn new() -> Self { + Assets::(Vec::::new()) + } + + pub fn add(&mut self, asset: T) -> &mut Self { + match self.0.iter().position(|x| x.name() == asset.name()) { + Some(index) => { + if self.0[index].weight() > asset.weight() { + self.0.remove(index); + self.0.push(asset); + } + } + _ => self.0.push(asset), + }; + self + } + + pub fn remove(&mut self, name: &'static str) -> &mut Self { + if let Some(index) = self.0.iter().position(|x| x.name() == name) { + self.0.remove(index); + }; + self + } + + pub fn prepare(&mut self) -> Markup { + let assets = &mut self.0; + assets.sort_by_key(AssetsTrait::weight); + html! { + @for a in assets { + (a.prepare()) + } + } + } +} diff --git a/pagetop/src/html/assets/favicon.rs b/pagetop/src/html/assets/favicon.rs new file mode 100644 index 00000000..068efcb4 --- /dev/null +++ b/pagetop/src/html/assets/favicon.rs @@ -0,0 +1,93 @@ +use crate::html::{html, Markup}; +use crate::AutoDefault; + +#[derive(AutoDefault)] +pub struct Favicon(Vec); + +impl Favicon { + pub fn new() -> Self { + Favicon::default() + } + + // Favicon BUILDER. + + pub fn with_icon(self, image: &str) -> Self { + self.add_icon_item("icon", image, None, None) + } + + pub fn with_icon_for_sizes(self, image: &str, sizes: &str) -> Self { + self.add_icon_item("icon", image, Some(sizes), None) + } + + pub fn with_apple_touch_icon(self, image: &str, sizes: &str) -> Self { + self.add_icon_item("apple-touch-icon", image, Some(sizes), None) + } + + pub fn with_mask_icon(self, image: &str, color: &str) -> Self { + self.add_icon_item("mask-icon", image, None, Some(color)) + } + + pub fn with_manifest(self, file: &str) -> Self { + self.add_icon_item("manifest", file, None, None) + } + + pub fn with_theme_color(mut self, color: &str) -> Self { + self.0.push(html! { + meta name="theme-color" content=(color); + }); + self + } + + pub fn with_ms_tile_color(mut self, color: &str) -> Self { + self.0.push(html! { + meta name="msapplication-TileColor" content=(color); + }); + self + } + + pub fn with_ms_tile_image(mut self, image: &str) -> Self { + self.0.push(html! { + meta name="msapplication-TileImage" content=(image); + }); + self + } + + fn add_icon_item( + mut self, + icon_rel: &str, + icon_source: &str, + icon_sizes: Option<&str>, + icon_color: Option<&str>, + ) -> Self { + let icon_type = match icon_source.rfind('.') { + Some(i) => match icon_source[i..].to_owned().to_lowercase().as_str() { + ".gif" => Some("image/gif"), + ".ico" => Some("image/x-icon"), + ".jpg" => Some("image/jpg"), + ".png" => Some("image/png"), + ".svg" => Some("image/svg+xml"), + _ => None, + }, + _ => None, + }; + self.0.push(html! { + link + rel=(icon_rel) + type=[(icon_type)] + sizes=[(icon_sizes)] + color=[(icon_color)] + href=(icon_source); + }); + self + } + + // Favicon PREPARE. + + pub(crate) fn prepare(&self) -> Markup { + html! { + @for item in &self.0 { + (item) + } + } + } +} diff --git a/pagetop/src/html/assets/javascript.rs b/pagetop/src/html/assets/javascript.rs new file mode 100644 index 00000000..672ab3e0 --- /dev/null +++ b/pagetop/src/html/assets/javascript.rs @@ -0,0 +1,111 @@ +use crate::html::assets::AssetsTrait; +use crate::html::{html, Markup}; +use crate::{concat_string, AutoDefault, Weight}; + +#[derive(AutoDefault)] +enum Source { + #[default] + From(String), + Defer(String), + Async(String), + Inline(String, String), + OnLoad(String, String), +} + +#[rustfmt::skip] +#[derive(AutoDefault)] +pub struct JavaScript { + source : Source, + prefix : &'static str, + version: &'static str, + weight : Weight, +} + +impl AssetsTrait for JavaScript { + fn name(&self) -> &String { + match &self.source { + Source::From(path) => path, + Source::Defer(path) => path, + Source::Async(path) => path, + Source::Inline(name, _) => name, + Source::OnLoad(name, _) => name, + } + } + + fn weight(&self) -> Weight { + self.weight + } + + fn prepare(&self) -> Markup { + match &self.source { + Source::From(path) => html! { + script src=(concat_string!(path, self.prefix, self.version)) {}; + }, + Source::Defer(path) => html! { + script src=(concat_string!(path, self.prefix, self.version)) defer {}; + }, + Source::Async(path) => html! { + script src=(concat_string!(path, self.prefix, self.version)) async {}; + }, + Source::Inline(_, code) => html! { + script { (code) }; + }, + Source::OnLoad(_, code) => html! { (concat_string!( + "document.addEventListener('DOMContentLoaded',function(){", + code, + "});" + )) }, + } + } +} + +impl JavaScript { + pub fn from(path: impl Into) -> Self { + JavaScript { + source: Source::From(path.into()), + ..Default::default() + } + } + + pub fn defer(path: impl Into) -> Self { + JavaScript { + source: Source::Defer(path.into()), + ..Default::default() + } + } + + pub fn asynchronous(path: impl Into) -> Self { + JavaScript { + source: Source::Async(path.into()), + ..Default::default() + } + } + + pub fn inline(name: impl Into, script: impl Into) -> Self { + JavaScript { + source: Source::Inline(name.into(), script.into()), + ..Default::default() + } + } + + pub fn on_load(name: impl Into, script: impl Into) -> Self { + JavaScript { + source: Source::OnLoad(name.into(), script.into()), + ..Default::default() + } + } + + pub fn with_version(mut self, version: &'static str) -> Self { + (self.prefix, self.version) = if version.is_empty() { + ("", "") + } else { + ("?v=", version) + }; + self + } + + pub fn with_weight(mut self, value: Weight) -> Self { + self.weight = value; + self + } +} diff --git a/pagetop/src/html/assets/stylesheet.rs b/pagetop/src/html/assets/stylesheet.rs new file mode 100644 index 00000000..11dde4ef --- /dev/null +++ b/pagetop/src/html/assets/stylesheet.rs @@ -0,0 +1,95 @@ +use crate::html::assets::AssetsTrait; +use crate::html::{html, Markup, PreEscaped}; +use crate::{concat_string, AutoDefault, Weight}; + +#[derive(AutoDefault)] +enum Source { + #[default] + From(String), + Inline(String, String), +} + +pub enum TargetMedia { + Default, + Print, + Screen, + Speech, +} + +#[rustfmt::skip] +#[derive(AutoDefault)] +pub struct StyleSheet { + source : Source, + prefix : &'static str, + version: &'static str, + media : Option<&'static str>, + weight : Weight, +} + +impl AssetsTrait for StyleSheet { + fn name(&self) -> &String { + match &self.source { + Source::From(path) => path, + Source::Inline(name, _) => name, + } + } + + fn weight(&self) -> Weight { + self.weight + } + + fn prepare(&self) -> Markup { + match &self.source { + Source::From(path) => html! { + link + rel="stylesheet" + href=(concat_string!(path, self.prefix, self.version)) + media=[self.media]; + }, + Source::Inline(_, code) => html! { + style { (PreEscaped(code)) }; + }, + } + } +} + +impl StyleSheet { + pub fn from(path: impl Into) -> Self { + StyleSheet { + source: Source::From(path.into()), + ..Default::default() + } + } + + pub fn inline(name: impl Into, styles: impl Into) -> Self { + StyleSheet { + source: Source::Inline(name.into(), styles.into()), + ..Default::default() + } + } + + pub fn with_version(mut self, version: &'static str) -> Self { + (self.prefix, self.version) = if version.is_empty() { + ("", "") + } else { + ("?v=", version) + }; + self + } + + pub fn with_weight(mut self, value: Weight) -> Self { + self.weight = value; + self + } + + #[rustfmt::skip] + pub fn for_media(mut self, media: &TargetMedia) -> Self { + self.media = match media { + TargetMedia::Default => None, + TargetMedia::Print => Some("print"), + TargetMedia::Screen => Some("screen"), + TargetMedia::Speech => Some("speech"), + }; + self + } +} diff --git a/pagetop/src/html/opt_classes.rs b/pagetop/src/html/opt_classes.rs new file mode 100644 index 00000000..453991cd --- /dev/null +++ b/pagetop/src/html/opt_classes.rs @@ -0,0 +1,111 @@ +//! **OptionClasses** implements a *helper* for dynamically adding class names to components. +//! +//! This *helper* differentiates between default classes (generally associated with styles provided +//! by the theme) and user classes (for customizing components based on application styles). +//! +//! Classes can be added using [Add]. Operations to [Remove], [Replace] or [Toggle] a class, as well +//! as [Clear] all classes, are also provided. +//! +//! **OptionClasses** assumes that the order of the classes is irrelevant +//! (), and duplicate classes will not be allowed. + +use crate::{fn_builder, AutoDefault}; + +pub enum ClassesOp { + Add, + Prepend, + Remove, + Replace(String), + Toggle, + Set, +} + +#[derive(AutoDefault)] +pub struct OptionClasses(Vec); + +impl OptionClasses { + pub fn new(classes: impl Into) -> Self { + OptionClasses::default().with_value(ClassesOp::Prepend, classes) + } + + // OptionClasses BUILDER. + + #[fn_builder] + pub fn set_value(&mut self, op: ClassesOp, classes: impl Into) -> &mut Self { + let classes: String = classes.into(); + let classes: Vec<&str> = classes.split_ascii_whitespace().collect(); + + if classes.is_empty() { + return self; + } + + match op { + ClassesOp::Add => { + self.add(&classes, self.0.len()); + } + ClassesOp::Prepend => { + self.add(&classes, 0); + } + ClassesOp::Remove => { + for class in classes { + self.0.retain(|c| c.ne(&class.to_string())); + } + } + ClassesOp::Replace(classes_to_replace) => { + let mut pos = self.0.len(); + let replace: Vec<&str> = classes_to_replace.split_ascii_whitespace().collect(); + for class in replace { + if let Some(replace_pos) = self.0.iter().position(|c| c.eq(class)) { + self.0.remove(replace_pos); + if pos > replace_pos { + pos = replace_pos; + } + } + } + self.add(&classes, pos); + } + ClassesOp::Toggle => { + for class in classes { + if !class.is_empty() { + if let Some(pos) = self.0.iter().position(|c| c.eq(class)) { + self.0.remove(pos); + } else { + self.0.push(class.to_string()); + } + } + } + } + ClassesOp::Set => { + self.0.clear(); + self.add(&classes, 0); + } + } + + self + } + + #[inline] + fn add(&mut self, classes: &[&str], mut pos: usize) { + for &class in classes { + if !class.is_empty() && !self.0.iter().any(|c| c == class) { + self.0.insert(pos, class.to_string()); + pos += 1; + } + } + } + + // OptionClasses GETTERS. + + pub fn get(&self) -> Option { + if self.0.is_empty() { + None + } else { + Some(self.0.join(" ")) + } + } + + pub fn contains(&self, class: impl Into) -> bool { + let class: String = class.into(); + self.0.iter().any(|c| c.eq(&class)) + } +} diff --git a/pagetop/src/html/opt_id.rs b/pagetop/src/html/opt_id.rs new file mode 100644 index 00000000..80e98325 --- /dev/null +++ b/pagetop/src/html/opt_id.rs @@ -0,0 +1,29 @@ +use crate::{fn_builder, AutoDefault}; + +#[derive(AutoDefault)] +pub struct OptionId(Option); + +impl OptionId { + pub fn new(value: impl Into) -> Self { + OptionId::default().with_value(value) + } + + // OptionId BUILDER. + + #[fn_builder] + pub fn set_value(&mut self, value: impl Into) -> &mut Self { + self.0 = Some(value.into().trim().replace(' ', "_")); + self + } + + // OptionId GETTERS. + + pub fn get(&self) -> Option { + if let Some(value) = &self.0 { + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None + } +} diff --git a/pagetop/src/html/opt_name.rs b/pagetop/src/html/opt_name.rs new file mode 100644 index 00000000..5ba0c486 --- /dev/null +++ b/pagetop/src/html/opt_name.rs @@ -0,0 +1,29 @@ +use crate::{fn_builder, AutoDefault}; + +#[derive(AutoDefault)] +pub struct OptionName(Option); + +impl OptionName { + pub fn new(value: impl Into) -> Self { + OptionName::default().with_value(value) + } + + // OptionName BUILDER. + + #[fn_builder] + pub fn set_value(&mut self, value: impl Into) -> &mut Self { + self.0 = Some(value.into().trim().replace(' ', "_")); + self + } + + // OptionName GETTERS. + + pub fn get(&self) -> Option { + if let Some(value) = &self.0 { + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None + } +} diff --git a/pagetop/src/html/opt_string.rs b/pagetop/src/html/opt_string.rs new file mode 100644 index 00000000..7de22486 --- /dev/null +++ b/pagetop/src/html/opt_string.rs @@ -0,0 +1,29 @@ +use crate::{fn_builder, AutoDefault}; + +#[derive(AutoDefault)] +pub struct OptionString(Option); + +impl OptionString { + pub fn new(value: impl Into) -> Self { + OptionString::default().with_value(value) + } + + // OptionString BUILDER. + + #[fn_builder] + pub fn set_value(&mut self, value: impl Into) -> &mut Self { + self.0 = Some(value.into().trim().to_owned()); + self + } + + // OptionString GETTERS. + + pub fn get(&self) -> Option { + if let Some(value) = &self.0 { + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None + } +} diff --git a/pagetop/src/html/opt_translated.rs b/pagetop/src/html/opt_translated.rs new file mode 100644 index 00000000..e50a073f --- /dev/null +++ b/pagetop/src/html/opt_translated.rs @@ -0,0 +1,30 @@ +use crate::html::Markup; +use crate::locale::{L10n, LanguageIdentifier}; +use crate::{fn_builder, AutoDefault}; + +#[derive(AutoDefault)] +pub struct OptionTranslated(L10n); + +impl OptionTranslated { + pub fn new(value: L10n) -> Self { + OptionTranslated(value) + } + + // OptionTranslated BUILDER. + + #[fn_builder] + pub fn set_value(&mut self, value: L10n) -> &mut Self { + self.0 = value; + self + } + + // OptionTranslated GETTERS. + + pub fn using(&self, langid: &LanguageIdentifier) -> Option { + self.0.using(langid) + } + + pub fn escaped(&self, langid: &LanguageIdentifier) -> Markup { + self.0.escaped(langid) + } +} diff --git a/pagetop/src/html/unit.rs b/pagetop/src/html/unit.rs new file mode 100644 index 00000000..5a153c55 --- /dev/null +++ b/pagetop/src/html/unit.rs @@ -0,0 +1,56 @@ +use crate::AutoDefault; + +use std::fmt; + +// About pixels: Pixels (px) are relative to the viewing device. For low-dpi devices, 1px is one +// device pixel (dot) of the display. For printers and high resolution screens 1px implies multiple +// device pixels. + +// About em: 2em means 2 times the size of the current font. The em and rem units are practical in +// creating perfectly scalable layout! + +// About viewport: If the browser window size is 50cm wide, 1vw = 0.5cm. + +#[rustfmt::skip] +#[derive(AutoDefault)] +pub enum Value { + #[default] + None, + Auto, + + Cm(isize), // Centimeters. + In(isize), // Inches (1in = 96px = 2.54cm). + Mm(isize), // Millimeters. + Pc(isize), // Picas (1pc = 12pt). + Pt(isize), // Points (1pt = 1/72 of 1in). + Px(isize), // Pixels (1px = 1/96th of 1in). + + RelEm(f32), // Relative to the font-size of the element. + RelPct(f32), // Percentage relative to the parent element. + RelRem(f32), // Relative to font-size of the root element. + RelVh(f32), // Relative to 1% of the height of the viewport. + RelVw(f32), // Relative to 1% of the value of the viewport. +} + +#[rustfmt::skip] +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::None => write!(f, ""), + Value::Auto => write!(f, "auto"), + // Absolute value. + Value::Cm(av) => write!(f, "{av}cm"), + Value::In(av) => write!(f, "{av}in"), + Value::Mm(av) => write!(f, "{av}mm"), + Value::Pc(av) => write!(f, "{av}pc"), + Value::Pt(av) => write!(f, "{av}pt"), + Value::Px(av) => write!(f, "{av}px"), + // Relative value. + Value::RelEm(rv) => write!(f, "{rv}em"), + Value::RelPct(rv) => write!(f, "{rv}%"), + Value::RelRem(rv) => write!(f, "{rv}rem"), + Value::RelVh(rv) => write!(f, "{rv}vh"), + Value::RelVw(rv) => write!(f, "{rv}vw"), + } + } +} diff --git a/pagetop/src/lib.rs b/pagetop/src/lib.rs index c0c2e9a9..7ab5dade 100644 --- a/pagetop/src/lib.rs +++ b/pagetop/src/lib.rs @@ -42,7 +42,7 @@ //! //! async fn hello_world(request: HttpRequest) -> ResultPage { //! Page::new(request) -//! .with_component(Html::with(html! { h1 { "Hello World!" } })) +//! .with_body(PrepareMarkup::With(html! { h1 { "Hello World!" } })) //! .render() //! } //! @@ -79,7 +79,7 @@ pub use concat_string::concat_string; /// Enables flexible identifier concatenation in macros, allowing new items with pasted identifiers. pub use paste::paste; -pub use pagetop_macros::{html, main, test, AutoDefault}; +pub use pagetop_macros::{fn_builder, html, main, test, AutoDefault}; pub type StaticResources = std::collections::HashMap<&'static str, static_files::Resource>; diff --git a/pagetop/src/locale.rs b/pagetop/src/locale.rs index ab99968a..39c73c68 100644 --- a/pagetop/src/locale.rs +++ b/pagetop/src/locale.rs @@ -67,13 +67,13 @@ //! # How to apply localization in your code //! //! Once you have created your FTL resource directory, use the -//! [`static_locales!`](crate::static_locales) macro to integrate them into your module or +//! [`include_locales!`](crate::include_locales) macro to integrate them into your module or //! application. If your resources are located in the `"src/locale"` directory, simply declare: //! //! ``` //! use pagetop::prelude::*; //! -//! static_locales!(LOCALES_SAMPLE); +//! include_locales!(LOCALES_SAMPLE); //! ``` //! //! But if they are in another directory, then you can use: @@ -81,7 +81,7 @@ //! ``` //! use pagetop::prelude::*; //! -//! static_locales!(LOCALES_SAMPLE in "path/to/locale"); +//! include_locales!(LOCALES_SAMPLE from "path/to/locale"); //! ``` use crate::html::{Markup, PreEscaped}; @@ -89,55 +89,75 @@ use crate::{global, kv, AutoDefault}; pub use fluent_bundle::FluentValue; pub use fluent_templates; -pub use unic_langid::LanguageIdentifier; +pub use unic_langid::{CharacterDirection, LanguageIdentifier}; use fluent_templates::Loader; use fluent_templates::StaticLoader as Locales; use unic_langid::langid; +use std::borrow::Cow; use std::collections::HashMap; use std::sync::LazyLock; use std::fmt; -const LANGUAGE_SET_FAILURE: &str = "language_set_failure"; - /// A mapping between language codes (e.g., "en-US") and their corresponding [`LanguageIdentifier`] -/// and human-readable names. +/// and locale key names. static LANGUAGES: LazyLock> = LazyLock::new(|| { kv![ - "en" => (langid!("en-US"), "English"), - "en-GB" => (langid!("en-GB"), "English (British)"), - "en-US" => (langid!("en-US"), "English (United States)"), - "es" => (langid!("es-ES"), "Spanish"), - "es-ES" => (langid!("es-ES"), "Spanish (Spain)"), + "en" => ( langid!("en-US"), "english" ), + "en-GB" => ( langid!("en-GB"), "english_british" ), + "en-US" => ( langid!("en-US"), "english_united_states" ), + "es" => ( langid!("es-ES"), "spanish" ), + "es-ES" => ( langid!("es-ES"), "spanish_spain" ), ] }); -pub static LANGID_FALLBACK: LazyLock = LazyLock::new(|| langid!("en-US")); +static FALLBACK: LazyLock = LazyLock::new(|| langid!("en-US")); /// Sets the application's default /// [Unicode Language Identifier](https://unicode.org/reports/tr35/tr35.html#Unicode_language_identifier) /// through `SETTINGS.app.language`. pub static DEFAULT_LANGID: LazyLock<&LanguageIdentifier> = - LazyLock::new(|| langid_for(&global::SETTINGS.app.language).unwrap_or(&LANGID_FALLBACK)); + LazyLock::new(|| langid_for(&global::SETTINGS.app.language).unwrap_or(&FALLBACK)); -pub fn langid_for(language: impl Into) -> Result<&'static LanguageIdentifier, String> { +pub enum LangError { + EmptyLang, + UnknownLang(String), +} + +impl fmt::Display for LangError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LangError::EmptyLang => write!(f, "The language identifier is empty."), + LangError::UnknownLang(lang) => write!(f, "Unknown language identifier: {lang}"), + } + } +} + +pub fn langid_for(language: impl Into) -> Result<&'static LanguageIdentifier, LangError> { let language = language.into(); if language.is_empty() { - return Ok(&LANGID_FALLBACK); + return Err(LangError::EmptyLang); } - LANGUAGES - .get(&language) - .map(|(langid, _)| langid) - .ok_or_else(|| format!("No langid for Unicode Language Identifier \"{language}\".")) + // Attempt to match the full language code (e.g., "es-MX"). + if let Some(langid) = LANGUAGES.get(&language).map(|(langid, _)| langid) { + return Ok(langid); + } + // Fallback to the base language if no sublocale is found (e.g., "es"). + if let Some((base_lang, _)) = language.split_once('-') { + if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) { + return Ok(langid); + } + } + Err(LangError::UnknownLang(language)) } #[macro_export] /// Defines a set of localization elements and local translation texts, removing Unicode isolating /// marks around arguments to improve readability and compatibility in certain rendering contexts. -macro_rules! static_locales { +macro_rules! include_locales { ( $LOCALES:ident $(, $core_locales:literal)? ) => { $crate::locale::fluent_templates::static_loader! { static $LOCALES = { @@ -149,7 +169,7 @@ macro_rules! static_locales { }; } }; - ( $LOCALES:ident in $dir_locales:literal $(, $core_locales:literal)? ) => { + ( $LOCALES:ident from $dir_locales:literal $(, $core_locales:literal)? ) => { $crate::locale::fluent_templates::static_loader! { static $LOCALES = { locales: $dir_locales, @@ -162,7 +182,7 @@ macro_rules! static_locales { }; } -static_locales!(LOCALES_PAGETOP); +include_locales!(LOCALES_PAGETOP); #[derive(AutoDefault)] enum L10nOp { @@ -180,13 +200,9 @@ pub struct L10n { } impl L10n { - pub fn none() -> Self { - L10n::default() - } - - pub fn n(text: impl Into) -> Self { + pub fn n>>(text: S) -> Self { L10n { - op: L10nOp::Text(text.into()), + op: L10nOp::Text(text.into().to_string()), ..Default::default() } } @@ -208,11 +224,32 @@ impl L10n { } pub fn with_arg(mut self, arg: impl Into, value: impl Into) -> Self { - self.args - .insert(arg.into(), FluentValue::from(value.into())); + let value = FluentValue::from(value.into()); + self.args.insert(arg.into(), value); self } + pub fn add_args(mut self, args: HashMap) -> Self { + for (k, v) in args { + self.args.insert(k, FluentValue::from(v)); + } + self + } + + pub fn with_count(mut self, key: impl Into, count: usize) -> Self { + self.args.insert(key.into(), FluentValue::from(count)); + self + } + + pub fn with_date(mut self, key: impl Into, date: impl Into) -> Self { + self.args.insert(key.into(), FluentValue::from(date.into())); + self + } + + pub fn get(&self) -> Option { + self.using(&DEFAULT_LANGID) + } + pub fn using(&self, langid: &LanguageIdentifier) -> Option { match &self.op { L10nOp::None => None, @@ -230,13 +267,13 @@ impl L10n { } } - /// Escapes the content using the default language identifier. + /// Escapes translated text using the default language identifier. pub fn markup(&self) -> Markup { - let content = self.using(&DEFAULT_LANGID).unwrap_or_default(); + let content = self.get().unwrap_or_default(); PreEscaped(content) } - /// Escapes the content using the specified language identifier. + /// Escapes translated text using the specified language identifier. pub fn escaped(&self, langid: &LanguageIdentifier) -> Markup { let content = self.using(langid).unwrap_or_default(); PreEscaped(content) @@ -245,37 +282,11 @@ impl L10n { impl fmt::Display for L10n { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.op { - L10nOp::None => write!(f, ""), - L10nOp::Text(text) => write!(f, "{text}"), - L10nOp::Translate(key) => { - if let Some(locales) = self.locales { - write!( - f, - "{}", - if self.args.is_empty() { - locales.lookup( - match key.as_str() { - LANGUAGE_SET_FAILURE => &LANGID_FALLBACK, - _ => &DEFAULT_LANGID, - }, - key, - ) - } else { - locales.lookup_with_args( - match key.as_str() { - LANGUAGE_SET_FAILURE => &LANGID_FALLBACK, - _ => &DEFAULT_LANGID, - }, - key, - &self.args, - ) - } - ) - } else { - write!(f, "Unknown localization {key}") - } - } - } + let content = match &self.op { + L10nOp::None => "".to_string(), + L10nOp::Text(text) => text.clone(), + L10nOp::Translate(key) => self.get().unwrap_or_else(|| format!("No <{}>", key)), + }; + write!(f, "{}", content) } } diff --git a/pagetop/src/locale/en-US/languages.ftl b/pagetop/src/locale/en-US/languages.ftl new file mode 100644 index 00000000..1e816605 --- /dev/null +++ b/pagetop/src/locale/en-US/languages.ftl @@ -0,0 +1,5 @@ +english = English +english_british = English (British) +english_united_states = English (United States) +spanish = Spanish +spanish_spain = Spanish (Spain) diff --git a/pagetop/src/locale/es-ES/languages.ftl b/pagetop/src/locale/es-ES/languages.ftl new file mode 100644 index 00000000..ee74ec26 --- /dev/null +++ b/pagetop/src/locale/es-ES/languages.ftl @@ -0,0 +1,5 @@ +english = Inglés +english_british = Inglés (Gran Bretaña) +english_united_states = Inglés (Estados Unidos) +spanish = Español +spanish_spain = Español (España) diff --git a/pagetop/src/prelude.rs b/pagetop/src/prelude.rs index 8d554e57..a222e88a 100644 --- a/pagetop/src/prelude.rs +++ b/pagetop/src/prelude.rs @@ -2,18 +2,18 @@ // RE-EXPORTED. -pub use crate::{concat_string, html, main, paste, test}; +pub use crate::{concat_string, fn_builder, html, main, paste, test}; pub use crate::{AutoDefault, StaticResources, TypeId, Weight}; // MACROS. // crate::util -pub use crate::{kv, static_config}; +pub use crate::{include_config, kv}; // crate::locale -pub use crate::static_locales; +pub use crate::include_locales; // crate::service -pub use crate::{static_files, static_files_service}; +pub use crate::{include_files, include_files_service}; // crate::core::action pub use crate::actions; @@ -36,7 +36,7 @@ pub use crate::core::action::*; pub use crate::core::package::*; pub use crate::core::theme::*; -pub use crate::response::{json::*, redirect::*, ResponseError}; +pub use crate::response::{json::*, page::*, redirect::*, ResponseError}; pub use crate::global; diff --git a/pagetop/src/response.rs b/pagetop/src/response.rs index ecbcd954..e51974b1 100644 --- a/pagetop/src/response.rs +++ b/pagetop/src/response.rs @@ -2,6 +2,8 @@ pub use actix_web::ResponseError; +pub mod page; + pub mod json; pub mod redirect; diff --git a/pagetop/src/response/page.rs b/pagetop/src/response/page.rs new file mode 100644 index 00000000..66ff300c --- /dev/null +++ b/pagetop/src/response/page.rs @@ -0,0 +1,195 @@ +mod error; +pub use error::ErrorPage; + +mod context; +pub use context::{AssetsOp, ContextPage /*, ParamError*/}; +/* +pub type FnContextualPath = fn(cx: &Context) -> &str; +*/ + +use crate::fn_builder; +use crate::html::{html, Markup, PrepareMarkup, DOCTYPE}; +use crate::html::{ClassesOp, OptionClasses, OptionId, OptionTranslated}; +use crate::locale::L10n; +use crate::service::HttpRequest; + +pub use actix_web::Result as ResultPage; + +use unic_langid::CharacterDirection; + +#[rustfmt::skip] +pub struct Page { + title : OptionTranslated, + description : OptionTranslated, + metadata : Vec<(&'static str, &'static str)>, + properties : Vec<(&'static str, &'static str)>, + context : ContextPage, + body_id : OptionId, + body_classes: OptionClasses, + body_content: PrepareMarkup, +} + +impl Page { + #[rustfmt::skip] + pub fn new(request: HttpRequest) -> Self { + Page { + title : OptionTranslated::default(), + description : OptionTranslated::default(), + metadata : Vec::default(), + properties : Vec::default(), + context : ContextPage::new(request), + body_id : OptionId::default(), + body_classes: OptionClasses::default(), + body_content: PrepareMarkup::default(), + } + } + + // Page BUILDER. + + #[fn_builder] + pub fn set_title(&mut self, title: L10n) -> &mut Self { + self.title.set_value(title); + self + } + + #[fn_builder] + pub fn set_description(&mut self, description: L10n) -> &mut Self { + self.description.set_value(description); + self + } + + #[fn_builder] + pub fn set_metadata(&mut self, name: &'static str, content: &'static str) -> &mut Self { + self.metadata.push((name, content)); + self + } + + #[fn_builder] + pub fn set_property(&mut self, property: &'static str, content: &'static str) -> &mut Self { + self.metadata.push((property, content)); + self + } + + #[fn_builder] + pub fn set_assets(&mut self, op: AssetsOp) -> &mut Self { + self.context.set_assets(op); + self + } + + #[fn_builder] + pub fn set_body_id(&mut self, id: impl Into) -> &mut Self { + self.body_id.set_value(id); + self + } + + #[fn_builder] + pub fn set_body_classes(&mut self, op: ClassesOp, classes: impl Into) -> &mut Self { + self.body_classes.set_value(op, classes); + self + } + + #[fn_builder] + pub fn set_body(&mut self, content: PrepareMarkup) -> &mut Self { + self.body_content = content; + self + } + /* + #[fn_builder] + pub fn set_layout(&mut self, layout: &'static str) -> &mut Self { + self.context.set_assets(AssetsOp::Layout(layout)); + self + } + + #[fn_builder] + pub fn set_regions(&mut self, region: &'static str, op: AnyOp) -> &mut Self { + self.context.set_regions(region, op); + self + } + + pub fn with_component(mut self, component: impl ComponentTrait) -> Self { + self.context + .set_regions("content", AnyOp::Add(AnyComponent::with(component))); + self + } + + pub fn with_component_in( + mut self, + region: &'static str, + component: impl ComponentTrait, + ) -> Self { + self.context + .set_regions(region, AnyOp::Add(AnyComponent::with(component))); + self + } + */ + // Page GETTERS. + + pub fn title(&mut self) -> Option { + self.title.using(self.context.langid()) + } + + pub fn description(&mut self) -> Option { + self.description.using(self.context.langid()) + } + + pub fn metadata(&self) -> &Vec<(&str, &str)> { + &self.metadata + } + + pub fn properties(&self) -> &Vec<(&str, &str)> { + &self.properties + } + + pub fn context(&mut self) -> &mut ContextPage { + &mut self.context + } + + pub fn body_id(&self) -> &OptionId { + &self.body_id + } + + pub fn body_classes(&self) -> &OptionClasses { + &self.body_classes + } + + pub fn body_content(&self) -> &PrepareMarkup { + &self.body_content + } + + // Page RENDER. + + pub fn render(&mut self) -> ResultPage { + // Theme operations before preparing the page body. + //self.context.theme().before_prepare_body(self); + + // Packages actions before preparing the page body. + //action::page::BeforePrepareBody::dispatch(self); + + // Prepare page body. + let body = self.context.theme().prepare_page_body(self); + + // Theme operations after preparing the page body. + //self.context.theme().after_prepare_body(self); + + // Packages actions after preparing the page body. + //action::page::AfterPrepareBody::dispatch(self); + + // Prepare page head. + let head = self.context.theme().prepare_page_head(self); + + // Render the page. + let lang = self.context.langid().language.as_str(); + let dir = match self.context.langid().character_direction() { + CharacterDirection::LTR => "ltr", + CharacterDirection::RTL => "rtl", + CharacterDirection::TTB => "auto", + }; + Ok(html! { + (DOCTYPE) + html lang=(lang) dir=(dir) { + (head.render()) + (body.render()) + } + }) + } +} diff --git a/pagetop/src/response/page/context.rs b/pagetop/src/response/page/context.rs new file mode 100644 index 00000000..8c4e9078 --- /dev/null +++ b/pagetop/src/response/page/context.rs @@ -0,0 +1,208 @@ +/* +use crate::base::component::add_base_assets; +use crate::concat_string; +use crate::core::component::AnyOp; */ +use crate::core::theme::all::{theme_by_short_name, DEFAULT_THEME}; +use crate::core::theme::{/*ComponentsInRegions,*/ ThemeRef}; +/* use crate::global::TypeInfo; */ +use crate::html::{html, Markup}; +use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; +use crate::locale::{LanguageIdentifier, DEFAULT_LANGID}; +use crate::service::HttpRequest; +/* +use std::collections::HashMap; +use std::error::Error; +use std::str::FromStr; + +use std::fmt; +*/ + +pub enum AssetsOp { + LangId(&'static LanguageIdentifier), + Theme(&'static str), + //Layout(&'static str), + // Favicon. + SetFavicon(Option), + SetFaviconIfNone(Favicon), + // Stylesheets. + AddStyleSheet(StyleSheet), + RemoveStyleSheet(&'static str), + // JavaScripts. + AddJavaScript(JavaScript), + RemoveJavaScript(&'static str), + // Add assets to properly use base components. + //AddBaseAssets, +} +/* +#[derive(Debug)] +pub enum ParamError { + NotFound, + ParseError(String), +} + +impl fmt::Display for ParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParamError::NotFound => write!(f, "Parameter not found"), + ParamError::ParseError(e) => write!(f, "Parse error: {e}"), + } + } +} + +impl Error for ParamError {} +*/ +#[rustfmt::skip] +pub struct ContextPage { + request : HttpRequest, + langid : &'static LanguageIdentifier, + theme : ThemeRef, /* + layout : &'static str, */ + favicon : Option, + stylesheet: Assets, + javascript: Assets, /* + regions : ComponentsInRegions, + params : HashMap<&'static str, String>, + id_counter: usize, */ +} + +impl ContextPage { + #[rustfmt::skip] + pub(crate) fn new(request: HttpRequest) -> Self { + ContextPage { + request, + langid : &DEFAULT_LANGID, + theme : *DEFAULT_THEME, /* + layout : "default", */ + favicon : None, + stylesheet: Assets::::new(), + javascript: Assets::::new(), /* + regions : ComponentsInRegions::default(), + params : HashMap::<&str, String>::new(), + id_counter: 0,*/ + } + } + + pub fn set_assets(&mut self, op: AssetsOp) -> &mut Self { + match op { + AssetsOp::LangId(langid) => { + self.langid = langid; + } + AssetsOp::Theme(theme_name) => { + self.theme = theme_by_short_name(theme_name).unwrap_or(*DEFAULT_THEME); + } /* + AssetsOp::Layout(layout) => { + self.layout = layout; + } */ + // Favicon. + AssetsOp::SetFavicon(favicon) => { + self.favicon = favicon; + } + AssetsOp::SetFaviconIfNone(icon) => { + if self.favicon.is_none() { + self.favicon = Some(icon); + } + } + // Stylesheets. + AssetsOp::AddStyleSheet(css) => { + self.stylesheet.add(css); + } + AssetsOp::RemoveStyleSheet(path) => { + self.stylesheet.remove(path); + } + // JavaScripts. + AssetsOp::AddJavaScript(js) => { + self.javascript.add(js); + } + AssetsOp::RemoveJavaScript(path) => { + self.javascript.remove(path); + } /* + // Add assets to properly use base components. + AssetsOp::AddBaseAssets => { + add_base_assets(self); + } */ + } + self + } + /* + pub fn set_regions(&mut self, region: &'static str, op: AnyOp) -> &mut Self { + self.regions.set_components(region, op); + self + } + + pub fn set_param(&mut self, key: &'static str, value: &T) -> &mut Self { + self.params.insert(key, value.to_string()); + self + } + */ + // Context GETTERS. + + pub fn request(&self) -> &HttpRequest { + &self.request + } + + pub fn langid(&self) -> &LanguageIdentifier { + self.langid + } + + pub fn theme(&self) -> ThemeRef { + self.theme + } + /* + pub fn layout(&self) -> &str { + self.layout + } + + pub fn regions(&self) -> &ComponentsInRegions { + &self.regions + } + + pub fn get_param(&self, key: &'static str) -> Result { + self.params + .get(key) + .ok_or(ParamError::NotFound) + .and_then(|v| T::from_str(v).map_err(|_| ParamError::ParseError(v.clone()))) + } + */ + // Context PREPARE. + + pub(crate) fn prepare_assets(&mut self) -> Markup { + html! { + @if let Some(favicon) = &self.favicon { + (favicon.prepare()) + } + (self.stylesheet.prepare()) + (self.javascript.prepare()) + } + } + /* + pub(crate) fn prepare_region(&mut self, region: impl Into) -> Markup { + self.regions + .all_components(self.theme, region.into().as_str()) + .render(self) + } + + // Context EXTRAS. + + pub fn remove_param(&mut self, key: &'static str) -> bool { + self.params.remove(key).is_some() + } + + pub fn required_id(&mut self, id: Option) -> String { + if let Some(id) = id { + id + } else { + let prefix = TypeInfo::ShortName + .of::() + .trim() + .replace(' ', "_") + .to_lowercase(); + let prefix = if prefix.is_empty() { + "prefix".to_owned() + } else { + prefix + }; + self.id_counter += 1; + concat_string!(prefix, "-", self.id_counter.to_string()) + } + } */ +} diff --git a/pagetop/src/response/page/error.rs b/pagetop/src/response/page/error.rs new file mode 100644 index 00000000..2dfa93ff --- /dev/null +++ b/pagetop/src/response/page/error.rs @@ -0,0 +1,71 @@ +use crate::core::theme::all::DEFAULT_THEME; +use crate::response::ResponseError; +use crate::service::http::{header::ContentType, StatusCode}; +use crate::service::{HttpRequest, HttpResponse}; + +use std::fmt; + +#[derive(Debug)] +pub enum ErrorPage { + NotModified(HttpRequest), + BadRequest(HttpRequest), + AccessDenied(HttpRequest), + NotFound(HttpRequest), + PreconditionFailed(HttpRequest), + InternalError(HttpRequest), + Timeout(HttpRequest), +} + +impl fmt::Display for ErrorPage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // Error 304. + ErrorPage::NotModified(_) => write!(f, "Not Modified"), + // Error 400. + ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"), + // Error 403. + ErrorPage::AccessDenied(request) => { + if let Ok(page) = DEFAULT_THEME.error_403(request.clone()).render() { + write!(f, "{}", page.into_string()) + } else { + write!(f, "Access Denied") + } + } + // Error 404. + ErrorPage::NotFound(request) => { + if let Ok(page) = DEFAULT_THEME.error_404(request.clone()).render() { + write!(f, "{}", page.into_string()) + } else { + write!(f, "Not Found") + } + } + // Error 412. + ErrorPage::PreconditionFailed(_) => write!(f, "Precondition Failed"), + // Error 500. + ErrorPage::InternalError(_) => write!(f, "Internal Error"), + // Error 504. + ErrorPage::Timeout(_) => write!(f, "Timeout"), + } + } +} + +impl ResponseError for ErrorPage { + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()) + .insert_header(ContentType::html()) + .body(self.to_string()) + } + + #[rustfmt::skip] + fn status_code(&self) -> StatusCode { + match self { + ErrorPage::NotModified(_) => StatusCode::NOT_MODIFIED, + ErrorPage::BadRequest(_) => StatusCode::BAD_REQUEST, + ErrorPage::AccessDenied(_) => StatusCode::FORBIDDEN, + ErrorPage::NotFound(_) => StatusCode::NOT_FOUND, + ErrorPage::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED, + ErrorPage::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::Timeout(_) => StatusCode::GATEWAY_TIMEOUT, + } + } +} diff --git a/pagetop/src/service.rs b/pagetop/src/service.rs index e28be6e9..e6bc6373 100644 --- a/pagetop/src/service.rs +++ b/pagetop/src/service.rs @@ -13,7 +13,7 @@ pub use actix_web_files::Files as ActixFiles; pub use actix_web_static_files::ResourceFiles; #[macro_export] -macro_rules! static_files { +macro_rules! include_files { ( $bundle:ident ) => { $crate::paste! { mod [] { @@ -34,11 +34,12 @@ macro_rules! static_files { } #[macro_export] -macro_rules! static_files_service { +macro_rules! include_files_service { ( $scfg:ident, $bundle:ident => $path:expr $(, [$root:expr, $relative:expr])? ) => {{ $crate::paste! { let span = $crate::trace::debug_span!("Configuring static files ", path = $path); let _ = span.in_scope(|| { + #[allow(unused_mut)] let mut serve_embedded:bool = true; $( if !$root.is_empty() && !$relative.is_empty() { diff --git a/pagetop/src/util.rs b/pagetop/src/util.rs index 22f79341..ed5ca946 100644 --- a/pagetop/src/util.rs +++ b/pagetop/src/util.rs @@ -218,7 +218,7 @@ macro_rules! kv { /// serde = { version = "1.0", features = ["derive"] } /// ``` /// -/// Y luego inicializa con la macro [`static_config!`](crate::static_config) tus ajustes, usando +/// Y luego inicializa con la macro [`include_config!`](crate::include_config) tus ajustes, usando /// tipos seguros y asignando los valores predefinidos para la estructura asociada: /// /// ``` @@ -238,7 +238,7 @@ macro_rules! kv { /// pub height: u16, /// } /// -/// static_config!(SETTINGS: Settings => [ +/// include_config!(SETTINGS: Settings from [ /// // [myapp] /// "myapp.name" => "Value Name", /// "myapp.width" => 900, @@ -278,7 +278,7 @@ macro_rules! kv { /// println!("{}", &config::SETTINGS.myapp.width); /// } /// ``` -macro_rules! static_config { +macro_rules! include_config { ( $SETTINGS:ident: $Settings:ty => [ $($key:literal => $value:literal),* $(,)? ] ) => { #[doc = concat!( "Assigned or predefined values for configuration settings associated to the ", diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 00000000..95e1affa Binary files /dev/null and b/static/favicon.ico differ