diff --git a/examples/basic/.perseus/builder/src/bin/build.rs b/examples/basic/.perseus/builder/src/bin/build.rs index 355eb78c37..d4fc9fe923 100644 --- a/examples/basic/.perseus/builder/src/bin/build.rs +++ b/examples/basic/.perseus/builder/src/bin/build.rs @@ -1,8 +1,8 @@ use fmterr::fmt_err; use perseus::{internal::build::build_app, PluginAction, SsrNode}; use perseus_engine::app::{ - get_immutable_store, get_locales, get_mutable_store, get_plugins, get_templates_map, - get_translations_manager, + get_global_state_creator, get_immutable_store, get_locales, get_mutable_store, get_plugins, + get_templates_map, get_translations_manager, }; #[tokio::main] @@ -27,6 +27,17 @@ async fn real_main() -> i32 { // We can't proceed without a translations manager let translations_manager = get_translations_manager().await; let locales = get_locales(&plugins); + // Generate the global state + let gsc = get_global_state_creator(&plugins); + let global_state = match gsc.get_build_state().await { + Ok(global_state) => global_state, + Err(err) => { + let err_msg = fmt_err(&err); + // TODO Functional action here + eprintln!("{}", err_msg); + return 1; + } + }; // 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 @@ -36,6 +47,7 @@ async fn real_main() -> i32 { &locales, (&immutable_store, &mutable_store), &translations_manager, + &global_state, // We use another binary to handle exporting false, ) diff --git a/examples/basic/.perseus/builder/src/bin/export.rs b/examples/basic/.perseus/builder/src/bin/export.rs index efde2e26f1..b888f5671b 100644 --- a/examples/basic/.perseus/builder/src/bin/export.rs +++ b/examples/basic/.perseus/builder/src/bin/export.rs @@ -5,8 +5,8 @@ use perseus::{ PluginAction, SsrNode, }; use perseus_engine::app::{ - get_app_root, get_immutable_store, get_locales, get_mutable_store, get_plugins, - get_static_aliases, get_templates_map, get_translations_manager, + 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 std::fs; use std::path::PathBuf; @@ -58,6 +58,17 @@ async fn build_and_export() -> i32 { let mutable_store = get_mutable_store(); let translations_manager = get_translations_manager().await; let locales = get_locales(&plugins); + // Generate the global state + let gsc = get_global_state_creator(&plugins); + let global_state = match gsc.get_build_state().await { + Ok(global_state) => global_state, + Err(err) => { + let err_msg = fmt_err(&err); + // TODO Functional action here + eprintln!("{}", err_msg); + return 1; + } + }; // 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 @@ -67,6 +78,7 @@ async fn build_and_export() -> i32 { &locales, (&immutable_store, &mutable_store), &translations_manager, + &global_state, // We use another binary to handle normal building true, ) @@ -97,6 +109,7 @@ async fn build_and_export() -> i32 { &immutable_store, &translations_manager, get_path_prefix_server(), + &global_state, ) .await; if let Err(err) = export_res { diff --git a/examples/basic/.perseus/server/Cargo.toml b/examples/basic/.perseus/server/Cargo.toml index 20101eeafa..1e53672f8b 100644 --- a/examples/basic/.perseus/server/Cargo.toml +++ b/examples/basic/.perseus/server/Cargo.toml @@ -25,7 +25,7 @@ tokio = { version = "1", optional = true, features = [ "macros", "rt-multi-threa integration-actix-web = [ "perseus-actix-web", "actix-web", "actix-http" ] integration-warp = [ "perseus-warp", "warp", "tokio" ] -default = [ "integration-warp" ] +default = [ "integration-actix-web" ] # This makes the binary work on its own, and is enabled by `perseus deploy` (do NOT invoke this manually!) standalone = [ "perseus/standalone", "perseus-engine/standalone" ] diff --git a/examples/basic/.perseus/server/src/main.rs b/examples/basic/.perseus/server/src/main.rs index 7910f6359a..82e59b305c 100644 --- a/examples/basic/.perseus/server/src/main.rs +++ b/examples/basic/.perseus/server/src/main.rs @@ -5,8 +5,9 @@ use perseus::plugins::PluginAction; use perseus::stores::MutableStore; use perseus::SsrNode; use perseus_engine::app::{ - get_app_root, get_error_pages_contained, get_immutable_store, get_locales, get_mutable_store, - get_plugins, get_static_aliases, get_templates_map_atomic_contained, get_translations_manager, + get_app_root, get_error_pages_contained, get_global_state_creator, get_immutable_store, + get_locales, get_mutable_store, get_plugins, get_static_aliases, + get_templates_map_atomic_contained, get_translations_manager, }; use std::env; use std::fs; @@ -26,6 +27,7 @@ async fn main() -> std::io::Result<()> { let is_standalone = get_standalone_and_act(); let (host, port) = get_host_and_port(); + HttpServer::new(move || App::new().configure(block_on(configurer(get_props(is_standalone))))) .bind((host, port))? .run() @@ -40,7 +42,7 @@ async fn main() { use std::net::SocketAddr; let is_standalone = get_standalone_and_act(); - let props = get_props(is_standalone); + let props = get_props(is_standalone).await; let (host, port) = get_host_and_port(); let addr: SocketAddr = format!("{}:{}", host, port) .parse() @@ -105,6 +107,8 @@ fn get_props(is_standalone: bool) -> ServerProps ServerProps() -> ErrorPages { let plugins = get_plugins::(); get_error_pages(&plugins) } + +pub fn get_global_state_creator(plugins: &Plugins) -> GlobalStateCreator { + // TODO Control action to override + app::get_global_state_creator() +} diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index 121f5252cf..d46c4f83d5 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -14,8 +14,8 @@ use perseus::{ templates::{RouterState, TemplateNodeType}, DomNode, }; -use std::cell::RefCell; use std::rc::Rc; +use std::{any::Any, cell::RefCell}; use sycamore::prelude::{cloned, create_effect, view, NodeRef, ReadSignal}; use sycamore_router::{HistoryIntegration, Router, RouterProps}; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; @@ -67,6 +67,9 @@ pub fn run() -> Result<(), JsValue> { let router_state = RouterState::default(); // Create a page state store to use let pss = PageStateStore::default(); + // Create a new global state set to `None`, which will be updated and handled entirely by the template macro from here on + let global_state: Rc>> = + Rc::new(RefCell::new(Box::new(Option::<()>::None))); // Create the router we'll use for this app, based on the user's app definition create_app_route! { @@ -90,7 +93,7 @@ pub fn run() -> Result<(), JsValue> { // Sycamore's reactivity is broken by a future, so we need to explicitly add the route to the reactive dependencies here // We do need the future though (otherwise `container_rx` doesn't link to anything until it's too late) let _ = route.get(); - wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, router_state, pss, translations_manager, error_pages, initial_container) => async move { + wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, router_state, pss, global_state, translations_manager, error_pages, initial_container) => async move { let container_rx_elem = container_rx.get::().unchecked_into::(); checkpoint("router_entry"); match &route.get().as_ref().0 { @@ -113,7 +116,8 @@ pub fn run() -> Result<(), JsValue> { error_pages: error_pages.clone(), initial_container: initial_container.unwrap().clone(), container_rx_elem: container_rx_elem.clone(), - page_state_store: pss.clone() + page_state_store: pss.clone(), + global_state: global_state.clone() } ).await, // If the user is using i18n, then they'll want to detect the locale on any paths missing a locale diff --git a/examples/rx_state/src/about.rs b/examples/rx_state/src/about.rs index bace105973..ebf740c29b 100644 --- a/examples/rx_state/src/about.rs +++ b/examples/rx_state/src/about.rs @@ -1,18 +1,18 @@ +use crate::global_state::AppState; use crate::index::IndexPropsRx; use perseus::{get_render_ctx, Html, Template}; -use sycamore::prelude::{component, view, Signal}; +use sycamore::prelude::{view, Signal}; use sycamore::view::View; // This template doesn't have any properties, so there's no point in using the special `template_with_rx_state` macro (but we could) -#[perseus::template(AboutPage)] -#[component(AboutPage)] +#[perseus::template_with_rx_state(component = "AboutPage", global_state = "AppState")] pub fn about_page() -> View { // Get the page state store manually - let pss = get_render_ctx!().page_state_store; + // The index page is just an empty string + let index_props_rx = get_render_ctx!().page_state_store.get::(""); // Get the state from the index page // If the user hasn't visited there yet, this won't exist - // The index page is just an empty string - let username = match pss.get::("") { + let username = match index_props_rx { Some(IndexPropsRx { username }) => username, None => Signal::new("".to_string()), }; diff --git a/examples/rx_state/src/global_state.rs b/examples/rx_state/src/global_state.rs new file mode 100644 index 0000000000..532a5c8cdc --- /dev/null +++ b/examples/rx_state/src/global_state.rs @@ -0,0 +1,17 @@ +use perseus::{state::GlobalStateCreator, RenderFnResult}; + +pub fn get_global_state_creator() -> GlobalStateCreator { + GlobalStateCreator::new().build_state_fn(get_build_state) +} + +#[perseus::make_rx(AppStateRx)] +pub struct AppState { + pub test: String, +} + +#[perseus::autoserde(global_build_state)] +pub async fn get_build_state() -> RenderFnResult { + Ok(AppState { + test: "Hello from the global state build process!".to_string(), + }) +} diff --git a/examples/rx_state/src/index.rs b/examples/rx_state/src/index.rs index 91275d1c12..f7397d2c4f 100644 --- a/examples/rx_state/src/index.rs +++ b/examples/rx_state/src/index.rs @@ -1,6 +1,8 @@ use perseus::{Html, RenderFnResultWithCause, Template}; use sycamore::prelude::*; +use crate::global_state::{AppState, AppStateRx}; + // We define a normal `struct` and then use `make_rx` (which derives `Serialize`, `Deserialize`, and `Clone` automatically) // This will generate a new `struct` called `IndexPropsRx` (as we asked it to), in which every field is made reactive with a `Signal` #[perseus::make_rx(IndexPropsRx)] @@ -11,13 +13,17 @@ pub struct IndexProps { // This special macro (normally we'd use `template(IndexProps)`) converts the state we generate elsewhere to a reactive version // We need to tell it the name of the unreactive properties we created to start with (unfortunately the compiler isn't smart enough to figure that out yet) // This will also add our reactive properties to the global state store, and, if they're already there, it'll use the existing one -#[perseus::template_with_rx_state(IndexPage, IndexProps)] -#[component(IndexPage)] -pub fn index_page(IndexPropsRx { username }: IndexPropsRx) -> View { +#[perseus::template_with_rx_state( + component = "IndexPage", + unrx_props = "IndexProps", + global_state = "AppState" +)] +pub fn index_page(IndexPropsRx { username }: IndexPropsRx, global_state: AppStateRx) -> View { let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released view! { p { (format!("Greetings, {}!", username.get())) } input(bind:value = username_2, placeholder = "Username") + p { (global_state.test.get()) } // When the user visits this and then comes back, they'll still be able to see their username (the previous state will be retrieved from the global state automatically) a(href = "about") { "About" } diff --git a/examples/rx_state/src/lib.rs b/examples/rx_state/src/lib.rs index d47bbd695a..7a2f38983e 100644 --- a/examples/rx_state/src/lib.rs +++ b/examples/rx_state/src/lib.rs @@ -1,7 +1,9 @@ mod about; +mod global_state; mod index; use perseus::define_app; + define_app! { templates: [ index::get_template::(), @@ -11,5 +13,6 @@ define_app! { sycamore::view! { p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } } - }) + }), + global_state_creator: global_state::get_global_state_creator() } diff --git a/examples/showcase/src/templates/router_state.rs b/examples/showcase/src/templates/router_state.rs index 0bf9f4ecc6..772d47453f 100644 --- a/examples/showcase/src/templates/router_state.rs +++ b/examples/showcase/src/templates/router_state.rs @@ -4,9 +4,7 @@ use sycamore::prelude::{cloned, component, create_memo, view, View}; #[perseus::template(RouterStatePage)] #[component(RouterStatePage)] pub fn router_state_page() -> View { - let load_state = sycamore::context::use_context::() - .router - .get_load_state(); + let load_state = perseus::get_render_ctx!().router.get_load_state(); let load_state_str = create_memo( cloned!(load_state => move || match (*load_state.get()).clone() { RouterLoadState::Loaded(name) => format!("Loaded {}.", name), diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index 612cb0e71d..5e5a76cfa2 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -41,6 +41,7 @@ pub async fn configurer, ) -> impl FnOnce(&mut actix_web::web::ServiceConfig) { let opts = Rc::new(opts); // TODO Find a more efficient way of doing this @@ -56,6 +57,12 @@ pub async fn configurer( req: HttpRequest, opts: web::Data>, @@ -44,6 +45,7 @@ pub async fn initial_load( immutable_store: web::Data, mutable_store: web::Data, translations_manager: web::Data, + global_state: web::Data>, ) -> HttpResponse { let templates = &opts.templates_map; let error_pages = &opts.error_pages; @@ -81,6 +83,7 @@ pub async fn initial_load( template, was_incremental_match, http_req, + &global_state, (immutable_store.get_ref(), mutable_store.get_ref()), translations_manager.get_ref(), ) @@ -96,7 +99,7 @@ pub async fn initial_load( let final_html = html_shell .get_ref() .clone() - .page_data(&page_data) + .page_data(&page_data, &global_state) .to_string(); let mut http_res = HttpResponse::Ok(); diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index c2f822ad45..b444b1132b 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -19,12 +19,14 @@ pub struct PageDataReq { } /// The handler for calls to `.perseus/page/*`. This will manage returning errors and the like. +#[allow(clippy::too_many_arguments)] pub async fn page_data( req: HttpRequest, opts: web::Data>, immutable_store: web::Data, mutable_store: web::Data, translations_manager: web::Data, + global_state: web::Data>, web::Query(query_params): web::Query, ) -> HttpResponse { let templates = &opts.templates_map; @@ -60,6 +62,7 @@ pub async fn page_data( template, was_incremental_match, http_req, + &global_state, (immutable_store.get_ref(), mutable_store.get_ref()), translations_manager.get_ref(), ) diff --git a/packages/perseus-macro/src/autoserde.rs b/packages/perseus-macro/src/autoserde.rs index 9a20ef867c..5a226795d0 100644 --- a/packages/perseus-macro/src/autoserde.rs +++ b/packages/perseus-macro/src/autoserde.rs @@ -19,6 +19,8 @@ pub struct AutoserdeArgs { set_headers: bool, #[darling(default)] amalgamate_states: bool, + #[darling(default)] + global_build_state: bool, } /// A function that can be wrapped in the Perseus test sub-harness. @@ -175,7 +177,26 @@ pub fn autoserde_impl(input: AutoserdeFn, fn_type: AutoserdeArgs) -> TokenStream amalgamated_state_with_str } } + } else if fn_type.global_build_state { + quote! { + #vis async fn #name() -> ::perseus::RenderFnResult<::std::string::String> { + // The user's function + // We can assume the return type to be `RenderFnResultWithCause` + #(#attrs)* + async fn #name#generics(#args) -> #return_type { + #block + } + // Call the user's function and then serialize the result to a string + // We only serialize the `Ok` outcome, errors are left as-is + // We also assume that this will serialize correctly + let build_state = #name().await; + let build_state_with_str = build_state.map(|val| ::serde_json::to_string(&val).unwrap()); + build_state_with_str + } + } } else { - todo!() + quote! { + compile_error!("function type not supported, must be one of: `build_state`, `request_state`, `set_headers`, `amalgamate_states`, or `global_build_state`") + } } } diff --git a/packages/perseus-macro/src/lib.rs b/packages/perseus-macro/src/lib.rs index 51f294f4ad..e5bcfc485b 100644 --- a/packages/perseus-macro/src/lib.rs +++ b/packages/perseus-macro/src/lib.rs @@ -30,6 +30,7 @@ mod autoserde; mod head; mod rx_state; mod template; +mod template2; mod test; use darling::FromMeta; @@ -77,12 +78,20 @@ pub fn template(args: TokenStream, input: TokenStream) -> TokenStream { /// /// Additionally, this macro will add the reactive state to the global state store, and will fetch it from there, allowing template state to persists between page changes. Additionally, /// that state can be accessed by other templates if necessary. +// TODO Rename this to `template2` and rewrite docs on it with examples #[proc_macro_attribute] pub fn template_with_rx_state(args: TokenStream, input: TokenStream) -> TokenStream { - let parsed = syn::parse_macro_input!(input as template::TemplateFn); + let parsed = syn::parse_macro_input!(input as template2::TemplateFn); let attr_args = syn::parse_macro_input!(args as syn::AttributeArgs); + // Parse macro arguments with `darling` + let args = match template2::TemplateArgs::from_list(&attr_args) { + Ok(v) => v, + Err(e) => { + return TokenStream::from(e.write_errors()); + } + }; - template::template_with_rx_state_impl(parsed, attr_args).into() + template2::template_impl(parsed, args).into() } /// Labels a function as a Perseus head function, which is very similar to a template, but diff --git a/packages/perseus-macro/src/template.rs b/packages/perseus-macro/src/template.rs index a4665d3fdf..1bba1a0804 100644 --- a/packages/perseus-macro/src/template.rs +++ b/packages/perseus-macro/src/template.rs @@ -1,11 +1,8 @@ -use darling::util::PathList; -use darling::FromMeta; use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use syn::parse::{Parse, ParseStream}; use syn::{ - Attribute, AttributeArgs, Block, FnArg, Generics, Ident, Item, ItemFn, NestedMeta, Result, - ReturnType, Type, Visibility, + Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, Result, ReturnType, Type, Visibility, }; /// A function that can be wrapped in the Perseus test sub-harness. @@ -152,98 +149,3 @@ pub fn template_impl(input: TemplateFn, component_name: Ident) -> TokenStream { } } } - -#[derive(FromMeta)] -pub struct TemplateWithRxStateArgs(pub PathList); - -pub fn template_with_rx_state_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream { - let TemplateFn { - block, - arg, - generics, - vis, - attrs, - name, - return_type, - } = input; - - // We want two arguments only - if attr_args.len() != 2 { - return quote!(compile_error!("this macro always takes two arguments")); - } - // This must always be provided - let component_name = match &attr_args[0] { - NestedMeta::Meta(meta) => meta.path().get_ident(), - nested_meta => { - return syn::Error::new_spanned( - nested_meta, - "first argument must be a component identifier", - ) - .to_compile_error() - } - }; - // As must this - let unrx_ty = match &attr_args[1] { - NestedMeta::Meta(meta) => meta.path().get_ident(), - nested_meta => { - return syn::Error::new_spanned( - nested_meta, - "second argument must be the identifier for your unreactive `struct`", - ) - .to_compile_error() - } - }; - - // We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed - // This is dependent on what arguments the template takes - if arg.is_some() { - // There's an argument that will be provided as a `String`, so the wrapper will deserialize it - // We'll also make it reactive and potentially add it to the global store - quote! { - #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { - // The user's function, with Sycamore component annotations and the like preserved - // We know this won't be async because Sycamore doesn't allow that - #(#attrs)* - fn #name#generics(#arg) -> #return_type { - #block - } - ::sycamore::prelude::view! { - #component_name( - { - // Check if properties of the reactive type are already in the page state store - // If they are, we'll use them (so state persists for templates across the whole app) - // TODO Isolate this for pages - let mut pss = ::perseus::get_render_ctx!().page_state_store; - match pss.get(&props.path) { - Some(old_state) => old_state, - None => { - // If there are props, they will always be provided, the compiler just doesn't know that - // If the user is using this macro, they sure should be using `#[make_rx(...)]` or similar! - let rx_props = ::serde_json::from_str::<#unrx_ty>(&props.state.unwrap()).unwrap().make_rx(); - // They aren't in there, so insert them - pss.add(&props.path, rx_props.clone()); - rx_props - } - } - } - ) - } - } - } - } else { - // There are no arguments - quote! { - #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { - // The user's function, with Sycamore component annotations and the like preserved - // We know this won't be async because Sycamore doesn't allow that - #(#attrs)* - fn #name#generics(#arg) -> #return_type { - #block - } - ::sycamore::prelude::view! { - #component_name() - } - } - } - } -} diff --git a/packages/perseus-macro/src/template2.rs b/packages/perseus-macro/src/template2.rs new file mode 100644 index 0000000000..6a7d2d4813 --- /dev/null +++ b/packages/perseus-macro/src/template2.rs @@ -0,0 +1,290 @@ +use darling::FromMeta; +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{ + Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, PatType, Result, ReturnType, Type, + Visibility, +}; + +/// A function that can be wrapped in the Perseus test sub-harness. +pub struct TemplateFn { + /// The body of the function. + pub block: Box, + /// The arguments for custom properties and a global state, both of which are optional. (But global state needs custom properties, which can be a dummy `struct`.) + pub args: Vec, + /// The visibility of the function. + pub vis: Visibility, + /// Any attributes the function uses. + pub attrs: Vec, + /// The actual name of the function. + pub name: Ident, + /// The return type of the function. + pub return_type: Box, + /// Any generics the function takes (should be one for the Sycamore `GenericNode`). + pub generics: Generics, +} +impl Parse for TemplateFn { + fn parse(input: ParseStream) -> Result { + let parsed: Item = input.parse()?; + + match parsed { + Item::Fn(func) => { + let ItemFn { + attrs, + vis, + sig, + block, + } = func; + // Validate each part of this function to make sure it fulfills the requirements + // Mustn't be async + if sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + sig.asyncness, + "templates cannot be asynchronous", + )); + } + // Can't be const + if sig.constness.is_some() { + return Err(syn::Error::new_spanned( + sig.constness, + "const functions can't be used as templates", + )); + } + // Can't be external + if sig.abi.is_some() { + return Err(syn::Error::new_spanned( + sig.abi, + "external functions can't be used as templates", + )); + } + // Must have an explicit return type + let return_type = match sig.output { + ReturnType::Default => { + return Err(syn::Error::new_spanned( + sig, + "template functions can't return default/inferred type", + )) + } + ReturnType::Type(_, ty) => ty, + }; + let mut args = Vec::new(); + for arg in sig.inputs.iter() { + // We don't care what the type is, as long as it's not `self` + if let FnArg::Receiver(arg) = arg { + return Err(syn::Error::new_spanned(arg, "templates can't take `self`")); + } + args.push(arg.clone()) + } + // We can have anywhere between 0 and 2 arguments + if args.len() > 2 { + return Err(syn::Error::new_spanned(&args[2], "template functions accept a maximum of two arguments (one for custom properties and antoher for global state, both optional)")); + } + + Ok(Self { + block, + args, + vis, + attrs, + name: sig.ident, + return_type, + generics: sig.generics, + }) + } + item => Err(syn::Error::new_spanned( + item, + "only funtions can be used as templates", + )), + } + } +} + +#[derive(FromMeta)] +pub struct TemplateArgs { + /// The name of the component. + component: Ident, + /// The name of the type parameter to use (default to `G`). + #[darling(default)] + type_param: Option, + // /// The identifier of the global state type, if there is one. + // #[darling(default)] + // global_state: Option, + /// The identifier of the global state. + global_state: Ident, + /// The name of the unreactive properties, if there are any. + #[darling(default)] + unrx_props: Option, +} + +pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream { + let TemplateFn { + block, + // We know that these are all typed (none are `self`) + args: fn_args, + generics, + vis, + attrs, + name, + return_type, + } = input; + + let component_name = &args.component; + let global_state = &args.global_state; + let type_param = match &args.type_param { + Some(type_param) => type_param.clone(), + None => Ident::new("G", Span::call_site()), + }; + // This is only optional if the second argument wasn't provided + // let global_state = if fn_args.len() == 2 { + // match &args.global_state { + // Some(global_state) => global_state.clone(), + // None => return syn::Error::new_spanned(&fn_args[0], "template functions with two arguments must declare their global state type (`global_state = `)").to_compile_error() + // } + // } else { + // match &args.global_state { + // Some(global_state) => global_state.clone(), + // None => Ident::new("Dummy", Span::call_site()), + // } + // }; + // This is only optional if the first argument wasn't provided + let unrx_props = if !fn_args.is_empty() { + match &args.unrx_props { + Some(unrx_props) => unrx_props.clone(), + None => return syn::Error::new_spanned(&fn_args[0], "template functions with one argument or more must declare their unreactive properties type (`unrx_props = `)").to_compile_error() + } + } else { + match &args.unrx_props { + Some(unrx_props) => unrx_props.clone(), + None => Ident::new("Dummy", Span::call_site()), + } + }; + + let manage_global_state = quote! { + // Deserialize the global state, make it reactive, and register it with the `RenderCtx` + // If it's already there, we'll leave it + // This means that we can pass an `Option` around safely and then deal with it at the template site + let global_state_refcell = ::perseus::get_render_ctx!().global_state; + let global_state = global_state_refcell.borrow(); + if (&global_state).downcast_ref::<::std::option::Option::<()>>().is_some() { + // We can downcast it as the type set by the core render system, so we're the first page to be loaded + // In that case, we'll set the global state properly + drop(global_state); + let mut global_state = global_state_refcell.borrow_mut(); + // This will be defined if we're the first page + let global_state_props = &props.global_state.unwrap(); + let new_global_state = ::serde_json::from_str::<#global_state>(global_state_props).unwrap().make_rx(); + *global_state = ::std::boxed::Box::new(new_global_state); + // The component function can now access this in `RenderCtx` + } + }; + + // We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed + // This is dependent on what arguments the template takes + if fn_args.len() == 2 { + // There's an argument for page properties that needs to have state extracted, so the wrapper will deserialize it + // We'll also make it reactive and add it to the page state store + let state_arg = &fn_args[0]; + // There's also a second argument for the global state, which we'll deserialize and make global if it's not already (aka. if any other pages have loaded before this one) + // Sycamore won't let us have more than one argument to a component though, so we sneakily extract it and literally construct it as a variable (this should be fine?) + let global_state_arg = &fn_args[1]; + let (global_state_arg_pat, global_state_rx) = match global_state_arg { + FnArg::Typed(PatType { pat, ty, .. }) => (pat, ty), + FnArg::Receiver(_) => unreachable!(), + }; + quote! { + #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { + #manage_global_state + // The user's function + // We know this won't be async because Sycamore doesn't allow that + #(#attrs)* + #[::sycamore::component(#component_name<#type_param>)] + fn #name#generics(#state_arg) -> #return_type { + let #global_state_arg_pat: #global_state_rx = { + let global_state = ::perseus::get_render_ctx!().global_state; + let global_state = global_state.borrow(); + // We can guarantee that it will downcast correctly now, because we'll only invoke the component from this function, which sets up the global state correctly + let global_state_ref = (&global_state).downcast_ref::<#global_state_rx>().unwrap(); + (*global_state_ref).clone() + }; + #block + } + ::sycamore::prelude::view! { + #component_name( + { + // Check if properties of the reactive type are already in the page state store + // If they are, we'll use them (so state persists for templates across the whole app) + let mut pss = ::perseus::get_render_ctx!().page_state_store; + match pss.get(&props.path) { + ::std::option::Option::Some(old_state) => old_state, + ::std::option::Option::None => { + // If there are props, they will always be provided, the compiler just doesn't know that + // If the user is using this macro, they sure should be using `#[make_rx(...)]` or similar! + let rx_props = ::serde_json::from_str::<#unrx_props>(&props.state.unwrap()).unwrap().make_rx(); + // They aren't in there, so insert them + pss.add(&props.path, rx_props.clone()); + rx_props + } + } + } + ) + } + } + } + } else if fn_args.len() == 1 { + // There's an argument for page properties that needs to have state extracted, so the wrapper will deserialize it + // We'll also make it reactive and add it to the page state store + let arg = &fn_args[0]; + quote! { + #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { + #manage_global_state + // The user's function, with Sycamore component annotations and the like preserved + // We know this won't be async because Sycamore doesn't allow that + #(#attrs)* + #[::sycamore::component(#component_name<#type_param>)] + fn #name#generics(#arg) -> #return_type { + #block + } + ::sycamore::prelude::view! { + #component_name( + { + // Check if properties of the reactive type are already in the page state store + // If they are, we'll use them (so state persists for templates across the whole app) + let mut pss = ::perseus::get_render_ctx!().page_state_store; + match pss.get(&props.path) { + ::std::option::Option::Some(old_state) => old_state, + ::std::option::Option::None => { + // If there are props, they will always be provided, the compiler just doesn't know that + // If the user is using this macro, they sure should be using `#[make_rx(...)]` or similar! + let rx_props = ::serde_json::from_str::<#unrx_props>(&props.state.unwrap()).unwrap().make_rx(); + // They aren't in there, so insert them + pss.add(&props.path, rx_props.clone()); + rx_props + } + } + } + ) + } + } + } + } else if fn_args.is_empty() { + // There are no arguments + quote! { + #vis fn #name(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View { + #manage_global_state + // The user's function, with Sycamore component annotations and the like preserved + // We know this won't be async because Sycamore doesn't allow that + #(#attrs)* + #[::sycamore::component(#component_name<#type_param>)] + fn #name#generics() -> #return_type { + #block + } + ::sycamore::prelude::view! { + #component_name() + } + } + } + } else { + // We filtered out this possibility in the function parsing + unreachable!() + } +} diff --git a/packages/perseus-warp/src/initial_load.rs b/packages/perseus-warp/src/initial_load.rs index dc04c42dca..fa11635192 100644 --- a/packages/perseus-warp/src/initial_load.rs +++ b/packages/perseus-warp/src/initial_load.rs @@ -42,6 +42,7 @@ pub async fn initial_load_handler( immutable_store: Arc, mutable_store: Arc, translations_manager: Arc, + global_state: Arc>, ) -> Response { let path = path.as_str(); let templates = &opts.templates_map; @@ -70,6 +71,7 @@ pub async fn initial_load_handler( template, was_incremental_match, req, + &global_state, (immutable_store.as_ref(), mutable_store.as_ref()), translations_manager.as_ref(), ) @@ -85,7 +87,7 @@ pub async fn initial_load_handler( let final_html = html_shell .as_ref() .clone() - .page_data(&page_data) + .page_data(&page_data, &global_state) .to_string(); let mut http_res = Response::builder().status(200); diff --git a/packages/perseus-warp/src/page_data.rs b/packages/perseus-warp/src/page_data.rs index 8f6e1766f3..2ad1fd71f2 100644 --- a/packages/perseus-warp/src/page_data.rs +++ b/packages/perseus-warp/src/page_data.rs @@ -32,6 +32,7 @@ pub async fn page_handler( immutable_store: Arc, mutable_store: Arc, translations_manager: Arc, + global_state: Arc>, ) -> Response { let templates = &opts.templates_map; // Check if the locale is supported @@ -56,6 +57,7 @@ pub async fn page_handler( template, was_incremental_match, http_req, + &global_state, (immutable_store.as_ref(), mutable_store.as_ref()), translations_manager.as_ref(), ) diff --git a/packages/perseus-warp/src/perseus_routes.rs b/packages/perseus-warp/src/perseus_routes.rs index 6319c1f268..f41829eeac 100644 --- a/packages/perseus-warp/src/perseus_routes.rs +++ b/packages/perseus-warp/src/perseus_routes.rs @@ -22,6 +22,7 @@ pub async fn perseus_routes, ) -> impl Filter + Clone { let render_cfg = get_render_cfg(&immutable_store) @@ -34,6 +35,11 @@ pub async fn perseus_routes, translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), + global_state: &Option, exporting: bool, ) -> Result<(Vec, bool), ServerError> { let mut single_page = false; @@ -67,7 +68,13 @@ pub async fn build_template( // Note that build paths pages on incrementally generable pages will use the immutable store let mut futs = Vec::new(); for path in paths.iter() { - let fut = gen_state_for_path(path, template, translator, (immutable_store, mutable_store)); + let fut = gen_state_for_path( + path, + template, + translator, + (immutable_store, mutable_store), + global_state, + ); futs.push(fut); } try_join_all(futs).await?; @@ -81,6 +88,7 @@ async fn gen_state_for_path( template: &Template, translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), + global_state: &Option, ) -> Result<(), ServerError> { let template_path = template.get_path(); // If needed, we'll contruct a full path that's URL encoded so we can easily save it as a file @@ -124,13 +132,16 @@ async fn gen_state_for_path( &initial_state, ) .await?; + // Assemble the page properties + let page_props = PageProps { + path: full_path_with_locale.clone(), + state: Some(initial_state), + global_state: global_state.clone(), + }; // Prerender the template using that state let prerendered = sycamore::render_to_string(|| { - template.render_for_template( - PageProps { - path: full_path_with_locale.clone(), - state: Some(initial_state.clone()), - }, + template.render_for_template_server( + page_props.clone(), translator, true, RouterState::default(), @@ -143,13 +154,7 @@ async fn gen_state_for_path( .await?; // Prerender the document `` with that state // If the page also uses request state, amalgamation will be applied as for the normal content - let head_str = template.render_head_str( - PageProps { - path: full_path_with_locale.clone(), - state: Some(initial_state.clone()), - }, - translator, - ); + let head_str = template.render_head_str(page_props, translator); mutable_store .write( &format!("static/{}.head.html", full_path_encoded), @@ -168,13 +173,16 @@ async fn gen_state_for_path( &initial_state, ) .await?; + // Assemble the page properties + let page_props = PageProps { + path: full_path_with_locale.clone(), + state: Some(initial_state), + global_state: global_state.clone(), + }; // Prerender the template using that state let prerendered = sycamore::render_to_string(|| { - template.render_for_template( - PageProps { - path: full_path_with_locale.clone(), - state: Some(initial_state.clone()), - }, + template.render_for_template_server( + page_props.clone(), translator, true, RouterState::default(), @@ -187,13 +195,7 @@ async fn gen_state_for_path( .await?; // Prerender the document `` with that state // If the page also uses request state, amalgamation will be applied as for the normal content - let head_str = template.render_head_str( - PageProps { - path: full_path_with_locale.clone(), - state: Some(initial_state), - }, - translator, - ); + let head_str = template.render_head_str(page_props, translator); immutable_store .write( &format!("static/{}.head.html", full_path_encoded), @@ -223,25 +225,22 @@ async fn gen_state_for_path( // If the template is very basic, prerender without any state // It's safe to add a property to the render options here because `.is_basic()` will only return true if path generation is not being used (or anything else) if template.is_basic() { + // Assemble the page properties + let page_props = PageProps { + path: full_path_with_locale, + state: None, + global_state: global_state.clone(), + }; let prerendered = sycamore::render_to_string(|| { - template.render_for_template( - PageProps { - path: full_path_with_locale.clone(), - state: None, - }, + template.render_for_template_server( + page_props.clone(), translator, true, RouterState::default(), PageStateStore::default(), ) }); - let head_str = template.render_head_str( - PageProps { - path: full_path_with_locale, - state: None, - }, - translator, - ); + let head_str = template.render_head_str(page_props, translator); // Write that prerendered HTML to a static file immutable_store .write(&format!("static/{}.html", full_path_encoded), &prerendered) @@ -261,6 +260,7 @@ async fn build_template_and_get_cfg( template: &Template, translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), + global_state: &Option, exporting: bool, ) -> Result, ServerError> { let mut render_cfg = HashMap::new(); @@ -271,6 +271,7 @@ async fn build_template_and_get_cfg( template, translator, (immutable_store, mutable_store), + global_state, exporting, ) .await?; @@ -307,6 +308,7 @@ pub async fn build_templates_for_locale( templates: &TemplateMap, translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), + global_state: &Option, exporting: bool, ) -> Result<(), ServerError> { // The render configuration stores a list of pages to the root paths of their templates @@ -318,6 +320,7 @@ pub async fn build_templates_for_locale( template, translator, (immutable_store, mutable_store), + global_state, exporting, )); } @@ -342,6 +345,7 @@ async fn build_templates_and_translator_for_locale( locale: String, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, + global_state: &Option, exporting: bool, ) -> Result<(), ServerError> { let translator = translations_manager @@ -351,6 +355,7 @@ async fn build_templates_and_translator_for_locale( templates, &translator, (immutable_store, mutable_store), + global_state, exporting, ) .await?; @@ -365,6 +370,7 @@ pub async fn build_app( locales: &Locales, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, + global_state: &Option, exporting: bool, ) -> Result<(), ServerError> { let locales = locales.get_all(); @@ -376,6 +382,7 @@ pub async fn build_app( locale.to_string(), (immutable_store, mutable_store), translations_manager, + global_state, exporting, )); } diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 1e94440341..ef4e419ee6 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -38,6 +38,8 @@ pub enum ServerError { source: Box, }, #[error(transparent)] + GlobalStateError(#[from] GlobalStateError), + #[error(transparent)] StoreError(#[from] StoreError), #[error(transparent)] TranslationsManagerError(#[from] TranslationsManagerError), @@ -62,6 +64,16 @@ pub fn err_to_status_code(err: &ServerError) -> u16 { } } +/// Errors that can occur with regards to global state. +#[derive(Error, Debug)] +pub enum GlobalStateError { + #[error("couldn't generate global state at build time")] + BuildGenerationFailed { + #[source] + source: Box, + }, +} + /// Errors that can occur while reading from or writing to a mutable or immutable store. #[derive(Error, Debug)] pub enum StoreError { diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index 129e2c435e..fe1a767a0a 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -42,6 +42,7 @@ async fn get_static_page_data( /// Exports your app to static files, which can be served from anywhere, without needing a server. This assumes that the app has already /// been built, and that no templates are using non-static features (which can be ensured by passing `true` as the last parameter to /// `build_app`). +#[allow(clippy::too_many_arguments)] pub async fn export_app( templates: &TemplateMap, html_shell_path: &str, @@ -50,6 +51,7 @@ pub async fn export_app( immutable_store: &ImmutableStore, translations_manager: &impl TranslationsManager, path_prefix: String, + global_state: &Option, ) -> 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?; @@ -72,6 +74,7 @@ pub async fn export_app( &html_shell, immutable_store, path_prefix.to_string(), + global_state, ); export_futs.push(fut); } @@ -119,6 +122,7 @@ async fn export_path( html_shell: &HtmlShell<'_>, immutable_store: &ImmutableStore, path_prefix: String, + global_state: &Option, ) -> Result<(), ServerError> { // We need the encoded path to reference flattened build artifacts // But we don't create a flattened system with exporting, everything is properly created in a directory structure @@ -173,7 +177,10 @@ async fn export_path( .await?; // Create a full HTML file from those that can be served for initial loads // The build process writes these with a dummy default locale even though we're not using i18n - let full_html = html_shell.clone().page_data(&page_data).to_string(); + let full_html = html_shell + .clone() + .page_data(&page_data, global_state) + .to_string(); immutable_store .write( &format!("exported/{}/{}.html", locale, initial_load_path), @@ -199,7 +206,10 @@ async fn export_path( .await?; // Create a full HTML file from those that can be served for initial loads // The build process writes these with a dummy default locale even though we're not using i18n - let full_html = html_shell.clone().page_data(&page_data).to_string(); + let full_html = html_shell + .clone() + .page_data(&page_data, global_state) + .to_string(); // We don't add an extension because this will be queried directly by the browser immutable_store .write(&format!("exported/{}.html", initial_load_path), &full_html) diff --git a/packages/perseus/src/global_state.rs b/packages/perseus/src/global_state.rs new file mode 100644 index 0000000000..4ea118dca4 --- /dev/null +++ b/packages/perseus/src/global_state.rs @@ -0,0 +1,50 @@ +use crate::errors::*; +use crate::make_async_trait; +use crate::template::{AsyncFnReturn, RenderFnResult}; +use futures::Future; + +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; + +/// 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)] +pub struct GlobalStateCreator { + /// The function that creates state at build-time. This is roughly equivalent to the *build state* strategy for templates. + build: Option, +} +impl GlobalStateCreator { + /// Creates a new instance of this `struct`. + pub fn new() -> Self { + Self::default() + } + /// Adds a function to generate global state at build-time. + #[allow(unused_mut)] + #[allow(unused_variables)] + pub fn build_state_fn( + mut self, + val: impl GlobalStateCreatorFnType + Send + Sync + 'static, + ) -> Self { + #[cfg(feature = "server-side")] + { + self.build = Some(Box::new(val)); + } + self + } + /// Gets the global state at build-time. If no function was registered to this, we'll return `None`. + pub async fn get_build_state(&self) -> Result, GlobalStateError> { + if let Some(get_server_state) = &self.build { + let res = get_server_state.call().await; + match res { + Ok(res) => Ok(Some(res)), + Err(err) => Err(GlobalStateError::BuildGenerationFailed { source: err }), + } + } else { + Ok(None) + } + } +} diff --git a/packages/perseus/src/html_shell.rs b/packages/perseus/src/html_shell.rs index 9acdf7fca6..b920e7638c 100644 --- a/packages/perseus/src/html_shell.rs +++ b/packages/perseus/src/html_shell.rs @@ -94,8 +94,8 @@ impl<'a> HtmlShell<'a> { } } - /// Interpolates page data into the shell. - pub fn page_data(mut self, page_data: &'a PageData) -> Self { + /// Interpolates page data and global state into the shell. + pub fn page_data(mut self, page_data: &'a PageData, global_state: &Option) -> Self { // Interpolate a global variable of the state so the app shell doesn't have to make any more trips // The app shell will unset this after usage so it doesn't contaminate later non-initial loads // Error pages (above) will set this to `error` @@ -104,10 +104,17 @@ impl<'a> HtmlShell<'a> { } else { "None".to_string() }; + let global_state = if let Some(state) = global_state { + escape_page_data(state) + } else { + "None".to_string() + }; - // We put this at the very end of the head (after the delimiter comment) because it doesn't matter if it's expunged on subsequent loads - let initial_state = format!("window.__PERSEUS_INITIAL_STATE = `{}`", initial_state); + // We put these at the very end of the head (after the delimiter comment) because it doesn't matter if they're expunged on subsequent loads + let initial_state = format!("window.__PERSEUS_INITIAL_STATE = `{}`;", initial_state); self.scripts_after_boundary.push(initial_state.into()); + let global_state = format!("window.__PERSEUS_GLOBAL_STATE = `{}`;", global_state); + self.scripts_after_boundary.push(global_state.into()); // Interpolate the document `` (this should of course be removed between page loads) self.head_after_boundary.push((&page_data.head).into()); // And set the content diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index b33f2741c6..855ceeed4d 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -48,6 +48,7 @@ mod decode_time_str; mod default_headers; mod error_pages; mod export; +mod global_state; mod html_shell; mod locale_detector; mod locales; @@ -98,6 +99,7 @@ pub mod templates { // TODO (v0.4.0) Refactor to put several more things inside here (everything to do with generation functions) /// Utilities for working with state. pub mod state { + pub use crate::global_state::GlobalStateCreator; pub use crate::page_state_store::PageStateStore; } /// A series of exports that should be unnecessary for nearly all uses of Perseus. These are used principally in developing alternative diff --git a/packages/perseus/src/macros.rs b/packages/perseus/src/macros.rs index a6175a15ec..226514f657 100644 --- a/packages/perseus/src/macros.rs +++ b/packages/perseus/src/macros.rs @@ -35,6 +35,22 @@ macro_rules! define_get_mutable_store { } }; } +/// An internal macro used for defining the global state creator. +// TODO Plugin extensibility? +#[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)] @@ -180,7 +196,7 @@ macro_rules! define_plugins { /// get all the dependencies without driving the user's `Cargo.toml` nuts). This also defines the template map. This is intended to make /// compatibility with the Perseus CLI significantly easier. /// -/// Warning: all properties must currently be in the correct order (`root`, `templates`, `error_pages`, `locales`, `static_aliases`, +/// 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`). #[macro_export] macro_rules! define_app { @@ -191,6 +207,7 @@ macro_rules! define_app { $($template:expr),+ ], error_pages: $error_pages:expr, + $(global_state_creator: $global_state_creator:expr,)? // This deliberately enforces verbose i18n definition, and forces developers to consider i18n as integral locales: { default: $default_locale:literal, @@ -213,6 +230,7 @@ macro_rules! define_app { $($template),+ ], error_pages: $error_pages, + $(global_state_creator: $global_state_creator,)? locales: { default: $default_locale, // The user doesn't have to define any other locales (but they'll still get locale detection and the like) @@ -235,6 +253,7 @@ macro_rules! define_app { $($template:expr),+ ], error_pages: $error_pages:expr + $(,global_state_creator: $global_state_creator:expr)? $(,static_aliases: { $($url:literal => $resource:literal),* })? @@ -250,6 +269,7 @@ macro_rules! define_app { $($template),+ ], error_pages: $error_pages, + $(global_state_creator: $global_state_creator,)? // This deliberately enforces verbose i18n definition, and forces developers to consider i18n as integral locales: { default: "xx-XX", @@ -274,6 +294,7 @@ macro_rules! define_app { $($template:expr),+ ], error_pages: $error_pages:expr, + $(global_state_creator: $global_state_creator:expr,)? // This deliberately enforces verbose i18n definition, and forces developers to consider i18n as integral locales: { default: $default_locale:literal, @@ -325,6 +346,9 @@ macro_rules! define_app { })? ); + /// Gets the global state creator for the app. + $crate::define_get_global_state_creator!($($global_state_creator)?); + /// 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![ diff --git a/packages/perseus/src/server/options.rs b/packages/perseus/src/server/options.rs index e7984acaa7..24039c26e5 100644 --- a/packages/perseus/src/server/options.rs +++ b/packages/perseus/src/server/options.rs @@ -1,5 +1,6 @@ use crate::error_pages::ErrorPages; use crate::locales::Locales; +use crate::state::GlobalStateCreator; use crate::stores::{ImmutableStore, MutableStore}; use crate::template::ArcTemplateMap; use crate::translations_manager::TranslationsManager; @@ -45,4 +46,6 @@ pub struct ServerProps { pub mutable_store: M, /// A translations manager to use. pub translations_manager: T, + /// The global state creator. This is used to avoid issues with `async` and cloning in Actix Web. + pub global_state_creator: GlobalStateCreator, } diff --git a/packages/perseus/src/server/render.rs b/packages/perseus/src/server/render.rs index f1fd28b7b1..7d8862a086 100644 --- a/packages/perseus/src/server/render.rs +++ b/packages/perseus/src/server/render.rs @@ -75,6 +75,7 @@ async fn render_request_state( template: &Template, translator: &Translator, path: &str, + global_state: &Option, req: Request, ) -> Result<(String, String, Option), ServerError> { let path_with_locale = get_path_with_locale(path, translator); @@ -84,26 +85,23 @@ async fn render_request_state( .get_request_state(path.to_string(), translator.get_locale(), req) .await?, ); + // Assemble the page properties + let page_props = PageProps { + path: path_with_locale, + state: state.clone(), + global_state: global_state.clone(), + }; // Use that to render the static HTML let html = sycamore::render_to_string(|| { - template.render_for_template( - PageProps { - path: path_with_locale.clone(), - state: state.clone(), - }, + template.render_for_template_server( + page_props.clone(), translator, true, RouterState::default(), PageStateStore::default(), ) }); - let head = template.render_head_str( - PageProps { - path: path_with_locale, - state: state.clone(), - }, - translator, - ); + let head = template.render_head_str(page_props, translator); Ok((html, head, state)) } @@ -174,6 +172,7 @@ async fn revalidate( translator: &Translator, path: &str, path_encoded: &str, + global_state: &Option, mutable_store: &impl MutableStore, ) -> Result<(String, String, Option), ServerError> { let path_with_locale = get_path_with_locale(path, translator); @@ -186,25 +185,22 @@ async fn revalidate( ) .await?, ); + // Assemble the page properties + let page_props = PageProps { + path: path_with_locale, + state: state.clone(), + global_state: global_state.clone(), + }; let html = sycamore::render_to_string(|| { - template.render_for_template( - PageProps { - path: path_with_locale.clone(), - state: state.clone(), - }, + template.render_for_template_server( + page_props.clone(), translator, true, RouterState::default(), PageStateStore::default(), ) }); - let head = template.render_head_str( - PageProps { - path: path_with_locale, - state: state.clone(), - }, - translator, - ); + let head = template.render_head_str(page_props, translator); // Handle revalidation, we need to parse any given time strings into datetimes // We don't need to worry about revalidation that operates by logic, that's request-time only if template.revalidates_with_time() { @@ -238,6 +234,7 @@ async fn revalidate( /// can avoid an unnecessary lookup if you already know the template in full (e.g. initial load server-side routing). Because this /// handles templates with potentially revalidation and incremental generation, it uses both mutable and immutable stores. // TODO possible further optimizations on this for futures? +#[allow(clippy::too_many_arguments)] pub async fn get_page_for_template( // This must not contain the locale raw_path: &str, @@ -246,6 +243,7 @@ pub async fn get_page_for_template( // This allows us to differentiate pages for incrementally generated templates that were pre-rendered with build paths (and are in the immutable store) from those generated and cached at runtime (in the mutable store) was_incremental_match: bool, req: Request, + global_state: &Option, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, ) -> Result { @@ -282,9 +280,15 @@ pub async fn get_page_for_template( Some((html_val, head_val)) => { // Check if we need to revalidate if should_revalidate(template, &path_encoded, mutable_store).await? { - let (html_val, head_val, state) = - revalidate(template, &translator, path, &path_encoded, mutable_store) - .await?; + let (html_val, head_val, state) = revalidate( + template, + &translator, + path, + &path_encoded, + global_state, + mutable_store, + ) + .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val; @@ -316,25 +320,22 @@ pub async fn get_page_for_template( .get_build_state(path.to_string(), locale.to_string()) .await?, ); + // Assemble the page properties + let page_props = PageProps { + path: path_with_locale, + state: state.clone(), + global_state: global_state.clone(), + }; let html_val = sycamore::render_to_string(|| { - template.render_for_template( - PageProps { - path: path_with_locale.clone(), - state: state.clone(), - }, + template.render_for_template_server( + page_props.clone(), &translator, true, RouterState::default(), PageStateStore::default(), ) }); - let head_val = template.render_head_str( - PageProps { - path: path_with_locale.clone(), - state: state.clone(), - }, - &translator, - ); + let head_val = template.render_head_str(page_props, &translator); // Handle revalidation, we need to parse any given time strings into datetimes // We don't need to worry about revalidation that operates by logic, that's request-time only // Obviously we don't need to revalidate now, we just created it @@ -379,8 +380,15 @@ pub async fn get_page_for_template( // Handle if we need to revalidate // It'll be in the mutable store if we do if should_revalidate(template, &path_encoded, mutable_store).await? { - let (html_val, head_val, state) = - revalidate(template, &translator, path, &path_encoded, mutable_store).await?; + let (html_val, head_val, state) = revalidate( + template, + &translator, + path, + &path_encoded, + global_state, + mutable_store, + ) + .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val; @@ -414,7 +422,7 @@ pub async fn get_page_for_template( // Handle request state if template.uses_request_state() { let (html_val, head_val, state) = - render_request_state(template, &translator, path, req).await?; + render_request_state(template, &translator, path, global_state, req).await?; // Request-time HTML always overrides anything generated at build-time or incrementally (this has more information) html = html_val; head = head_val; @@ -446,6 +454,7 @@ pub async fn get_page_for_template( /// Gets the HTML/JSON data for the given page path. This will call SSG/SSR/etc., whatever is needed for that page. Note that HTML /// generated at request-time will **always** replace anything generated at build-time, incrementally, revalidated, etc. +#[allow(clippy::too_many_arguments)] pub async fn get_page( // This must not contain the locale raw_path: &str, @@ -453,6 +462,7 @@ pub async fn get_page( (template_name, was_incremental_match): (&str, bool), req: Request, templates: &TemplateMap, + global_state: &Option, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), translations_manager: &impl TranslationsManager, ) -> Result { @@ -480,6 +490,7 @@ pub async fn get_page( template, was_incremental_match, req, + global_state, (immutable_store, mutable_store), translations_manager, ) diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index bcc12ef49e..a6a3e4f2a2 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -8,6 +8,7 @@ use crate::template::Template; use crate::templates::{PageProps, RouterLoadState, RouterState, TemplateNodeType}; use crate::ErrorPages; use fmterr::fmt_err; +use std::any::Any; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -129,6 +130,25 @@ pub fn get_initial_state() -> InitialState { } } +/// Gets the global state injected by the server, if there was any. If there are errors in this, we can return `None` and not worry about it, they'll be handled by the initial state. +pub fn get_global_state() -> Option { + let val_opt = web_sys::window().unwrap().get("__PERSEUS_GLOBAL_STATE"); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return None, + }; + // The object should only actually contain the string value that was injected + let state_str = match js_obj.as_string() { + Some(state_str) => state_str, + None => return None, + }; + // On the server-side, we encode a `None` value directly (otherwise it will be some convoluted stringified JSON) + match state_str.as_str() { + "None" => None, + state_str => Some(state_str.to_string()), + } +} + /// Marks a checkpoint in the code and alerts any tests that it's been reached by creating an element that represents it. The preferred /// solution would be emitting a DOM event, but the WebDriver specification currently doesn't support waiting on those (go figure). This /// will only create a custom element if the `__PERSEUS_TESTING` JS global variable is set to `true`. @@ -234,6 +254,8 @@ pub struct ShellProps { pub initial_container: Element, /// The container for reactive content. pub container_rx_elem: Element, + /// The global state store. Brekaing it out here prevents it being overriden every time a new template loads. + pub global_state: Rc>>, } /// Fetches the information for the given page and renders it. This should be provided the actual path of the page to render (not just the @@ -251,6 +273,7 @@ pub async fn app_shell( error_pages, initial_container, container_rx_elem, + global_state: curr_global_state, }: ShellProps, ) { checkpoint("app_shell_entry"); @@ -260,6 +283,9 @@ pub async fn app_shell( }; // Update the router state router_state.set_load_state(RouterLoadState::Loading(template.get_path())); + // Get the global state if possible (we'll want this in all cases except errors) + // If this is a subsequent load, the template macro will have already set up the global state, and it will ignore whatever we naively give it (so we'll give it `None`) + let global_state = get_global_state(); // Check if this was an initial load and we already have the state let initial_state = get_initial_state(); match initial_state { @@ -275,6 +301,13 @@ pub async fn app_shell( &JsValue::undefined(), ) .unwrap(); + // Also do this for the global state + Reflect::set( + &JsValue::from(web_sys::window().unwrap()), + &JsValue::from("__PERSEUS_GLOBAL_STATE"), + &JsValue::undefined(), + ) + .unwrap(); // We need to move the server-rendered content from its current container to the reactive container (otherwise Sycamore can't work with it properly) let initial_html = initial_container.inner_html(); container_rx_elem.set_inner_html(&initial_html); @@ -313,6 +346,7 @@ pub async fn app_shell( let page_props = PageProps { path: path_with_locale, state, + global_state, }; #[cfg(not(feature = "hydrate"))] { @@ -320,12 +354,13 @@ pub async fn app_shell( container_rx_elem.set_inner_html(""); sycamore::render_to( move || { - template.render_for_template( + template.render_for_template_client( page_props, translator, false, router_state_2, page_state_store, + curr_global_state, ) }, &container_rx_elem, @@ -335,12 +370,13 @@ pub async fn app_shell( sycamore::hydrate_to( // This function provides translator context as needed || { - template.render_for_template( + template.render_for_template_client( page_props, translator, false, router_state_2, page_state_store, + curr_global_state, ) }, &container_rx_elem, @@ -426,6 +462,7 @@ pub async fn app_shell( let page_props = PageProps { path: path_with_locale, state: page_data.state, + global_state, }; #[cfg(not(feature = "hydrate"))] { @@ -433,12 +470,13 @@ pub async fn app_shell( container_rx_elem.set_inner_html(""); sycamore::render_to( move || { - template.render_for_template( + template.render_for_template_client( page_props, translator, false, router_state_2.clone(), page_state_store, + curr_global_state, ) }, &container_rx_elem, @@ -448,12 +486,13 @@ pub async fn app_shell( sycamore::hydrate_to( // This function provides translator context as needed move || { - template.render_for_template( + template.render_for_template_client( page_props, translator, false, router_state_2, page_state_store, + curr_global_state, ) }, &container_rx_elem, diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index b1398bf15c..b84809f919 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -10,6 +10,8 @@ use crate::Request; use crate::SsrNode; use futures::Future; use http::header::HeaderMap; +use std::any::Any; +use std::cell::RefCell; use std::collections::HashMap; use std::pin::Pin; use std::rc::Rc; @@ -19,11 +21,14 @@ use sycamore::prelude::{view, View}; /// The properties that every page will be initialized with. You shouldn't ever need to interact with this unless you decide not to use `#[perseus::template(...)]` or /// `#[perseus::template_with_rx_state(...)]`. +#[derive(Clone)] pub struct PageProps { /// The path it's rendering at. pub path: String, /// The state provided to the page. This will be `Some(_)` if state was generated, we just can't prove that to the compiler. pub state: Option, + /// The global state, stringified. This will be `Some(_)` if state was generated, we just can't prove that to the compiler. + pub global_state: Option, } /// This encapsulates all elements of context currently provided to Perseus templates. While this can be used manually, there are macros @@ -42,6 +47,12 @@ pub struct RenderCtx { /// the `#[perseus::template_with_rx_state(...)]` macro, but it can be used manually as well to get the state of one page from another (provided that the target page has already /// been visited). pub page_state_store: PageStateStore, + /// The user-provided global state. This is stored on the heap to avoid a type parameter that would be needed every time we had to access the render context (which would be very difficult + /// to pass around inside Perseus). + /// + /// Because we store `dyn Any` in here, we initialize it as `Option::None`, and then the template macro (which does the heavy lifting for global state) will find that it can't downcast + /// to the user's global state type, which will prompt it to deserialize whatever global state it was given and then write that here. + pub global_state: Rc>>, } /// Represents all the different states that can be generated for a single template, allowing amalgamation logic to be run with the knowledge @@ -91,10 +102,12 @@ pub type RenderFnResult = std::result::Result = std::result::Result; /// A generic return type for asynchronous functions that we need to store in a struct. -type AsyncFnReturn = Pin + Send + Sync>>; +pub type AsyncFnReturn = Pin + Send + Sync>>; /// Creates traits that prevent users from having to pin their functions' return types. We can't make a generic one until desugared function /// types are stabilized (https://github.com/rust-lang/rust/issues/29625). +#[macro_export] +#[doc(hidden)] macro_rules! make_async_trait { ($name:ident, $return_ty:ty$(, $arg_name:ident: $arg:ty)*) => { // These traits should be purely internal, the user is likely to shoot themselves in the foot @@ -243,8 +256,32 @@ impl Template { } // Render executors - /// Executes the user-given function that renders the template on the server-side (build or request time). - pub fn render_for_template( + /// Executes the user-given function that renders the template on the client-side ONLY. This takes in an extsing global state. + pub fn render_for_template_client( + &self, + props: PageProps, + translator: &Translator, + is_server: bool, + router_state: RouterState, + page_state_store: PageStateStore, + global_state: Rc>>, + ) -> View { + view! { + // We provide the translator through context, which avoids having to define a separate variable for every translation due to Sycamore's `template!` macro taking ownership with `move` closures + ContextProvider(ContextProviderProps { + value: RenderCtx { + is_server, + translator: translator.clone(), + router: router_state, + page_state_store, + global_state + }, + children: || (self.template)(props) + }) + } + } + /// Executes the user-given function that renders the template on the server-side ONLY. This automatically initializes an isolated global state. + pub fn render_for_template_server( &self, props: PageProps, translator: &Translator, @@ -259,7 +296,8 @@ impl Template { is_server, translator: translator.clone(), router: router_state, - page_state_store + page_state_store, + global_state: Rc::new(RefCell::new(Box::new(Option::<()>::None))) }, children: || (self.template)(props) }) @@ -279,7 +317,8 @@ impl Template { translator: translator.clone(), // The head string is rendered to a string, and so never has information about router or page state router: RouterState::default(), - page_state_store: PageStateStore::default() + page_state_store: PageStateStore::default(), + global_state: Rc::new(RefCell::new(Box::new(Option::<()>::None))) }, children: || (self.head)(props) })