From 78fef13a5937009d5fcd9201431699b86014b822 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Mon, 21 Feb 2022 19:13:12 +1100 Subject: [PATCH 01/10] docs(blog): added first draft of hsr post We don't have a way to display this yet, but there will soon be one. --- website/blog/hsr-vs-hmr.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 website/blog/hsr-vs-hmr.md diff --git a/website/blog/hsr-vs-hmr.md b/website/blog/hsr-vs-hmr.md new file mode 100644 index 0000000000..0c2d67087c --- /dev/null +++ b/website/blog/hsr-vs-hmr.md @@ -0,0 +1,26 @@ +# HSR vs HMR -- How Web Development is Getting an Upgrade + +In v0.3.4, Perseus includes a new, revolutionary feature: *hot state reloading* (HSR), a Wasm improvement to JavaScript's *hot module reloading* (HMR). But what exactly is this new tech, and what are the differences between it and HMR? + +Well, to understand this, we first need to understand HMR. For those who aren't familiar with the JS ecosystem, it's a feature that's part of Webpack and a number of other JS bundlers, and it basically enables a single part of your codebase to be swapped out in the browser, meaning the rest of your code stays the same, meaning your app's state stays the same, meaning you can continue to work in development without having to be thrown back to the beginning of your app. To illustrate this, let's take the following workflow: + +1. Code a login page that works in two stages, the first with a username/password entry and the second with a two-factor authentication code input. +2. Start testing this and get to the 2FA screen. +3. Realize that you misspelled the input placeholder (or some other error) and go back into the code to fix it. +4. The build tool detects the code change, rebuilds, and updates the browser. + +The big question for developer experience here is this: is the developer thrown back to the username/password input, or do they retain their place at the 2FA code input? While this may seem insignificant, this problem very easily scales to become a huge problem, especially in large apps with a number of moving parts. Most web developers that have used a non-HMR framework in the past will have some horror story of having to put in the same inputs dozens of times while they debug and try to fix some strange error three screens into a loging flow or the like. + +HMR was invented to solve this exact problem (as well as a few others), and the way it does it is interesting. With JavaScript, your code can be split into *chunks*, which is a complex and nuanced process that I won't go into here, but it essentially means that the minimum amount of code is served to users' browsers to get some part of your app to work, and then more code is requested as necessary (potentially even predictively). This means that we can swap out just one of those files when some code in it (or some code in another file that ends up in that chunk by bundler magic) is changed, while leaving the rest of the code untouched. This avoids having to perform a full reload of the page, and it crucially *keeps your app's state*. + +But, over in the Wasm world, this isn't feasible on a large scale yet. Wasm code splitting (chunking) is still in the early stages of throwing ideas around, and, while it can be done manually, it's inefficient and introduces unnecessary code bloat. Wasm also has the added benefit of comign to the browser already compiled, unlike JS, meaning there's less overhead in using Wasm, which is why it's a fallacy to compare JS file sizes to Wasm file sizes, when in reality they're more comparable to image file sizes (a 300kb JS bundle would make developers shiver, but a 300kb image is perfectly normal, and much faster to load because there's next to zero compilation overhead). + +So, the upshot of all this is that HMR in any kind of automated and efficient way is insanely hard in Wasm, to the point that (to my knowledge) no-one has actually created a system that can do it reliably and without extensive manual configuration. This means that we're stuck with just performing a full reload of the webpage whenever the code changes, meaning the app's state is lost forever, and those horror stories come right back. Or does it? + +Around the end of 2021, I had an idea about making the concept of page state in Perseus much more powerful, by adding the ability to make it reactive. After a good month of work, v0.3.4 was ready with all these features, and, along the way, I had another idea: **if developers are going to have their entire app's state inside a reactive object, what if we serialized that to a string?** It turns out, that's only a few dozen lines of code, and that's Perseus' game-changing principle of *state freezing* and *state thawing*: the idea that you can take your entire app's state, turn it into a string, store it anywhere you want, and use it to restore the app to a previous state at any time in the future. + +So what if we combine this with live reloading? What if Perseus, when it received a live reload instruction from the CLI, froze the state, stored it in IndexedDB (built-in browser storage), reloaded the page, and then thawed that state? Well, we get what I've termed *hot state reloading*, a full restoration of the app's state. So, how does this compare with HMR? Well, for starters, it's more resilient, because we don't have to worry about what's in a chunk with what, your app's state can be restored in every single case, except when you change the data model for a page (e.g. adding a new property or the like, which would make deserialization impossible). That's the main advantage, but there's also the obvious positive that this is *far* easier to implement than HMR. For frameworks that have app state represented in a single place, this should be a no-brainer in my opinion. No bunders needed, no magic needed, just a bit of stringifying. + +But what about development speed? One of the big advantages of JS frameworks over Wasm frameworks right now is the faster iteration times, and that probably won't change anytime soon. With `perseus export -sw` today, the development pace is extremely speedy compared to other frameworks (and much faster than Perseus once was), but it still doesn't come close to the performance of a JS framework that doesn't need to 'compile' at all. But what about the latency between HMR and HSR? Well, they're basically exactly the same. While HMR may not look like it's triggering a full reload of the page, HSR's full reload with perks is as fast if not faster, since the Wasm bundle can be instantiated more quickly than even a smaller volume of JS code, and since Rust is just objectively faster than JS, especially on the server. Additionally, literally zero server-side HSR-specific computation is needed. In fact, if you were to inspect the WebSocket communications between the Perseus CLI and your app in development, they would have no payloads! That is how simple HSR actually is, and yet it can save a huge amount of developer time and effort. + +To summarize, HSR is an improvement over HMR in a myriad of ways, not least because it's far easier to implement, it's build-tool agnostic in terms of implementation, and it's more resilient than even the best HMR implementations. It's also more versatile, since it can be implemented in any language, regardless of code splitting ability, and it provides a feature that's been sorely missing from the Wasm ecosystem since its inception. With the release of this post, HSR is available for use in Perseus today, and I sincerely hope that other frameworks will adopt and improve upon this technology, so that, together, we can improve developer experience in web development to deliver meaningfully positive software to users across the world more efficiently than ever before. From 04e48049334f5c27dcbee8a76246d66f04ccf4b8 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 8 Mar 2022 20:02:10 +1100 Subject: [PATCH 02/10] feat: added typed options system and updated macros This removes the need for the `define_app!` macro, and makes it dependent on this new system. There may be some minor changes needed to code using custom translations manager (which are now handled as futures). --- examples/core/basic/.perseus/Cargo.toml | 1 + .../basic/.perseus/builder/src/bin/build.rs | 24 +- .../basic/.perseus/builder/src/bin/export.rs | 34 +- .../builder/src/bin/export_error_page.rs | 11 +- .../basic/.perseus/builder/src/bin/tinker.rs | 4 +- .../core/basic/.perseus/server/src/main.rs | 27 +- examples/core/basic/.perseus/src/app.rs | 167 ------ examples/core/basic/.perseus/src/lib.rs | 17 +- examples/core/basic/src/lib.rs | 13 +- packages/perseus/src/error_pages.rs | 24 + packages/perseus/src/i18n/mod.rs | 2 +- .../perseus/src/i18n/translations_manager.rs | 104 ++-- packages/perseus/src/init.rs | 507 ++++++++++++++++++ packages/perseus/src/lib.rs | 3 + packages/perseus/src/macros.rs | 294 ++-------- packages/perseus/src/state/global_state.rs | 6 +- packages/perseus/src/stores/mutable.rs | 2 +- 17 files changed, 730 insertions(+), 510 deletions(-) delete mode 100644 examples/core/basic/.perseus/src/app.rs create mode 100644 packages/perseus/src/init.rs 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..2c6f3cc157 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,13 @@ 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(); + let app_root = app.get_root(); // 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 +76,12 @@ async fn build_and_export() -> i32 { return 1; } }; + let templates_map = app.get_templates_map(); + // 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,7 +108,6 @@ 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", @@ -134,12 +134,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 +181,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..3a888c0825 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 @@ -6,7 +6,7 @@ use perseus::{ }, PluginAction, SsrNode, }; -use perseus_engine::app::{get_app_root, get_error_pages, get_immutable_store, get_plugins}; +use perseus_engine as app; use std::{env, fs}; #[tokio::main] @@ -18,12 +18,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 error_pages = app.get_error_pages(); + let root_id = app.get_root(); + let immutable_store = app.get_immutable_store(); let render_cfg = match get_render_cfg(&immutable_store).await { Ok(render_cfg) => render_cfg, Err(err) => { 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..787650cd05 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 @@ -103,14 +100,14 @@ 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..8ab988675a 100644 --- a/examples/core/basic/.perseus/src/lib.rs +++ b/examples/core/basic/.perseus/src/lib.rs @@ -1,8 +1,7 @@ #![allow(clippy::unused_unit)] // rustwasm/wasm-bindgen#2774 awaiting next `wasm-bindgen` release -pub mod app; +pub use app::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 +10,6 @@ use perseus::{ }, plugins::PluginAction, templates::TemplateNodeType, - DomNode, }; use sycamore::prelude::view; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; @@ -19,7 +17,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 = app::main(); + let plugins = app.get_plugins(); checkpoint("begin"); // Panics should always go to the console @@ -37,7 +36,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 +47,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 => &app::main::().get_templates_map(), + locales => &app::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..9e659d7dc1 100644 --- a/examples/core/basic/src/lib.rs +++ b/examples/core/basic/src/lib.rs @@ -1,12 +1,11 @@ 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() +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/src/error_pages.rs b/packages/perseus/src/error_pages.rs index fd16b18e14..7dd365a7a9 100644 --- a/packages/perseus/src/error_pages.rs +++ b/packages/perseus/src/error_pages.rs @@ -3,6 +3,7 @@ use crate::{DomNode, Html, HydrateNode, SsrNode}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::rc::Rc; +use sycamore::view; use sycamore::view::View; use web_sys::Element; @@ -124,6 +125,29 @@ impl ErrorPages { }) } } +// 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/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..679be1478e --- /dev/null +++ b/packages/perseus/src/init.rs @@ -0,0 +1,507 @@ +use crate::plugins::PluginAction; +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. + pub fn get_index_view(&self) -> String { + // TODO Allow plugin modification of this + self.index_view.to_string() + } + /// 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 { + // TODO Allow plugin modification of this + (self.error_pages.0)() + } + /// 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(id = "root") {} + } +} + +/// 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..2827f1806b 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; @@ -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..fed33803cd 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,46 @@ 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)?); + 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); + )?; + // Build a `Locales` instance + let mut other_locales = 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/state/global_state.rs b/packages/perseus/src/state/global_state.rs index a238443d04..3b809b55e2 100644 --- a/packages/perseus/src/state/global_state.rs +++ b/packages/perseus/src/state/global_state.rs @@ -9,14 +9,14 @@ use std::rc::Rc; make_async_trait!(GlobalStateCreatorFnType, RenderFnResult); /// The type of functions that generate global state. These will generate a `String` for their custom global state type. -pub type GlobalStateCreatorFn = Box; +pub type GlobalStateCreatorFn = Rc; /// A creator for global state. This stores user-provided functions that will be invoked to generate global state on the client /// and the server. /// /// The primary purpose of this is to allow the generation of top-level app state on the server and the client. Notably, /// this can also be interacted with by plugins. -#[derive(Default)] +#[derive(Default, Clone)] pub struct GlobalStateCreator { /// The function that creates state at build-time. This is roughly equivalent to the *build state* strategy for templates. build: Option, @@ -45,7 +45,7 @@ impl GlobalStateCreator { ) -> Self { #[cfg(feature = "server-side")] { - self.build = Some(Box::new(val)); + self.build = Some(Rc::new(val)); } self } diff --git a/packages/perseus/src/stores/mutable.rs b/packages/perseus/src/stores/mutable.rs index dcf736b06b..cfa3fb4c43 100644 --- a/packages/perseus/src/stores/mutable.rs +++ b/packages/perseus/src/stores/mutable.rs @@ -24,7 +24,7 @@ pub struct FsMutableStore { root_path: String, } impl FsMutableStore { - /// Creates a new filesystem configuration manager. You should provide a path like `/dist/mutable` here. Make sure that this is + /// Creates a new filesystem configuration manager. You should provide a path like `dist/mutable` here. Make sure that this is /// not the same path as the immutable store, as this will cause potentially problematic overlap between the two systems. pub fn new(root_path: String) -> Self { Self { root_path } From 13d83bcebc6d66864ab8be1db728b367acefb4e0 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 8 Mar 2022 20:37:44 +1100 Subject: [PATCH 03/10] refactor: updated tiny example to use new init api --- examples/comprehensive/tiny/src/lib.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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)) } }) } From 094a96632431831073fc5b92aba04761cafd6650 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Wed, 9 Mar 2022 19:59:25 +1100 Subject: [PATCH 04/10] feat: added support for plugin modification of error pages This feature was originally present, and is now supported again! --- packages/perseus/src/init.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/perseus/src/init.rs b/packages/perseus/src/init.rs index 679be1478e..575d0eab9b 100644 --- a/packages/perseus/src/init.rs +++ b/packages/perseus/src/init.rs @@ -382,7 +382,20 @@ impl PerseusAppBase { /// Gets the error pages used in the app. This returns an `Rc`. pub fn get_error_pages(&self) -> ErrorPages { // TODO Allow plugin modification of this - (self.error_pages.0)() + 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 { From 8dd638a4bb7623ccc9a9ee7720522cdb53150af8 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Mon, 14 Mar 2022 20:19:09 +1100 Subject: [PATCH 05/10] feat: integrated new index view system This removes the dependency on an `index.html` file, all that is managed internally now! Note that this makes the old `index.html` file system dysfunctional, but I will fix that before this is merged. --- .../basic/.perseus/builder/src/bin/export.rs | 5 ++-- .../builder/src/bin/export_error_page.rs | 26 ++----------------- .../core/basic/.perseus/server/src/main.rs | 9 ++++--- examples/core/basic/src/lib.rs | 11 +++++++- packages/perseus-actix-web/src/configurer.rs | 14 ++-------- packages/perseus-warp/src/perseus_routes.rs | 15 +++-------- packages/perseus/src/export.rs | 17 +++--------- packages/perseus/src/init.rs | 25 ++++++++++++++---- packages/perseus/src/server/options.rs | 7 ++--- 9 files changed, 51 insertions(+), 78 deletions(-) diff --git a/examples/core/basic/.perseus/builder/src/bin/export.rs b/examples/core/basic/.perseus/builder/src/bin/export.rs index 2c6f3cc157..23918adb14 100644 --- a/examples/core/basic/.perseus/builder/src/bin/export.rs +++ b/examples/core/basic/.perseus/builder/src/bin/export.rs @@ -60,7 +60,6 @@ async fn build_and_export() -> i32 { // We don't need this in exporting, but the build process does let mutable_store = app.get_mutable_store(); let locales = app.get_locales(); - let app_root = app.get_root(); // Generate the global state let gsc = app.get_global_state_creator(); let global_state = match gsc.get_build_state().await { @@ -77,6 +76,7 @@ async fn build_and_export() -> i32 { } }; 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; @@ -110,9 +110,8 @@ async fn build_and_export() -> i32 { // Turn the build artifacts into self-contained static files 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(), 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 3a888c0825..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,11 +1,5 @@ use fmterr::fmt_err; -use perseus::{ - internal::{ - get_path_prefix_server, - serve::{build_error_page, get_render_cfg, HtmlShell}, - }, - PluginAction, SsrNode, -}; +use perseus::{internal::serve::build_error_page, PluginAction, SsrNode}; use perseus_engine as app; use std::{env, fs}; @@ -23,24 +17,8 @@ async fn real_main() -> i32 { let plugins = app.get_plugins(); let error_pages = app.get_error_pages(); - let root_id = app.get_root(); - let immutable_store = app.get_immutable_store(); - let render_cfg = match get_render_cfg(&immutable_store).await { - Ok(render_cfg) => render_cfg, - Err(err) => { - eprintln!("{}", fmt_err(&err)); - return 1; - } - }; // 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/server/src/main.rs b/examples/core/basic/.perseus/server/src/main.rs index 787650cd05..9f20d5b641 100644 --- a/examples/core/basic/.perseus/server/src/main.rs +++ b/examples/core/basic/.perseus/server/src/main.rs @@ -94,10 +94,10 @@ fn get_props(is_standalone: bool) -> ServerProps ServerProps() -> PerseusApp { PerseusApp::new() .template(crate::templates::index::get_template) .template(crate::templates::about::get_template) .error_pages(crate::error_pages::get_error_pages) + .index_view(|| { + sycamore::view! { + head {} + body { + p { "Test" } + PerseusRoot() + } + } + }) } 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 { /// 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<'a>, /// 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(); diff --git a/packages/perseus/src/init.rs b/packages/perseus/src/init.rs index 575d0eab9b..ec3ebca6b3 100644 --- a/packages/perseus/src/init.rs +++ b/packages/perseus/src/init.rs @@ -1,4 +1,6 @@ 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, @@ -321,10 +323,24 @@ impl PerseusAppBase { .run((), self.plugins.get_plugin_data()) .unwrap_or_else(|| self.root.to_string()) } - /// Gets the index view. - pub fn get_index_view(&self) -> String { + /// Gets the index view. This is asynchronous because it constructs an HTML shell, which invovles fetching the configuration of pages. + #[cfg(feature = "server-side")] + pub async fn get_index_view<'a>(&self) -> HtmlShell<'a> { // TODO Allow plugin modification of this - self.index_view.to_string() + // 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 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(), + ); + + html_shell } /// Gets the templates in an `Rc`-based `HashMap` for non-concurrent access. pub fn get_templates_map(&self) -> TemplateMap { @@ -381,7 +397,6 @@ impl PerseusAppBase { } /// Gets the error pages used in the app. This returns an `Rc`. pub fn get_error_pages(&self) -> ErrorPages { - // TODO Allow plugin modification of this let mut error_pages = (self.error_pages.0)(); let extra_error_pages = self .plugins @@ -506,7 +521,7 @@ impl PerseusAppBase { #[component(PerseusRoot)] pub fn perseus_root() -> View { view! { - div(id = "root") {} + div(dangerously_set_inner_html = "
") } } diff --git a/packages/perseus/src/server/options.rs b/packages/perseus/src/server/options.rs index 926c730cf2..ce07038f24 100644 --- a/packages/perseus/src/server/options.rs +++ b/packages/perseus/src/server/options.rs @@ -7,6 +7,8 @@ use crate::template::ArcTemplateMap; use crate::SsrNode; use std::collections::HashMap; +use super::HtmlShell; + /// The options for setting up all server integrations. This should be literally constructed, as nothing is optional. If integrations need further properties, /// they should expose their own options in addition to these. These should be accessed through an `Arc`/`Rc` for integration developers. #[derive(Debug)] @@ -17,9 +19,8 @@ pub struct ServerOptions { pub wasm_bundle: String, /// The location on the filesystem of your JS bundle converted from your Wasm bundle. This isn't required, and if you haven't generated this, you should provide a fake path. pub wasm_js_bundle: String, - /// The location on the filesystem of your `index.html` file. - // TODO Should this actually be a raw string of HTML so plugins can inject efficiently? - pub index: String, + /// The HTML shell to interpolate Perseus into. + pub html_shell: HtmlShell<'static>, /// A `HashMap` of your app's templates by their paths. pub templates_map: ArcTemplateMap, /// The locales information for the app. From d1ca2ab882907e63e56c50808751a51212d2abc1 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Fri, 18 Mar 2022 20:18:58 +1100 Subject: [PATCH 06/10] feat: added support for plugins for index view This will all be ready after the index view system supports `index.html` sourcing. --- examples/core/basic/src/lib.rs | 9 --- .../perseus-actix-web/src/initial_load.rs | 2 +- packages/perseus-warp/src/initial_load.rs | 2 +- packages/perseus/src/export.rs | 4 +- packages/perseus/src/init.rs | 80 ++++++++++++++++++- packages/perseus/src/plugins/control.rs | 10 +++ packages/perseus/src/plugins/functional.rs | 23 ++++++ packages/perseus/src/server/html_shell.rs | 41 ++++++---- packages/perseus/src/server/options.rs | 2 +- 9 files changed, 142 insertions(+), 31 deletions(-) diff --git a/examples/core/basic/src/lib.rs b/examples/core/basic/src/lib.rs index ad87aa08df..090bdc49be 100644 --- a/examples/core/basic/src/lib.rs +++ b/examples/core/basic/src/lib.rs @@ -8,13 +8,4 @@ pub fn main() -> PerseusApp { .template(crate::templates::index::get_template) .template(crate::templates::about::get_template) .error_pages(crate::error_pages::get_error_pages) - .index_view(|| { - sycamore::view! { - head {} - body { - p { "Test" } - PerseusRoot() - } - } - }) } diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index 08a4e0dad0..4a7dc728b8 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -40,7 +40,7 @@ fn return_error_page( pub async fn initial_load( 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-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/src/export.rs b/packages/perseus/src/export.rs index 90c20a4c56..a1bedaeeaa 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -41,7 +41,7 @@ pub struct ExportProps<'a, T: TranslationsManager> { /// All the templates in the app. pub templates: &'a TemplateMap, /// The HTML shell to use. - pub html_shell: HtmlShell<'a>, + pub html_shell: HtmlShell, /// The locales data for the app. pub locales: &'a Locales, /// An immutable store. @@ -128,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/init.rs b/packages/perseus/src/init.rs index ec3ebca6b3..37b60960a2 100644 --- a/packages/perseus/src/init.rs +++ b/packages/perseus/src/init.rs @@ -324,13 +324,16 @@ impl PerseusAppBase { .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<'a>(&self) -> HtmlShell<'a> { + 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 html_shell = HtmlShell::new( + 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) @@ -340,6 +343,79 @@ impl PerseusAppBase { &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() + .flat_map(|v| v) + .cloned() + .collect(), + ); + html_shell.scripts_before_boundary.push( + hsf_actions + .add_to_scripts_before_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flat_map(|v| v) + .cloned() + .collect(), + ); + html_shell.head_after_boundary.push( + hsf_actions + .add_to_head_after_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flat_map(|v| v) + .cloned() + .collect(), + ); + html_shell.scripts_after_boundary.push( + hsf_actions + .add_to_scripts_after_boundary + .run((), self.plugins.get_plugin_data()) + .values() + .flat_map(|v| v) + .cloned() + .collect(), + ); + html_shell.before_content.push( + hsf_actions + .add_to_before_content + .run((), self.plugins.get_plugin_data()) + .values() + .flat_map(|v| v) + .cloned() + .collect(), + ); + html_shell.after_content.push( + hsf_actions + .add_to_after_content + .run((), self.plugins.get_plugin_data()) + .values() + .flat_map(|v| v) + .cloned() + .collect(), + ); + html_shell } /// Gets the templates in an `Rc`-based `HashMap` for non-concurrent access. 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 `