diff --git a/examples/comprehensive/tiny/src/lib.rs b/examples/comprehensive/tiny/src/lib.rs index 781be00f4c..1ae8e990ee 100644 --- a/examples/comprehensive/tiny/src/lib.rs +++ b/examples/comprehensive/tiny/src/lib.rs @@ -1,12 +1,12 @@ -use perseus::{define_app, ErrorPages, Template}; +use perseus::{Html, PerseusApp, Template}; use sycamore::view; -define_app! { - templates: [ - Template::::new("index").template(|_| view! { - p { "Hello World!" } + +pub fn main() -> PerseusApp { + PerseusApp::new().template(|| { + Template::new("index").template(|_| { + view! { + p { "Hello World!" } + } }) - ], - error_pages: ErrorPages::new(|url, status, err, _| view! { - p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } }) } diff --git a/examples/core/basic/.perseus/Cargo.toml b/examples/core/basic/.perseus/Cargo.toml index 33f4007e5d..a4b92b64a7 100644 --- a/examples/core/basic/.perseus/Cargo.toml +++ b/examples/core/basic/.perseus/Cargo.toml @@ -19,6 +19,7 @@ web-sys = { version = "0.3", features = ["Event", "Headers", "Request", "Request wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" console_error_panic_hook = "0.1.6" +lazy_static = "1" # This section is needed for Wasm Pack (which we use instead of Trunk for flexibility) [lib] diff --git a/examples/core/basic/.perseus/builder/src/bin/build.rs b/examples/core/basic/.perseus/builder/src/bin/build.rs index e7309a6dc5..29a676d8e5 100644 --- a/examples/core/basic/.perseus/builder/src/bin/build.rs +++ b/examples/core/basic/.perseus/builder/src/bin/build.rs @@ -3,10 +3,7 @@ use perseus::{ internal::build::{build_app, BuildProps}, PluginAction, SsrNode, }; -use perseus_engine::app::{ - get_global_state_creator, get_immutable_store, get_locales, get_mutable_store, get_plugins, - get_templates_map, get_translations_manager, -}; +use perseus_engine as app; #[tokio::main] async fn main() { @@ -17,7 +14,8 @@ async fn main() { async fn real_main() -> i32 { // We want to be working in the root of `.perseus/` std::env::set_current_dir("../").unwrap(); - let plugins = get_plugins::(); + let app = app::main::(); + let plugins = app.get_plugins(); plugins .functional_actions @@ -25,13 +23,11 @@ async fn real_main() -> i32 { .before_build .run((), plugins.get_plugin_data()); - let immutable_store = get_immutable_store(&plugins); - let mutable_store = get_mutable_store(); - // We can't proceed without a translations manager - let translations_manager = get_translations_manager().await; - let locales = get_locales(&plugins); + let immutable_store = app.get_immutable_store(); + let mutable_store = app.get_mutable_store(); + let locales = app.get_locales(); // Generate the global state - let gsc = get_global_state_creator(); + let gsc = app.get_global_state_creator(); let global_state = match gsc.get_build_state().await { Ok(global_state) => global_state, Err(err) => { @@ -48,7 +44,11 @@ async fn real_main() -> i32 { // Build the site for all the common locales (done in parallel) // All these parameters can be modified by `define_app!` and plugins, so there's no point in having a plugin opportunity here - let templates_map = get_templates_map::(&plugins); + let templates_map = app.get_templates_map(); + + // We have to get the translations manager last, because it consumes everything + let translations_manager = app.get_translations_manager().await; + let res = build_app(BuildProps { templates: &templates_map, locales: &locales, diff --git a/examples/core/basic/.perseus/builder/src/bin/export.rs b/examples/core/basic/.perseus/builder/src/bin/export.rs index 8ff2a85882..23918adb14 100644 --- a/examples/core/basic/.perseus/builder/src/bin/export.rs +++ b/examples/core/basic/.perseus/builder/src/bin/export.rs @@ -8,10 +8,7 @@ use perseus::{ }, PluginAction, SsrNode, }; -use perseus_engine::app::{ - get_app_root, get_global_state_creator, get_immutable_store, get_locales, get_mutable_store, - get_plugins, get_static_aliases, get_templates_map, get_translations_manager, -}; +use perseus_engine as app; use std::fs; use std::path::PathBuf; @@ -24,8 +21,9 @@ async fn main() { async fn real_main() -> i32 { // We want to be working in the root of `.perseus/` std::env::set_current_dir("../").unwrap(); + let app = app::main::(); - let plugins = get_plugins::(); + let plugins = app.get_plugins(); // Building and exporting must be sequential, but that can be done in parallel with static directory/alias copying let exit_code = build_and_export().await; @@ -49,7 +47,8 @@ async fn real_main() -> i32 { } async fn build_and_export() -> i32 { - let plugins = get_plugins::(); + let app = app::main::(); + let plugins = app.get_plugins(); plugins .functional_actions @@ -57,13 +56,12 @@ async fn build_and_export() -> i32 { .before_build .run((), plugins.get_plugin_data()); - let immutable_store = get_immutable_store(&plugins); + let immutable_store = app.get_immutable_store(); // We don't need this in exporting, but the build process does - let mutable_store = get_mutable_store(); - let translations_manager = get_translations_manager().await; - let locales = get_locales(&plugins); + let mutable_store = app.get_mutable_store(); + let locales = app.get_locales(); // Generate the global state - let gsc = get_global_state_creator(); + let gsc = app.get_global_state_creator(); let global_state = match gsc.get_build_state().await { Ok(global_state) => global_state, Err(err) => { @@ -77,10 +75,13 @@ async fn build_and_export() -> i32 { return 1; } }; + let templates_map = app.get_templates_map(); + let index_view = app.get_index_view().await; + // This consumes `self`, so we get it finally + let translations_manager = app.get_translations_manager().await; // Build the site for all the common locales (done in parallel), denying any non-exportable features // We need to build and generate those artifacts before we can proceed on to exporting - let templates_map = get_templates_map::(&plugins); let build_res = build_app(BuildProps { templates: &templates_map, locales: &locales, @@ -107,12 +108,10 @@ async fn build_and_export() -> i32 { .after_successful_build .run((), plugins.get_plugin_data()); // Turn the build artifacts into self-contained static files - let app_root = get_app_root(&plugins); let export_res = export_app(ExportProps { templates: &templates_map, - html_shell_path: "../index.html", + html_shell: index_view, locales: &locales, - root_id: &app_root, immutable_store: &immutable_store, translations_manager: &translations_manager, path_prefix: get_path_prefix_server(), @@ -134,12 +133,13 @@ async fn build_and_export() -> i32 { } fn copy_static_dir() -> i32 { - let plugins = get_plugins::(); + let app = app::main::(); + let plugins = app.get_plugins(); // Loop through any static aliases and copy them in too // Unlike with the server, these could override pages! // We'll copy from the alias to the path (it could be a directory or a file) // Remember: `alias` has a leading `/`! - for (alias, path) in get_static_aliases(&plugins) { + for (alias, path) in app.get_static_aliases() { let from = PathBuf::from(path); let to = format!("dist/exported{}", alias); @@ -180,7 +180,8 @@ fn copy_static_dir() -> i32 { } fn copy_static_aliases() -> i32 { - let plugins = get_plugins::(); + let app = app::main::(); + let plugins = app.get_plugins(); // Copy the `static` directory into the export package if it exists // If the user wants extra, they can use static aliases, plugins are unnecessary here let static_dir = PathBuf::from("../static"); diff --git a/examples/core/basic/.perseus/builder/src/bin/export_error_page.rs b/examples/core/basic/.perseus/builder/src/bin/export_error_page.rs index 9401d105fa..0484934309 100644 --- a/examples/core/basic/.perseus/builder/src/bin/export_error_page.rs +++ b/examples/core/basic/.perseus/builder/src/bin/export_error_page.rs @@ -1,12 +1,6 @@ use fmterr::fmt_err; -use perseus::{ - internal::{ - get_path_prefix_server, - serve::{build_error_page, get_render_cfg, HtmlShell}, - }, - PluginAction, SsrNode, -}; -use perseus_engine::app::{get_app_root, get_error_pages, get_immutable_store, get_plugins}; +use perseus::{internal::serve::build_error_page, PluginAction, SsrNode}; +use perseus_engine as app; use std::{env, fs}; #[tokio::main] @@ -18,28 +12,13 @@ async fn main() { async fn real_main() -> i32 { // We want to be working in the root of `.perseus/` env::set_current_dir("../").unwrap(); + let app = app::main::(); - let plugins = get_plugins::(); + let plugins = app.get_plugins(); - let error_pages = get_error_pages(&plugins); - let root_id = get_app_root(&plugins); - let immutable_store = get_immutable_store(&plugins); - let render_cfg = match get_render_cfg(&immutable_store).await { - Ok(render_cfg) => render_cfg, - Err(err) => { - eprintln!("{}", fmt_err(&err)); - return 1; - } - }; + let error_pages = app.get_error_pages(); // Prepare the HTML shell - let html = match fs::read_to_string("../index.html") { - Ok(html) => html, - Err(err) => { - eprintln!("{}", fmt_err(&err)); - return 1; - } - }; - let html_shell = HtmlShell::new(html, &root_id, &render_cfg, &get_path_prefix_server()); + let html_shell = app.get_index_view().await; // Get the error code to build from the arguments to this executable let args = env::args().collect::>(); let err_code_to_build_for = match args.get(1) { diff --git a/examples/core/basic/.perseus/builder/src/bin/tinker.rs b/examples/core/basic/.perseus/builder/src/bin/tinker.rs index c51a77ca46..ff981e2f85 100644 --- a/examples/core/basic/.perseus/builder/src/bin/tinker.rs +++ b/examples/core/basic/.perseus/builder/src/bin/tinker.rs @@ -1,5 +1,5 @@ use perseus::{plugins::PluginAction, SsrNode}; -use perseus_engine::app::get_plugins; +use perseus_engine as app; fn main() { let exit_code = real_main(); @@ -10,7 +10,7 @@ fn real_main() -> i32 { // We want to be working in the root of `.perseus/` std::env::set_current_dir("../").unwrap(); - let plugins = get_plugins::(); + let plugins = app::main::().get_plugins(); // Run all the tinker actions // Note: this is deliberately synchronous, tinker actions that need a multithreaded async runtime should probably // be making their own engines! diff --git a/examples/core/basic/.perseus/server/src/main.rs b/examples/core/basic/.perseus/server/src/main.rs index bbabbca1ee..9f20d5b641 100644 --- a/examples/core/basic/.perseus/server/src/main.rs +++ b/examples/core/basic/.perseus/server/src/main.rs @@ -4,11 +4,7 @@ use perseus::internal::serve::{ServerOptions, ServerProps}; use perseus::plugins::PluginAction; use perseus::stores::MutableStore; use perseus::SsrNode; -use perseus_engine::app::{ - get_app_root, get_error_pages, get_global_state_creator, get_immutable_store, get_locales, - get_mutable_store, get_plugins, get_static_aliases, get_templates_map_atomic, - get_translations_manager, -}; +use perseus_engine as app; use std::env; use std::fs; @@ -88,7 +84,8 @@ fn get_host_and_port() -> (String, u16) { /// Gets the properties to pass to the server. fn get_props(is_standalone: bool) -> ServerProps { - let plugins = get_plugins::(); + let app = app::main::(); + let plugins = app.get_plugins(); plugins .functional_actions @@ -97,24 +94,25 @@ fn get_props(is_standalone: bool) -> ServerProps ServerProps impl MutableStore { -// todo!() -// } -pub fn get_immutable_store(plugins: &Plugins) -> ImmutableStore { - let immutable_store = app::get_immutable_store(); - plugins - .control_actions - .settings_actions - .set_immutable_store - .run(immutable_store.clone(), plugins.get_plugin_data()) - .unwrap_or(immutable_store) -} -pub fn get_app_root(plugins: &Plugins) -> String { - plugins - .control_actions - .settings_actions - .set_app_root - .run((), plugins.get_plugin_data()) - .unwrap_or_else(|| app::APP_ROOT.to_string()) -} -// pub async fn get_translations_manager() -> impl TranslationsManager { -// todo!() -// } -pub fn get_locales(plugins: &Plugins) -> Locales { - let locales = app::get_locales(); - plugins - .control_actions - .settings_actions - .set_locales - .run(locales.clone(), plugins.get_plugin_data()) - .unwrap_or(locales) -} -// This also performs rescoping and security checks so that we don't include anything outside the project root -pub fn get_static_aliases(plugins: &Plugins) -> HashMap { - let mut static_aliases = app::get_static_aliases(); - // This will return a map of plugin name to another map of static aliases that that plugin produced - let extra_static_aliases = plugins - .functional_actions - .settings_actions - .add_static_aliases - .run((), plugins.get_plugin_data()); - for (_plugin_name, aliases) in extra_static_aliases { - let new_aliases: HashMap = aliases - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - static_aliases.extend(new_aliases); - } - - let mut scoped_static_aliases = HashMap::new(); - for (url, path) in static_aliases { - // We need to move this from being scoped to the app to being scoped for `.perseus/` - // TODO make sure this works properly on Windows - let new_path = if path.starts_with('/') { - // Absolute paths are a security risk and are disallowed - panic!( - "it's a security risk to include absolute paths in `static_aliases` ('{}')", - path - ); - } else if path.starts_with("../") { - // Anything outside this directory is a security risk as well - panic!("it's a security risk to include paths outside the current directory in `static_aliases` ('{}')", path); - } else if path.starts_with("./") { - // `./` -> `../` (moving to execution from `.perseus/`) - // But if we're operating standalone, it stays the same - if cfg!(feature = "standalone") { - path.to_string() - } else { - format!(".{}", path) - } - } else { - // Anything else gets a `../` prepended - // But if we're operating standalone, it stays the same - if cfg!(feature = "standalone") { - path.to_string() - } else { - format!("../{}", path) - } - }; - - scoped_static_aliases.insert(url, new_path); - } - - scoped_static_aliases -} -// This doesn't take plugins because that would actually increase allocation and indirection on the server -pub fn get_templates_map(plugins: &Plugins) -> TemplateMap { - let mut templates = app::get_templates_map::(); - // This will return a map of plugin name to a vector of templates to add - let extra_templates = plugins - .functional_actions - .settings_actions - .add_templates - .run((), plugins.get_plugin_data()); - for (_plugin_name, plugin_templates) in extra_templates { - // Turn that vector into a template map by extracting the template root paths as keys - for template in plugin_templates { - templates.insert(template.get_path(), Rc::new(template)); - } - } - - templates -} -pub fn get_templates_map_atomic(plugins: &Plugins) -> ArcTemplateMap { - let mut templates = app::get_templates_map_atomic::(); - // This will return a map of plugin name to a vector of templates to add - let extra_templates = plugins - .functional_actions - .settings_actions - .add_templates - .run((), plugins.get_plugin_data()); - for (_plugin_name, plugin_templates) in extra_templates { - // Turn that vector into a template map by extracting the template root paths as keys - for template in plugin_templates { - templates.insert(template.get_path(), Arc::new(template)); - } - } - - templates -} -pub fn get_error_pages(plugins: &Plugins) -> ErrorPages { - let mut error_pages = app::get_error_pages::(); - // This will return a map of plugin name to a map of status codes to error pages - let extra_error_pages = plugins - .functional_actions - .settings_actions - .add_error_pages - .run((), plugins.get_plugin_data()); - for (_plugin_name, plugin_error_pages) in extra_error_pages { - for (status, error_page) in plugin_error_pages { - error_pages.add_page_rc(status, error_page); - } - } - - error_pages -} - -// We provide alternatives for `get_templates_map` and `get_error_pages` that get their own plugins -// This avoids major allocation/sync problems on the server -pub fn get_templates_map_contained() -> TemplateMap { - let plugins = get_plugins::(); - get_templates_map(&plugins) -} -pub fn get_templates_map_atomic_contained() -> ArcTemplateMap { - let plugins = get_plugins::(); - get_templates_map_atomic(&plugins) -} -pub fn get_error_pages_contained() -> ErrorPages { - let plugins = get_plugins::(); - get_error_pages(&plugins) -} diff --git a/examples/core/basic/.perseus/src/lib.rs b/examples/core/basic/.perseus/src/lib.rs index 6c54820feb..93e4ee8399 100644 --- a/examples/core/basic/.perseus/src/lib.rs +++ b/examples/core/basic/.perseus/src/lib.rs @@ -1,8 +1,8 @@ #![allow(clippy::unused_unit)] // rustwasm/wasm-bindgen#2774 awaiting next `wasm-bindgen` release -pub mod app; +// The user should use the `main` macro to create this wrapper +pub use app::__perseus_main as main; -use crate::app::{get_app_root, get_error_pages, get_locales, get_plugins, get_templates_map}; use perseus::{ checkpoint, create_app_route, internal::{ @@ -11,7 +11,6 @@ use perseus::{ }, plugins::PluginAction, templates::TemplateNodeType, - DomNode, }; use sycamore::prelude::view; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; @@ -19,7 +18,8 @@ use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; /// The entrypoint into the app itself. This will be compiled to Wasm and actually executed, rendering the rest of the app. #[wasm_bindgen] pub fn run() -> Result<(), JsValue> { - let plugins = get_plugins::(); + let app = main(); + let plugins = app.get_plugins(); checkpoint("begin"); // Panics should always go to the console @@ -37,7 +37,7 @@ pub fn run() -> Result<(), JsValue> { .unwrap() .document() .unwrap() - .query_selector(&format!("#{}", get_app_root(&plugins))) + .query_selector(&format!("#{}", app.get_root())) .unwrap() .unwrap(); @@ -48,16 +48,16 @@ pub fn run() -> Result<(), JsValue> { render_cfg => &get_render_cfg().expect("render configuration invalid or not injected"), // TODO avoid unnecessary allocation here (major problem!) // The `G` parameter is ambient here for `RouteVerdict` - templates => &get_templates_map::(&get_plugins()), - locales => &get_locales::(&get_plugins()) + templates => &main::().get_templates_map(), + locales => &main::().get_locales() } // Create a new version of the router with that type PerseusRouterWithAppRoute = PerseusRouter>; // Set up the properties we'll pass to the router let router_props = PerseusRouterProps { - locales: get_locales(&plugins), - error_pages: get_error_pages(&plugins), + locales: app.get_locales(), + error_pages: app.get_error_pages(), }; sycamore::render_to( diff --git a/examples/core/basic/src/lib.rs b/examples/core/basic/src/lib.rs index fa43fbfcad..ee3e6e1a79 100644 --- a/examples/core/basic/src/lib.rs +++ b/examples/core/basic/src/lib.rs @@ -1,12 +1,12 @@ mod error_pages; mod templates; -use perseus::define_app; +use perseus::{Html, PerseusApp}; -define_app! { - templates: [ - crate::templates::index::get_template::(), - crate::templates::about::get_template::() - ], - error_pages: crate::error_pages::get_error_pages() +#[perseus::main] +pub fn main() -> PerseusApp { + PerseusApp::new() + .template(crate::templates::index::get_template) + .template(crate::templates::about::get_template) + .error_pages(crate::error_pages::get_error_pages) } diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index eeba6e9eea..90ada52023 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -5,13 +5,11 @@ use actix_files::{Files, NamedFile}; use actix_web::{web, HttpRequest}; use perseus::{ internal::{ - get_path_prefix_server, i18n::TranslationsManager, - serve::{get_render_cfg, HtmlShell, ServerOptions, ServerProps}, + serve::{get_render_cfg, ServerOptions, ServerProps}, }, stores::MutableStore, }; -use std::fs; use std::rc::Rc; async fn js_bundle(opts: web::Data>) -> std::io::Result { @@ -51,15 +49,7 @@ pub async fn configurer( req: HttpRequest, opts: web::Data>, - html_shell: web::Data>, + html_shell: web::Data, render_cfg: web::Data>, immutable_store: web::Data, mutable_store: web::Data, diff --git a/packages/perseus-macro/src/entrypoint.rs b/packages/perseus-macro/src/entrypoint.rs new file mode 100644 index 0000000000..3a651118bd --- /dev/null +++ b/packages/perseus-macro/src/entrypoint.rs @@ -0,0 +1,105 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{Attribute, Block, Generics, Item, ItemFn, Result, ReturnType, Type}; + +/// A function that can be made into a Perseus app's entrypoint. +/// +/// The signature of this function is extremely restrictive. It takes one generic for the `Html` backend to target, and produces an instance of `PerseusApp`. +pub struct MainFn { + /// The body of the function. + pub block: Box, + /// Any attributes the function uses. + pub attrs: Vec, + /// The return type of the function. + pub return_type: Box, + /// Any generics the function takes (shouldn't be any, but it could in theory). + pub generics: Generics, +} +impl Parse for MainFn { + fn parse(input: ParseStream) -> Result { + let parsed: Item = input.parse()?; + + match parsed { + Item::Fn(func) => { + let ItemFn { + attrs, sig, block, .. + } = func; + // Validate each part of this function to make sure it fulfills the requirements + // Must not be async + if sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + sig.asyncness, + "the entrypoint can't be async", + )); + } + // Can't be const + if sig.constness.is_some() { + return Err(syn::Error::new_spanned( + sig.constness, + "the entrypoint can't be a const function", + )); + } + // Can't be external + if sig.abi.is_some() { + return Err(syn::Error::new_spanned( + sig.abi, + "the entrypoint can't be an external function", + )); + } + // Must return something (type checked by the existence of the wrapper code) + let return_type = match sig.output { + ReturnType::Default => { + return Err(syn::Error::new_spanned( + sig, + "the entrypoint must return an instance of `PerseusAppBase` or one of its aliases (e.g. `PerseusApp`)", + )) + } + ReturnType::Type(_, ty) => ty, + }; + // Must accept no arguments + let inputs = sig.inputs; + if !inputs.is_empty() { + return Err(syn::Error::new_spanned( + inputs, + "the entrypoint can't take any arguments", + )); + } + + Ok(Self { + block, + attrs, + return_type, + generics: sig.generics, + }) + } + item => Err(syn::Error::new_spanned( + item, + "only funtions can be used as tests", + )), + } + } +} + +pub fn main_impl(input: MainFn) -> TokenStream { + let MainFn { + block, + generics, + attrs, + return_type, + } = input; + + // We wrap the user's function to noramlize the name for the engine + let output = quote! { + pub fn __perseus_main() -> #return_type { + // The user's function + #(#attrs)* + fn fn_internal#generics() -> #return_type { + #block + } + fn_internal() + } + }; + + output +} diff --git a/packages/perseus-macro/src/lib.rs b/packages/perseus-macro/src/lib.rs index fa14961502..f55aeb8d1c 100644 --- a/packages/perseus-macro/src/lib.rs +++ b/packages/perseus-macro/src/lib.rs @@ -12,6 +12,7 @@ documentation, and this should mostly be used as a secondary reference source. Y */ mod autoserde; +mod entrypoint; mod head; mod rx_state; mod template; @@ -102,6 +103,16 @@ pub fn test(args: TokenStream, input: TokenStream) -> TokenStream { test::test_impl(parsed, args).into() } +/// Marks the given function as the entrypoint into your app. You should only use this once in the `lib.rs` file of your project. +/// +/// Internally, this just normalizes the function's name so that Perseus can find it easily. +#[proc_macro_attribute] +pub fn main(_args: TokenStream, input: TokenStream) -> TokenStream { + let parsed = syn::parse_macro_input!(input as entrypoint::MainFn); + + entrypoint::main_impl(parsed).into() +} + /// Processes the given `struct` to create a reactive version by wrapping each field in a `Signal`. This will generate a new `struct` with the given name and implement a `.make_rx()` /// method on the original that allows turning an instance of the unreactive `struct` into an instance of the reactive one. /// diff --git a/packages/perseus-warp/src/initial_load.rs b/packages/perseus-warp/src/initial_load.rs index 73ab93b67f..de4cd6d66b 100644 --- a/packages/perseus-warp/src/initial_load.rs +++ b/packages/perseus-warp/src/initial_load.rs @@ -37,7 +37,7 @@ pub async fn initial_load_handler( path: FullPath, req: perseus::http::Request<()>, opts: Arc, - html_shell: Arc>, + html_shell: Arc, render_cfg: Arc>, immutable_store: Arc, mutable_store: Arc, diff --git a/packages/perseus-warp/src/perseus_routes.rs b/packages/perseus-warp/src/perseus_routes.rs index 0792cecf65..1fc23b1ac1 100644 --- a/packages/perseus-warp/src/perseus_routes.rs +++ b/packages/perseus-warp/src/perseus_routes.rs @@ -7,11 +7,8 @@ use crate::{ translations::translations_handler, }; use perseus::internal::serve::{get_render_cfg, ServerProps}; -use perseus::{ - internal::{get_path_prefix_server, i18n::TranslationsManager, serve::HtmlShell}, - stores::MutableStore, -}; -use std::{fs, sync::Arc}; +use perseus::{internal::i18n::TranslationsManager, stores::MutableStore}; +use std::sync::Arc; use warp::Filter; /// The routes for Perseus. These will configure an existing Warp instance to run Perseus, and should be provided after any other routes, as they include a wildcard @@ -28,13 +25,7 @@ pub async fn perseus_routes { }) } } +// We provide default error pages to speed up development, but they have to be added before moving to production (or we'll `panic!`) +impl Default for ErrorPages { + #[cfg(debug_assertions)] + fn default() -> Self { + let mut error_pages = Self::new(|url, status, err, _| { + view! { + p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } + } + }); + // 404 is the most common by far, so we add a little page for that too + error_pages.add_page(404, |_, _, _, _| { + view! { + p { "Page not found." } + } + }); + + error_pages + } + #[cfg(not(debug_assertions))] + fn default() -> Self { + panic!("you must provide your own error pages in production") + } +} /// A representation of an error page, particularly for storage in transit so that server-side rendered error pages can be hydrated on /// the client-side. diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index ce497ead3c..a1bedaeeaa 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -5,7 +5,6 @@ use crate::stores::ImmutableStore; use crate::template::TemplateMap; use crate::SsrNode; use futures::future::{try_join, try_join_all}; -use std::fs; /// Gets the static page data. pub async fn get_static_page_data( @@ -41,12 +40,10 @@ pub async fn get_static_page_data( pub struct ExportProps<'a, T: TranslationsManager> { /// All the templates in the app. pub templates: &'a TemplateMap, - /// The path to the HTML shell to use. - pub html_shell_path: &'a str, + /// The HTML shell to use. + pub html_shell: HtmlShell, /// The locales data for the app. pub locales: &'a Locales, - /// The HTML ID of the `
` to inject into. - pub root_id: &'a str, /// An immutable store. pub immutable_store: &'a ImmutableStore, /// A translations manager. @@ -63,9 +60,8 @@ pub struct ExportProps<'a, T: TranslationsManager> { pub async fn export_app( ExportProps { templates, - html_shell_path, + html_shell, locales, - root_id, immutable_store, translations_manager, path_prefix, @@ -74,13 +70,6 @@ pub async fn export_app( ) -> Result<(), ServerError> { // The render configuration acts as a guide here, it tells us exactly what we need to iterate over (no request-side pages!) let render_cfg = get_render_cfg(immutable_store).await?; - // Get the HTML shell and prepare it by interpolating necessary values - let raw_html_shell = - fs::read_to_string(html_shell_path).map_err(|err| BuildError::HtmlShellNotFound { - path: html_shell_path.to_string(), - source: err, - })?; - let html_shell = HtmlShell::new(raw_html_shell, root_id, &render_cfg, &path_prefix); // We can do literally everything concurrently here let mut export_futs = Vec::new(); @@ -139,7 +128,7 @@ pub async fn export_path( (path, template_path): (String, String), templates: &TemplateMap, locales: &Locales, - html_shell: &HtmlShell<'_>, + html_shell: &HtmlShell, immutable_store: &ImmutableStore, path_prefix: String, global_state: &Option, diff --git a/packages/perseus/src/i18n/mod.rs b/packages/perseus/src/i18n/mod.rs index 36e905ca1a..435a2eb6cc 100644 --- a/packages/perseus/src/i18n/mod.rs +++ b/packages/perseus/src/i18n/mod.rs @@ -7,7 +7,7 @@ pub use client_translations_manager::ClientTranslationsManager; pub use locale_detector::detect_locale; pub use locales::Locales; pub use translations_manager::{ - DummyTranslationsManager, FsTranslationsManager, TranslationsManager, TranslationsManagerError, + FsTranslationsManager, TranslationsManager, TranslationsManagerError, }; // No explicitly internal things here diff --git a/packages/perseus/src/i18n/translations_manager.rs b/packages/perseus/src/i18n/translations_manager.rs index ace8ee4aef..62a5827b6e 100644 --- a/packages/perseus/src/i18n/translations_manager.rs +++ b/packages/perseus/src/i18n/translations_manager.rs @@ -34,16 +34,22 @@ use tokio::io::AsyncReadExt; /// be stored in a CMS. It is **strongly** advised that any implementations use some form of caching, guided by `FsTranslationsManager`. #[async_trait::async_trait] pub trait TranslationsManager: std::fmt::Debug + Clone + Send + Sync { - /// Gets a translator for the given locale. + /// Gets a translator for the given locale. If i18n is disabled, this should return an empty string. async fn get_translator_for_locale( &self, locale: String, ) -> Result; - /// Gets the translations in string format for the given locale (avoids deserialize-then-serialize). + /// Gets the translations in string format for the given locale (avoids deserialize-then-serialize). If i18n is disabled, this should return a translator for the given locale + /// with no translation string. async fn get_translations_str_for_locale( &self, locale: String, ) -> Result; + /// Creates a new instance of this translations manager, as a dummy for apps that aren't using i18n at all. This may seem pointless, but it's needed for trait completeness and to support + /// certain engine middleware use-cases. In general, this should simply create an empty instance of the manager, and all other functions should do nothing if it is empty. + /// + /// Notably, this must be synchronous. + fn new_dummy() -> Self; } /// A utility function for allowing parallel futures execution. This returns a tuple of the locale and the translations as a JSON string. @@ -67,6 +73,8 @@ async fn get_translations_str_and_cache( /// The default translations manager. This will store static files in the specified location on disk. This should be suitable for /// nearly all development and serverful use-cases. Serverless is another matter though (more development needs to be done). This /// mandates that translations be stored as files named as the locale they describe (e.g. 'en-US.ftl', 'en-US.json', etc.). +/// +/// As this is used as the default translations manager by most apps, this also supports not using i18n at all. #[derive(Clone, Debug)] pub struct FsTranslationsManager { root_path: String, @@ -77,6 +85,8 @@ pub struct FsTranslationsManager { cached_locales: Vec, /// The file extension expected (e.g. JSON, FTL, etc). This allows for greater flexibility of translation engines (future). file_ext: String, + /// This will be `true` is this translations manager is being used for an app that's not using i18n. + is_dummy: bool, } impl FsTranslationsManager { /// Creates a new filesystem translations manager. You should provide a path like `/translations` here. You should also provide @@ -91,6 +101,7 @@ impl FsTranslationsManager { cached_translations: HashMap::new(), cached_locales: Vec::new(), file_ext, + is_dummy: false, }; // Now use that to get the translations for the locales we want to cache (all done in parallel) let mut futs = Vec::new(); @@ -107,10 +118,24 @@ impl FsTranslationsManager { } #[async_trait::async_trait] impl TranslationsManager for FsTranslationsManager { + fn new_dummy() -> Self { + Self { + root_path: String::new(), + cached_translations: HashMap::new(), + cached_locales: Vec::new(), + file_ext: String::new(), + is_dummy: true, + } + } async fn get_translations_str_for_locale( &self, locale: String, ) -> Result { + // If this is a dummy translations manager, we'll just return an empty string + if self.is_dummy { + return Ok(String::new()); + } + // Check if the locale is cached for // No dynamic caching, so if it isn't cached it stays that way if self.cached_locales.contains(&locale) { @@ -151,6 +176,17 @@ impl TranslationsManager for FsTranslationsManager { &self, locale: String, ) -> Result { + // If this is a dummy translations manager, we'll return a dysfunctional translator (obviously, do NOT use this if you want i18n!) + if self.is_dummy { + let translator = Translator::new(locale.clone(), String::new()).map_err(|err| { + TranslationsManagerError::SerializationFailed { + locale, + source: err.into(), + } + })?; + return Ok(translator); + } + // Check if the locale is cached for // No dynamic caching, so if it isn't cached it stays that way let translations_str; @@ -171,36 +207,36 @@ impl TranslationsManager for FsTranslationsManager { } } -/// A dummy translations manager for use if you don't want i18n. This avoids errors of not being able to find translations. If you set -/// `no_i18n: true` in the `locales` section of `define_app!`, this will be used by default. If you intend to use i18n, do not use this! -/// Using the `link!` macro with this will NOT prepend the path prefix, and it will result in a nonsensical URL that won't work. -#[derive(Clone, Default, Debug)] -pub struct DummyTranslationsManager; -impl DummyTranslationsManager { - /// Creates a new dummy translations manager. - pub fn new() -> Self { - Self::default() - } -} -#[async_trait::async_trait] -impl TranslationsManager for DummyTranslationsManager { - async fn get_translations_str_for_locale( - &self, - _locale: String, - ) -> Result { - Ok(String::new()) - } - async fn get_translator_for_locale( - &self, - locale: String, - ) -> Result { - let translator = Translator::new(locale.clone(), String::new()).map_err(|err| { - TranslationsManagerError::SerializationFailed { - locale, - source: err.into(), - } - })?; +// /// A dummy translations manager for use if you don't want i18n. This avoids errors of not being able to find translations. If you set +// /// `no_i18n: true` in the `locales` section of `define_app!`, this will be used by default. If you intend to use i18n, do not use this! +// /// Using the `link!` macro with this will NOT prepend the path prefix, and it will result in a nonsensical URL that won't work. +// #[derive(Clone, Default, Debug)] +// pub struct DummyTranslationsManager; +// impl DummyTranslationsManager { +// /// Creates a new dummy translations manager. +// pub fn new() -> Self { +// Self::default() +// } +// } +// #[async_trait::async_trait] +// impl TranslationsManager for DummyTranslationsManager { +// async fn get_translations_str_for_locale( +// &self, +// _locale: String, +// ) -> Result { +// Ok(String::new()) +// } +// async fn get_translator_for_locale( +// &self, +// locale: String, +// ) -> Result { +// let translator = Translator::new(locale.clone(), String::new()).map_err(|err| { +// TranslationsManagerError::SerializationFailed { +// locale, +// source: err.into(), +// } +// })?; - Ok(translator) - } -} +// Ok(translator) +// } +// } diff --git a/packages/perseus/src/init.rs b/packages/perseus/src/init.rs new file mode 100644 index 0000000000..0e2078e364 --- /dev/null +++ b/packages/perseus/src/init.rs @@ -0,0 +1,611 @@ +use crate::plugins::PluginAction; +use crate::server::{get_render_cfg, HtmlShell}; +use crate::utils::get_path_prefix_server; +use crate::{ + i18n::{FsTranslationsManager, Locales, TranslationsManager}, + state::GlobalStateCreator, + stores::{FsMutableStore, ImmutableStore, MutableStore}, + templates::TemplateMap, + ErrorPages, Html, Plugins, Template, +}; +use futures::Future; +use std::pin::Pin; +use std::{collections::HashMap, rc::Rc}; +use sycamore::{ + prelude::{component, view}, + view::View, + SsrNode, +}; + +/// The default index view, because some simple apps won't need anything fancy here. The user should be able to provide the smallest possible amount of information for their app to work. +/// Note that this doesn't declare a character set or anything else (for maximal cross-language compatibility) +static DFLT_INDEX_VIEW: &str = r#" + + + + + +
+ +"#; + +// This is broken out for debug implementation ease +struct TemplateGetters(Vec Template>>); +impl std::fmt::Debug for TemplateGetters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TemplateGetters").finish() + } +} +// This is broken out for debug implementation ease +struct ErrorPagesGetter(Box ErrorPages>); +impl std::fmt::Debug for ErrorPagesGetter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ErrorPagesGetters").finish() + } +} + +/// The different types of translations managers that can be stored. This allows us to store dummy translations managers directly, without holding futures. If this stores a full +/// translations manager though, it will store it as a `Future`, which is later evaluated. +enum Tm { + Dummy(T), + Full(Pin>>), +} +impl std::fmt::Debug for Tm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tm").finish() + } +} + +/// An automatically implemented trait for asynchronous functions that return instances of `TranslationsManager`. This is needed so we can store the 'promise' of getting a translations +/// manager in future by executing a stored asynchronous function (because we don't want to take in the actual value, which would require asynchronous initialization functions, which we +/// can't have in environments like the browser). +#[doc(hidden)] +pub trait TranslationsManagerGetter { + type Output: TranslationsManager; + fn call(&self) -> Box>; +} +impl TranslationsManagerGetter for F +where + T: TranslationsManager, + F: Fn() -> Fut, + Fut: Future + 'static, +{ + type Output = T; + fn call(&self) -> Box> { + Box::new(self()) + } +} + +/// The options for constructing a Perseus app. These encompass all the information Perseus needs to know to create your app. Every Perseus app using the engine must export one of these. +/// +/// Note that this is an interim storage point, it's not depended on by any part of the core logic, and therefore custom engines can entirely ignore this. +#[derive(Debug)] +pub struct PerseusAppBase { + /// The HTML ID of the root `
` element into which Perseus will be injected. + root: String, + /// A list of function that produce templates for the app to use. These are stored as functions so that they can be called an arbitrary number of times. + // From this, we can construct the necessary kind of template map (we can call the user-given functions an arbitrary number of times) + template_getters: TemplateGetters, + /// The app's error pages. + error_pages: ErrorPagesGetter, + /// The global state creator for the app. + global_state_creator: GlobalStateCreator, + /// The internationalization information for the app. + locales: Locales, + /// The static aliases the app serves. + static_aliases: HashMap, + /// The plugins the app uses. + plugins: Rc>, + /// The app's immutable store. + immutable_store: ImmutableStore, + /// The HTML template that'll be used to render the app into. This must be static, but can be generated or sourced in any way. Note that this MUST + /// contain a `
` with the `id` set to whatever the value of `self.root` is. + index_view: String, + /// The app's mutable store. + mutable_store: M, + /// The app's translations manager, expressed as a function yielding a `Future`. This is only ever needed on the server-side, and can't be set up properly on the client-side because + /// we can't use futures in the app initialization in Wasm. + translations_manager: Tm, +} + +// The usual implementation in which the default mutable store is used +// We don't need to have a similar one for the default translations manager because things are completely generic there +impl PerseusAppBase { + /// Creates a new instance of a Perseus app using the default filesystem-based mutable store. For most apps, this will be sufficient. Note that this initializes the translations manager + /// as a dummy, and adds no templates or error pages. + /// + /// In development, you can get away with defining no error pages, but production apps (e.g. those created with `perseus deploy`) MUST set their own custom error pages. + /// + /// This is asynchronous because it creates a translations manager in the background. + // It makes no sense to implement `Default` on this, so we silence Clippy deliberately + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self::new_with_mutable_store(FsMutableStore::new("./dist/mutable".to_string())) + } +} +// If one's using the default translations manager, caching should be handled automatically for them +impl PerseusAppBase { + /// The same as `.locales_and_translations_manager()`, but this accepts a literal `Locales` `struct`, which means this can be used when you're using `FsTranslationsManager` but when you don't + /// know if your app is using i18n or not (almost always middleware). + pub fn locales_lit_and_translations_manager(mut self, locales: Locales) -> Self { + let using_i18n = locales.using_i18n; + self.locales = locales; + // If we're using i18n, do caching stuff + // If not, use a dummy translations manager + if using_i18n { + // By default, all translations are cached + let all_locales: Vec = self + .locales + .get_all() + .iter() + // We have a `&&String` at this point, hence the double clone + .cloned() + .cloned() + .collect(); + let tm_fut = FsTranslationsManager::new( + crate::internal::i18n::DFLT_TRANSLATIONS_DIR.to_string(), + all_locales, + crate::internal::i18n::TRANSLATOR_FILE_EXT.to_string(), + ); + self.translations_manager = Tm::Full(Box::pin(tm_fut)); + } else { + self.translations_manager = Tm::Dummy(FsTranslationsManager::new_dummy()); + } + + self + } + /// Sets the internationalization information for an app using the default translations manager (`FsTranslationsManager`). This handles locale caching and the like automatically for you, + /// though you could alternatively use `.locales()` and `.translations_manager()` independently to customize various behaviors. This takes the same arguments as `.locales()`, so + /// the first argument is the default locale (used as a fallback for users with no locale preferences set in their browsers), and the second is a list of other locales supported. + /// + /// If you're not using i18n, you don't need to call this function. If you for some reason do have to though (e.g. overriding some other preferences in middleware), use `.disable_i18n()`, + /// not this, as you're very likely to shoot yourself in the foot! (If i18n is disabled, the default locale MUST be set to `xx-XX`, for example.) + pub fn locales_and_translations_manager(self, default: &str, other: &[&str]) -> Self { + let locales = Locales { + default: default.to_string(), + other: other.iter().map(|s| s.to_string()).collect(), + using_i18n: true, + }; + + self.locales_lit_and_translations_manager(locales) + } +} +// The base implementation, generic over the mutable store and translations manager +impl PerseusAppBase { + /// Creates a new instance of a Perseus app, with the default options and a custom mutable store. + pub fn new_with_mutable_store(mutable_store: M) -> Self { + Self { + root: "root".to_string(), + // We do initialize with no templates, because an app without templates is in theory possible (and it's more convenient to call `.template()` for each one) + template_getters: TemplateGetters(Vec::new()), + // We do offer default error pages, but they'll panic if they're called for production building + error_pages: ErrorPagesGetter(Box::new(ErrorPages::default)), + global_state_creator: GlobalStateCreator::default(), + // By default, we'll disable i18n (as much as I may want more websites to support more languages...) + locales: Locales { + default: "xx-XX".to_string(), + other: Vec::new(), + using_i18n: false, + }, + // By default, we won't serve any static content outside the `static/` directory + static_aliases: HashMap::new(), + // By default, we won't use any plugins + plugins: Rc::new(Plugins::new()), + // This is relative to `.perseus/` + immutable_store: ImmutableStore::new("./dist".to_string()), + mutable_store, + translations_manager: Tm::Dummy(T::new_dummy()), + // Many users won't need anything fancy in the index view, so we provide a default + index_view: DFLT_INDEX_VIEW.to_string(), + } + } + + // Setters (these all consume `self`) + /// Sets the HTML ID of the `
` element at which to insert Perseus. + pub fn root(mut self, val: &str) -> Self { + self.root = val.to_string(); + self + } + /// Sets all the app's templates. This takes a vector of boxed functions that return templates. + pub fn templates(mut self, val: Vec Template>>) -> Self { + self.template_getters.0 = val; + self + } + /// Adds a single new template to the app (convenience function). This takes a function that returns a template. + pub fn template(mut self, val: impl Fn() -> Template + 'static) -> Self { + self.template_getters.0.push(Box::new(val)); + self + } + /// Sets the app's error pages. + pub fn error_pages(mut self, val: impl Fn() -> ErrorPages + 'static) -> Self { + self.error_pages = ErrorPagesGetter(Box::new(val)); + self + } + /// Sets the app's global state creator. + pub fn global_state_creator(mut self, val: GlobalStateCreator) -> Self { + self.global_state_creator = val; + self + } + /// Sets the locales information for the app. The first argument is the default locale (used as a fallback for users with no locale preferences set in their browsers), and + /// the second is a list of other locales supported. + /// + /// Note that this does not update the translations manager, which must be done separately (if you're using `FsTranslationsManager`, the default, you can use + /// `.locales_and_translations_manager()` to set both at once). + /// + /// If you're not using i18n, you don't need to call this function. If you for some reason do have to though (e.g. overriding some other preferences in middleware), use `.disable_i18n()`, + /// not this, as you're very likely to shoot yourself in the foot! (If i18n is disabled, the default locale MUST be set to `xx-XX`, for example.) + pub fn locales(mut self, default: &str, other: &[&str]) -> Self { + self.locales = Locales { + default: default.to_string(), + other: other.iter().map(|s| s.to_string()).collect(), + using_i18n: true, + }; + self + } + /// Sets the locales information directly based on an instance of `Locales`. Usually, end users will use `.locales()` instead for a friendlier interface. + pub fn locales_lit(mut self, val: Locales) -> Self { + self.locales = val; + self + } + /// Sets the translations manager. If you're using the default translations manager (`FsTranslationsManager`), you can use `.locales_and_translations_manager()` to set this automatically + /// based on the locales information. This takes a `Future`, where `T` is your translations manager's type. + /// + /// The reason that this takes a `Future` is to avoid the use of `.await` in your app definition code, which must be synchronous due to constraints of Perseus' client-side systems. + /// When your code is run on the server, the `Future` will be `.await`ed on, but on Wasm, it will be discarded and ignored, since the translations manager isn't needed in Wasm. + /// + /// This is generally intended for use with custom translations manager or specific use-cases with the default (mostly to do with custom caching behavior). + pub fn translations_manager(mut self, val: impl Future + 'static) -> Self { + self.translations_manager = Tm::Full(Box::pin(val)); + self + } + /// Explicitly disables internationalization. You shouldn't ever need to call this, as it's the default, but you may want to if you're writing middleware that doesn't support i18n. + pub fn disable_i18n(mut self) -> Self { + self.locales = Locales { + default: "xx-XX".to_string(), + other: Vec::new(), + using_i18n: false, + }; + // All translations manager must implement this function, which is designed for this exact purpose + self.translations_manager = Tm::Dummy(T::new_dummy()); + self + } + /// Sets all the app's static aliases. This takes a map of URLs (e.g. `/file`) to resource paths, relative to the project directory (e.g. `style.css`). + pub fn static_aliases(mut self, val: HashMap) -> Self { + self.static_aliases = val; + self + } + /// Adds a single static alias (convenience function). This takes a URL path (e.g. `/file`) followed by a path to a resource (which must be within the project directory, e.g. `style.css`). + pub fn static_alias(mut self, url: &str, resource: &str) -> Self { + // We don't elaborate the alias to an actual filesystem path until the getter + self.static_aliases + .insert(url.to_string(), resource.to_string()); + self + } + /// Sets the plugins that the app will use. + pub fn plugins(mut self, val: Plugins) -> Self { + self.plugins = Rc::new(val); + self + } + /// Sets the mutable store for the app to use, which you would change for some production server environments if you wanted to store build artifacts that can change at runtime in a + /// place other than on the filesystem (created for serverless functions specifically). + pub fn mutable_store(mut self, val: M) -> Self { + self.mutable_store = val; + self + } + /// Sets the immutable store for the app to use. You should almost never need to change this unless you're not working with the CLI. + pub fn immutable_store(mut self, val: ImmutableStore) -> Self { + self.immutable_store = val; + self + } + /// Sets the index view as a string. This should be used if you're using an `index.html` file or the like. + /// + /// Note: if possible, you should switch to using `.index_view()`, which uses a Sycamore view rather than an HTML string. + pub fn index_view_str(mut self, val: &str) -> Self { + self.index_view = val.to_string(); + self + } + /// Sets the index view using a Sycamore view, which avoids the need to write any HTML by hand whatsoever. Note that this must contain a `` and `` at a minimum. + /// + /// Warning: this view can't be reactive (yet). It will be rendered to a static string, which won't be hydrated. + // The lifetime of the provided function doesn't need to be static, because we render using it and then we're done with it + pub fn index_view<'a>(mut self, f: impl Fn() -> View + 'a) -> Self { + let html_str = sycamore::render_to_string(f); + self.index_view = html_str; + self + } + // Setters + /// Gets the HTML ID of the `
` at which to insert Perseus. + pub fn get_root(&self) -> String { + self.plugins + .control_actions + .settings_actions + .set_app_root + .run((), self.plugins.get_plugin_data()) + .unwrap_or_else(|| self.root.to_string()) + } + /// Gets the index view. This is asynchronous because it constructs an HTML shell, which invovles fetching the configuration of pages. + /// + /// Note that this automatically adds `` to the start of the HTMl shell produced, which can only be overriden with a control plugin (though you should really never do this + /// in Perseus, which targets HTML on the web). + #[cfg(feature = "server-side")] + pub async fn get_index_view(&self) -> HtmlShell { + // TODO Allow plugin modification of this + // We have to add an HTML document type declaration, otherwise the browser could think it's literally anything! (This shouldn't be a problem, but it could be in 100 years...) + let index_view_str = format!("\n{}", self.index_view); + // Construct an HTML shell + let mut html_shell = HtmlShell::new( + index_view_str, + &self.get_root(), + // TODO Handle this properly (good enough for now because that's what we weere already doing) + &get_render_cfg(&self.get_immutable_store()) + .await + .expect("Couldn't get render configuration!"), + &get_path_prefix_server(), + ); + + // Apply the myriad plugin actions to the HTML shell (replacing the whole thing first if need be) + let shell_str = self + .plugins + .control_actions + .settings_actions + .html_shell_actions + .set_shell + .run((), self.plugins.get_plugin_data()) + .unwrap_or(html_shell.shell); + html_shell.shell = shell_str; + // For convenience, we alias the HTML shell functional actions + let hsf_actions = &self + .plugins + .functional_actions + .settings_actions + .html_shell_actions; + + // These all return `Vec`, so the code is almost identical for all the places for flexible interpolation + html_shell.head_before_boundary.push( + hsf_actions + .add_to_head_before_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flatten() + .cloned() + .collect(), + ); + html_shell.scripts_before_boundary.push( + hsf_actions + .add_to_scripts_before_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flatten() + .cloned() + .collect(), + ); + html_shell.head_after_boundary.push( + hsf_actions + .add_to_head_after_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flatten() + .cloned() + .collect(), + ); + html_shell.scripts_after_boundary.push( + hsf_actions + .add_to_scripts_after_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flatten() + .cloned() + .collect(), + ); + html_shell.before_content.push( + hsf_actions + .add_to_before_content + .run((), self.plugins.get_plugin_data()) + .values() + .flatten() + .cloned() + .collect(), + ); + html_shell.after_content.push( + hsf_actions + .add_to_after_content + .run((), self.plugins.get_plugin_data()) + .values() + .flatten() + .cloned() + .collect(), + ); + + html_shell + } + /// Gets the templates in an `Rc`-based `HashMap` for non-concurrent access. + pub fn get_templates_map(&self) -> TemplateMap { + let mut map = HashMap::new(); + + // Now add the templates the user provided + for template_getter in self.template_getters.0.iter() { + let template = template_getter(); + map.insert(template.get_path(), Rc::new(template)); + } + + // This will return a map of plugin name to a vector of templates to add + let extra_templates = self + .plugins + .functional_actions + .settings_actions + .add_templates + .run((), self.plugins.get_plugin_data()); + for (_plugin_name, plugin_templates) in extra_templates { + // Turn that vector into a template map by extracting the template root paths as keys + for template in plugin_templates { + map.insert(template.get_path(), Rc::new(template)); + } + } + + map + } + /// Gets the templates in an `Arc`-based `HashMap` for concurrent access. This should only be relevant on the server-side. + #[cfg(feature = "server-side")] + pub fn get_atomic_templates_map(&self) -> crate::templates::ArcTemplateMap { + let mut map = HashMap::new(); + + // Now add the templates the user provided + for template_getter in self.template_getters.0.iter() { + let template = template_getter(); + map.insert(template.get_path(), std::sync::Arc::new(template)); + } + + // This will return a map of plugin name to a vector of templates to add + let extra_templates = self + .plugins + .functional_actions + .settings_actions + .add_templates + .run((), self.plugins.get_plugin_data()); + for (_plugin_name, plugin_templates) in extra_templates { + // Turn that vector into a template map by extracting the template root paths as keys + for template in plugin_templates { + map.insert(template.get_path(), std::sync::Arc::new(template)); + } + } + + map + } + /// Gets the error pages used in the app. This returns an `Rc`. + pub fn get_error_pages(&self) -> ErrorPages { + let mut error_pages = (self.error_pages.0)(); + let extra_error_pages = self + .plugins + .functional_actions + .settings_actions + .add_error_pages + .run((), self.plugins.get_plugin_data()); + for (_plugin_name, plugin_error_pages) in extra_error_pages { + for (status, error_page) in plugin_error_pages { + error_pages.add_page_rc(status, error_page); + } + } + + error_pages + } + /// Gets the global state creator. This can't be directly modified by plugins because of reactive type complexities. + pub fn get_global_state_creator(&self) -> GlobalStateCreator { + self.global_state_creator.clone() + } + /// Gets the locales information. + pub fn get_locales(&self) -> Locales { + let locales = self.locales.clone(); + self.plugins + .control_actions + .settings_actions + .set_locales + .run(locales.clone(), self.plugins.get_plugin_data()) + .unwrap_or(locales) + } + /// Gets the server-side translations manager. Like the mutable store, this can't be modified by plugins due to trait complexities. + /// + /// This involves evaluating the future stored for the translations manager, and so this consumes `self`. + #[cfg(feature = "server-side")] + pub async fn get_translations_manager(self) -> T { + match self.translations_manager { + Tm::Dummy(tm) => tm, + Tm::Full(tm) => tm.await, + } + } + /// Gets the immutable store. + #[cfg(feature = "server-side")] + pub fn get_immutable_store(&self) -> ImmutableStore { + let immutable_store = self.immutable_store.clone(); + self.plugins + .control_actions + .settings_actions + .set_immutable_store + .run(immutable_store.clone(), self.plugins.get_plugin_data()) + .unwrap_or(immutable_store) + } + /// Gets the mutable store. This can't be modified by plugins due to trait complexities, so plugins should instead expose a function that the user can use to manually set it. + #[cfg(feature = "server-side")] + pub fn get_mutable_store(&self) -> M { + self.mutable_store.clone() + } + /// Gets the plugins registered for the app. These are passed around and used in a way that doesn't require them to be concurrently accessible, and so are provided in an `Rc`. + pub fn get_plugins(&self) -> Rc> { + self.plugins.clone() + } + /// Gets the static aliases. This will check all provided resource paths to ensure they don't reference files outside the project directory, due to potential security risks in production + /// (we don't want to accidentally serve an arbitrary in a production environment where a path may point to somewhere evil, like an alias to `/etc/passwd`). + #[cfg(feature = "server-side")] + pub fn get_static_aliases(&self) -> HashMap { + let mut static_aliases = self.static_aliases.clone(); + // This will return a map of plugin name to another map of static aliases that that plugin produced + let extra_static_aliases = self + .plugins + .functional_actions + .settings_actions + .add_static_aliases + .run((), self.plugins.get_plugin_data()); + for (_plugin_name, aliases) in extra_static_aliases { + let new_aliases: HashMap = aliases + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + static_aliases.extend(new_aliases); + } + + let mut scoped_static_aliases = HashMap::new(); + for (url, path) in static_aliases { + // We need to move this from being scoped to the app to being scoped for `.perseus/` + // TODO Make sure this works properly on Windows (seems to..) + let new_path = if path.starts_with('/') { + // Absolute paths are a security risk and are disallowed + // The reason for this is that they could point somewhere completely different on a production server (like an alias to `/etc/passwd`) + // Allowing these would also inevitably cause head-scratching in production, it's much easier to disallow these + panic!( + "it's a security risk to include absolute paths in `static_aliases` ('{}'), please make this relative to the project directory", + path + ); + } else if path.starts_with("../") { + // Anything outside this directory is a security risk as well + panic!("it's a security risk to include paths outside the current directory in `static_aliases` ('{}')", path); + } else if path.starts_with("./") { + // `./` -> `../` (moving to execution from `.perseus/`) + // But if we're operating standalone, it stays the same + if cfg!(feature = "standalone") { + path.to_string() + } else { + format!(".{}", path) + } + } else { + // Anything else gets a `../` prepended + // But if we're operating standalone, it stays the same + if cfg!(feature = "standalone") { + path.to_string() + } else { + format!("../{}", path) + } + }; + + scoped_static_aliases.insert(url, new_path); + } + + scoped_static_aliases + } +} + +/// The component that represents the entrypoint at which Perseus will inject itself. You can use this with the `.index_view()` method of `PerseusApp` to avoid having to create the entrypoint +/// `
` manually. +#[component(PerseusRoot)] +pub fn perseus_root() -> View { + view! { + div(dangerously_set_inner_html = "
") + } +} + +/// An alias for the usual kind of Perseus app, which uses the filesystem-based mutable store and translations manager. +pub type PerseusApp = PerseusAppBase; +/// An alias for a Perseus app that uses a custom mutable store type. +pub type PerseusAppWithMutableStore = PerseusAppBase; +/// An alias for a Perseus app that uses a custom translations manager type. +pub type PerseusAppWithTranslationsManager = PerseusAppBase; +/// An alias for a fully customizable Perseus app that can accept a custom mutable store and a custom translations manager. Alternatively, you could just use `PerseusAppBase` directly. +pub type PerseusAppWithMutableStoreAndTranslationsManager = PerseusAppBase; diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 0132ce08c8..4d65aa2068 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -38,6 +38,7 @@ mod build; mod error_pages; mod export; mod i18n; +mod init; mod macros; mod router; mod server; @@ -53,7 +54,7 @@ pub use http::Request as HttpRequest; pub use wasm_bindgen_futures::spawn_local; /// All HTTP requests use empty bodies for simplicity of passing them around. They'll never need payloads (value in path requested). pub type Request = HttpRequest<()>; -pub use perseus_macro::{autoserde, head, make_rx, template, template_rx, test}; +pub use perseus_macro::{autoserde, head, main, make_rx, template, template_rx, test}; pub use sycamore::{generic_node::Html, DomNode, HydrateNode, SsrNode}; pub use sycamore_router::{navigate, navigate_replace, Route}; // TODO Should we be exporting `Route` anymore? @@ -66,6 +67,8 @@ pub use crate::plugins::{Plugin, PluginAction, Plugins}; pub use crate::shell::checkpoint; pub use crate::template::{HeadFn, RenderFnResult, RenderFnResultWithCause, States, Template}; pub use crate::utils::{cache_fallible_res, cache_res}; +// Everything in the `init.rs` file should be available at the top-level for convenience +pub use crate::init::*; /// Utilities for developing templates, particularly including return types for various rendering strategies. pub mod templates { pub use crate::errors::{ErrorCause, GenericErrorWithCause}; diff --git a/packages/perseus/src/macros.rs b/packages/perseus/src/macros.rs index 6846ec886f..7fc5ad4c76 100644 --- a/packages/perseus/src/macros.rs +++ b/packages/perseus/src/macros.rs @@ -1,68 +1,19 @@ -/// An internal macro used for defining a function to get the user's preferred immutable store (which requires multiple branches). -/// This can be reset by a control action. +/// An internal macro for adding the translations manager to an instance of an app based on whether or not one has been provided. This can only be used internally. +/// Regardless of the outcome of this, the locales must already have been set with `.locales_lit()`. #[doc(hidden)] #[macro_export] -macro_rules! define_get_immutable_store { - () => { - pub fn get_immutable_store() -> $crate::stores::ImmutableStore { - // This will be executed in the context of the user's directory, but moved into `.perseus` - // If we're in prod mode on the server though, this is fine too - $crate::stores::ImmutableStore::new("./dist".to_string()) - } - }; - ($dist_path:literal) => { - pub fn get_immutable_store() -> $crate::stores::ImmutableStore { - $crate::stores::ImmutableStore::new($dist_path.to_string()) - } - }; -} -/// An internal macro used for defining a function to get the user's preferred mutable store (which requires multiple branches). This -/// can be reset by a control action. -#[doc(hidden)] -#[macro_export] -macro_rules! define_get_mutable_store { - () => { - pub fn get_mutable_store() -> impl $crate::stores::MutableStore { - // This will be executed in the context of the user's directory, but moved into `.perseus` - // If we're in prod mode on the server though, this is fine too - // Note that this is separated out from the immutable store deliberately - $crate::stores::FsMutableStore::new("./dist/mutable".to_string()) - } - }; - ($mutable_store:expr) => { - pub fn get_mutable_store() -> impl $crate::stores::MutableStore { - $mutable_store - } - }; -} -/// An internal macro used for defining the global state creator. This cannot be altered in any way by plugins, except through -/// hooking into it through a generated template and modifying it. -#[doc(hidden)] -#[macro_export] -macro_rules! define_get_global_state_creator { - () => { - pub fn get_global_state_creator() -> $crate::state::GlobalStateCreator { - $crate::state::GlobalStateCreator::default() - } - }; - ($global_state_creator:expr) => { - pub fn get_global_state_creator() -> $crate::state::GlobalStateCreator { - $global_state_creator - } - }; -} -/// An internal macro used for defining the HTML `id` at which to render the Perseus app (which requires multiple branches). The default -/// is `root`. This can be reset by a control action. -#[doc(hidden)] -#[macro_export] -macro_rules! define_app_root { - () => { - pub static APP_ROOT: &str = "root"; - }; - ($root_id:literal) => { - pub static APP_ROOT: &str = $root_id; +macro_rules! add_translations_manager { + ($app:expr, $tm:expr, $locales:expr) => { + // We're not using `FsTranslationsManager`, set up the locales separately from the translations manager + $app = $app.locales_list($locales); + $app = $app.translations_manager($tm); + }; + ($app:expr, $locales:expr) => { + // We're using `FsTranslationsManager`, and we have locales information, so there's a nice convenience function to do all the caching stuff for us! + $app = $app.locales_lit_and_translations_manager($locales); }; } + #[cfg(feature = "standalone")] #[doc(hidden)] /// The default translations directory when we're running as a standalone binary. @@ -71,126 +22,6 @@ pub static DFLT_TRANSLATIONS_DIR: &str = "./translations"; #[doc(hidden)] /// The default translations directory when we're running with the `.perseus/` support structure. pub static DFLT_TRANSLATIONS_DIR: &str = "../translations"; -/// An internal macro used for defining a function to get the user's preferred translations manager (which requires multiple branches). -/// This is not plugin-extensible, but a control action can reset it later. -#[doc(hidden)] -#[macro_export] -macro_rules! define_get_translations_manager { - ($locales:expr) => { - pub async fn get_translations_manager() -> impl $crate::internal::i18n::TranslationsManager - { - // This will be executed in the context of the user's directory, but moved into `.perseus` - // Note that `translations/` must be next to `src/`, not within it - // By default, all translations are cached - let all_locales: Vec = $locales - .get_all() - .iter() - // We have a `&&String` at this point, hence the double clone - .cloned() - .cloned() - .collect(); - $crate::internal::i18n::FsTranslationsManager::new( - $crate::internal::i18n::DFLT_TRANSLATIONS_DIR.to_string(), - all_locales, - $crate::internal::i18n::TRANSLATOR_FILE_EXT.to_string(), - ) - .await - } - }; - ($locales:expr, $no_i18n:literal) => { - pub async fn get_translations_manager() -> impl $crate::internal::i18n::TranslationsManager - { - $crate::internal::i18n::DummyTranslationsManager::new() - } - }; - ($locales:expr, $translations_manager:expr) => { - pub async fn get_translations_manager() -> impl $crate::internal::i18n::TranslationsManager - { - $translations_manager - } - }; - // If the user doesn't want i18n but also sets their own transations manager, the latter takes priority - ($locales:expr, $no_i18n:literal, $translations_manager:expr) => { - pub async fn get_translations_manager() -> impl $crate::internal::i18n::TranslationsManager - { - $translations_manager - } - }; -} -/// An internal macro used for defining locales data. This is abstracted because it needs multiple branches. The `Locales` `struct` is -/// plugin-extensible. -#[doc(hidden)] -#[macro_export] -macro_rules! define_get_locales { - { - default: $default_locale:literal, - other: [$($other_locale:literal),*] - } => { - pub fn get_locales() -> $crate::internal::i18n::Locales { - $crate::internal::i18n::Locales { - default: $default_locale.to_string(), - other: vec![ - $($other_locale.to_string()),* - ], - using_i18n: true - } - } - }; - // With i18n disabled, the default locale will be `xx-XX` - { - default: $default_locale:literal, - other: [$($other_locale:literal),*], - no_i18n: $no_i18n:literal - } => { - pub fn get_locales() -> $crate::internal::i18n::Locales { - $crate::internal::i18n::Locales { - default: "xx-XX".to_string(), - other: Vec::new(), - using_i18n: false - } - } - }; -} -/// An internal macro for defining a function that gets the user's static content aliases (abstracted because it needs multiple -/// branches). This returns a plugin-extensible `HashMap`. -#[doc(hidden)] -#[macro_export] -macro_rules! define_get_static_aliases { - ( - static_aliases: { - $($url:literal => $resource:literal),* - } - ) => { - pub fn get_static_aliases() -> ::std::collections::HashMap { - let mut static_aliases = ::std::collections::HashMap::new(); - $( - static_aliases.insert($url.to_string(), $resource.to_string()); - )* - static_aliases - } - }; - () => { - pub fn get_static_aliases() -> ::std::collections::HashMap { - ::std::collections::HashMap::new() - } - }; -} -/// An internal macro used for defining the plugins for an app. Unsurprisingly, the results of this are not plugin-extensible! That -/// said, plugins can certainly register third-party runners in their own registrars (though they need to be mindful of security). -#[doc(hidden)] -#[macro_export] -macro_rules! define_plugins { - () => { - pub fn get_plugins() -> $crate::Plugins { - $crate::Plugins::new() - } - }; - ($plugins:expr) => { - pub fn get_plugins() -> $crate::Plugins { - $plugins - } - }; -} /// Defines the components to create an entrypoint for the app. The actual entrypoint is created in the `.perseus/` crate (where we can /// get all the dependencies without driving the user's `Cargo.toml` nuts). This also defines the template map. This is intended to make @@ -198,6 +29,8 @@ macro_rules! define_plugins { /// /// Warning: all properties must currently be in the correct order (`root`, `templates`, `error_pages`, `global_state_creator`, `locales`, `static_aliases`, /// `plugins`, `dist_path`, `mutable_store`, `translations_manager`). +/// +/// Note: as of v0.3.4, this is just a wrapper over `PerseusAppBase`, which is the recommended way to create a new Perseus app (no macros involved). #[macro_export] macro_rules! define_app { // With locales @@ -234,7 +67,8 @@ macro_rules! define_app { locales: { default: $default_locale, // The user doesn't have to define any other locales (but they'll still get locale detection and the like) - other: [$($other_locale),*] + other: [$($other_locale),*], + no_i18n: false } $(,static_aliases: { $($url => $resource),* @@ -299,9 +133,9 @@ macro_rules! define_app { locales: { default: $default_locale:literal, // The user doesn't have to define any other locales - other: [$($other_locale:literal),*] - // If this is defined at all, i18n will be disabled and the default locale will be set to `xx-XX` - $(,no_i18n: $no_i18n:literal)? + other: [$($other_locale:literal),*], + // If this is `true` + no_i18n: $no_i18n:literal } $(,static_aliases: { $($url:literal => $resource:literal),* @@ -312,62 +146,59 @@ macro_rules! define_app { $(,translations_manager: $translations_manager:expr)? } ) => { - /// Gets the plugins for the app. - $crate::define_plugins!($($plugins)?); - - /// The html `id` that will find the app root to render Perseus in. For server-side interpolation, this MUST be an element of - /// the form
` in your markup (double or single quotes, `root_id` replaced by what this property is set to). - $crate::define_app_root!($($root_selector)?); - - /// Gets the immutable store to use. This allows the user to conveniently change the path of distribution artifacts. - $crate::define_get_immutable_store!($($dist_path)?); - /// Gets the mutable store to use. This allows the user to conveniently substitute the default filesystem store for another - /// one in development and production. - $crate::define_get_mutable_store!($($mutable_store)?); - - /// Gets the translations manager to use. This allows the user to conveniently test production managers in development. If - /// nothing is given, the filesystem will be used. - $crate::define_get_translations_manager!(get_locales() $(, $no_i18n)? $(, $translations_manager)?); - - /// Defines the locales the app should build for, specifying defaults and common locales (which will be built at build-time - /// rather than on-demand). - $crate::define_get_locales! { - default: $default_locale, - other: [ - $($other_locale),* - ] - $(, no_i18n: $no_i18n)? - } - - /// Gets any static content aliases provided by the user. - $crate::define_get_static_aliases!( - $(static_aliases: { - $($url => $resource),* - })? - ); - - /// Gets the global state creator for the app. - $crate::define_get_global_state_creator!($($global_state_creator)?); + #[$crate::main] + pub fn main() -> $crate::PerseusAppBase { + let mut app = $crate::PerseusAppBase::new(); + // If we have a mutable store, we'll actually initialize in a completely different way + $( + let mut app = $crate::PerseusAppBase::new_with_mutable_store($mutable_store).await; + )?; + // Conditionally add each property the user provided + $( + app = app.root($root_selector); + )?; + $( + app = app.template(|| $template); + )+; + app = app.error_pages(|| $error_pages); + $( + app = app.global_state_creator($global_state_creator); + )?; + $($( + app = app.static_alias($url, $resource); + )*)?; + $( + app = app.plugins($plugins); + )?; + // Use `index.html` for the index view (for backward compatibility) + // We need the filesystem here, and we don't need on it in the browser + // We can't modify `app` if this is all in a block, so we compromise a bit + let index_html = if cfg!(target_arch = "wasm32") { + // In the browser, this would turn into using the hardocded default, but we don't need the index view there anyway + ::std::result::Result::Err(::std::io::Error::from(::std::io::ErrorKind::NotFound)) + } else { + ::std::fs::read_to_string("../index.html") + }; + if let ::std::result::Result::Ok(index_html) = index_html { + app = app.index_view_str(&index_html) + } + // Build a `Locales` instance + let mut other_locales = ::std::vec::Vec::new(); + $( + other_locales.push($other_locale.to_string()); + )*; + // We can't guarantee that the user is using `FsTranslationsManager`, so we set only the locales, and the translations manager separately later + let locales = $crate::internal::i18n::Locales { + default: $default_locale.to_string(), + other: other_locales, + using_i18n: !$no_i18n + }; - /// Gets a map of all the templates in the app by their root paths. This returns a `HashMap` that is plugin-extensible. - pub fn get_templates_map() -> $crate::templates::TemplateMap { - $crate::get_templates_map![ - $($template),+ - ] - } + // Set the translations manager and locales information with a helper macro that can handle two different paths of provision + $crate::add_translations_manager!(app, $($translations_manager,)? locales); - /// Gets a map of all the templates in the app by their root paths. This returns a `HashMap` that is plugin-extensible. - /// - /// This is the thread-safe version, which should only be called on the server. - pub fn get_templates_map_atomic() -> $crate::templates::ArcTemplateMap { - $crate::get_templates_map_atomic![ - $($template),+ - ] + app } - /// Gets the error pages (done here so the user doesn't have to worry about naming). This is plugin-extensible. - pub fn get_error_pages() -> $crate::ErrorPages { - $error_pages - } }; } diff --git a/packages/perseus/src/plugins/control.rs b/packages/perseus/src/plugins/control.rs index c526cca6ac..0f86c4273c 100644 --- a/packages/perseus/src/plugins/control.rs +++ b/packages/perseus/src/plugins/control.rs @@ -89,6 +89,16 @@ pub struct ControlPluginSettingsActions { pub set_locales: ControlPluginAction, /// Sets the app root to be used everywhere. This must correspond to the ID of an empty HTML `div`. pub set_app_root: ControlPluginAction<(), String>, + /// Actions pertaining to the HTML shell, partitioned away for deliberate inconvenience (you should almost never use these). + pub html_shell_actions: ControlPluginHtmlShellActions, +} +/// Control actions that pertain to the HTML shell. Note that these actions should be used extremely sparingly, as they are very rarely needed (see the available functional actions +/// for the HTML shell), and they can have confusing side effects for CSS hierarchies, as well as potentially interrupting Perseus' interpolation processes. Changing certain things +/// with these may break Perseus completely in certain cases! +#[derive(Default, Debug)] +pub struct ControlPluginHtmlShellActions { + /// Overrides whatever the user provided as their HTML shell completely. Whatever you provide here MUST contain a `` and a `` at least, or Perseus will completely fail. + pub set_shell: ControlPluginAction<(), String>, } /// Control actions that pertain to the build process. #[derive(Default, Debug)] diff --git a/packages/perseus/src/plugins/functional.rs b/packages/perseus/src/plugins/functional.rs index 5a3836a2fc..7a1aaddc5f 100644 --- a/packages/perseus/src/plugins/functional.rs +++ b/packages/perseus/src/plugins/functional.rs @@ -107,6 +107,8 @@ pub struct FunctionalPluginSettingsActions { /// power to override the user's error pages. pub add_error_pages: FunctionalPluginAction<(), HashMap>>, + /// Actions pertaining to the HTML shell, in their own category for cleanliness (as there are quite a few). + pub html_shell_actions: FunctionalPluginHtmlShellActions, } impl Default for FunctionalPluginSettingsActions { fn default() -> Self { @@ -114,10 +116,31 @@ impl Default for FunctionalPluginSettingsActions { add_static_aliases: FunctionalPluginAction::default(), add_templates: FunctionalPluginAction::default(), add_error_pages: FunctionalPluginAction::default(), + html_shell_actions: FunctionalPluginHtmlShellActions::default(), } } } +/// Functional actions that pertain to the HTML shell. +/// +/// **IMPORTANT:** The HTML shell's `` contains an *interpolation boundary*, after which all content is wiped between page loads. If you want the code you add (HTML or JS) to +/// persist between pages (which you usually will), make sure to use the `..._before_boundary` actions. +#[derive(Default, Debug)] +pub struct FunctionalPluginHtmlShellActions { + /// Adds to the additional HTML content in the document `` before the interpolation boundary. + pub add_to_head_before_boundary: FunctionalPluginAction<(), Vec>, + /// Adds JS code (which will be placed into a `