diff --git a/.vscode/settings.json b/.vscode/settings.json index 3da58605b6..be8fcb97dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "cli", "book", "examples", - "testing" + "testing", + "templates" ] } diff --git a/examples/basic/.perseus/Cargo.toml b/examples/basic/.perseus/Cargo.toml index dfb7a6fb58..7875adf82b 100644 --- a/examples/basic/.perseus/Cargo.toml +++ b/examples/basic/.perseus/Cargo.toml @@ -5,6 +5,7 @@ name = "perseus-cli-builder" version = "0.2.1" edition = "2018" +default-run = "perseus-builder" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -30,5 +31,9 @@ crate-type = ["cdylib", "rlib"] # We define a binary for building, serving, and doing both [[bin]] -name = "perseus-internal" +name = "perseus-builder" path = "src/bin/build.rs" + +[[bin]] +name = "perseus-exporter" +path = "src/bin/export.rs" diff --git a/examples/basic/.perseus/src/bin/build.rs b/examples/basic/.perseus/src/bin/build.rs index 799d4811d7..3b26500a43 100644 --- a/examples/basic/.perseus/src/bin/build.rs +++ b/examples/basic/.perseus/src/bin/build.rs @@ -18,10 +18,12 @@ fn real_main() -> i32 { &locales, &config_manager, &translations_manager, + // We use another binary to handle exporting + false ); let res = block_on(fut); if let Err(err) = res { - eprintln!("Static generation failed: '{}'", err); + eprintln!("Static generation failed: '{}'.", err); 1 } else { println!("Static generation successfully completed!"); diff --git a/examples/basic/.perseus/src/bin/export.rs b/examples/basic/.perseus/src/bin/export.rs new file mode 100644 index 0000000000..7b7826c8e9 --- /dev/null +++ b/examples/basic/.perseus/src/bin/export.rs @@ -0,0 +1,42 @@ +use app::{get_config_manager, get_locales, get_templates_vec, get_templates_map, get_translations_manager}; +use futures::executor::block_on; +use perseus::{build_app, export_app, SsrNode}; + +fn main() { + let exit_code = real_main(); + std::process::exit(exit_code) +} + +fn real_main() -> i32 { + let config_manager = get_config_manager(); + let translations_manager = block_on(get_translations_manager()); + let locales = get_locales(); + + // Build the site for all the common locales (done in parallel), denying any non-exportable features + let build_fut = build_app( + get_templates_vec::(), + &locales, + &config_manager, + &translations_manager, + // We use another binary to handle normal building + true + ); + if let Err(err) = block_on(build_fut) { + eprintln!("Static exporting failed: '{}'.", err); + return 1 + } + // Turn the build artifacts into self-contained static files + let export_fut = export_app( + get_templates_map(), + "../index.html", + &locales, + &config_manager + ); + if let Err(err) = block_on(export_fut) { + eprintln!("Static exporting failed: '{}'.", err); + 1 + } else { + println!("Static exporting successfully completed!"); + 0 + } +} diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index 8c2d2c8fcd..0709a1820c 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -5,9 +5,9 @@ use actix_files::{Files, NamedFile}; use actix_web::{web, HttpRequest}; use perseus::{ get_render_cfg, ConfigManager, ErrorPages, Locales, SsrNode, TemplateMap, TranslationsManager, + html_shell::prep_html_shell }; use std::collections::HashMap; -use std::env; use std::fs; /// The options for setting up the Actix Web integration. This should be literally constructed, as nothing is optional. @@ -73,33 +73,8 @@ pub async fn configurer - import init, { run } from "/.perseus/bundle.js"; - async function main() { - await init("/.perseus/bundle.wasm"); - run(); - } - main(); -"#; - let index_with_render_cfg = index_file.replace( - "", - // It's safe to assume that something we just deserialized will serialize again in this case - &format!( - "\n{}\n\n", - serde_json::to_string(&render_cfg).unwrap(), - load_script, - testing_var=if env::var("PERSEUS_TESTING").is_ok() { - "window.__PERSEUS_TESTING = true;" - } else { - "" - } - ), - ); + let index_with_render_cfg = prep_html_shell(index_file, &render_cfg); move |cfg: &mut web::ServiceConfig| { cfg diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index fd7cc07de7..7ee6e089fd 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -4,7 +4,7 @@ use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use perseus::error_pages::ErrorPageData; use perseus::router::{match_route, RouteInfo, RouteVerdict}; use perseus::{ - err_to_status_code, serve::get_page_for_template_and_translator, ConfigManager, ErrorPages, + err_to_status_code, serve::get_page_for_template, ConfigManager, ErrorPages, SsrNode, TranslationsManager, Translator, }; use std::collections::HashMap; @@ -104,25 +104,14 @@ pub async fn initial_load( return html_err(400, &err.to_string()); } }; - // Create a translator here, we'll use it twice - let translator_raw = translations_manager - .get_translator_for_locale(locale.to_string()) - .await; - let translator_raw = match translator_raw { - Ok(translator_raw) => translator_raw, - Err(err) => { - return html_err(500, &err.to_string()); - } - }; - let translator = Rc::new(translator_raw); // Actually render the page as we would if this weren't an initial load - let page_data = get_page_for_template_and_translator( + let page_data = get_page_for_template( &path, &locale, &template, http_req, - Rc::clone(&translator), config_manager.get_ref(), + translations_manager.get_ref() ) .await; let page_data = match page_data { @@ -133,12 +122,10 @@ pub async fn initial_load( } }; - // Render the HTML head and interpolate it - let head_str = - template.render_head_str(page_data.state.clone(), Rc::clone(&translator)); + // Interpolate the document `` let html_with_head = html_shell.replace( "", - &format!("{}", head_str), + &format!("{}", &page_data.head), ); // Interpolate a global variable of the state so the app shell doesn't have to make any more trips diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index cfa69bfa0b..5156282c0e 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -3,11 +3,10 @@ use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use perseus::{ err_to_status_code, - serve::{get_page_for_template_and_translator, PageDataWithHead}, + serve::get_page_for_template, ConfigManager, TranslationsManager, }; use serde::Deserialize; -use std::rc::Rc; #[derive(Deserialize)] pub struct PageDataReq { @@ -38,18 +37,6 @@ pub async fn page_data( .body(err.to_string()) } }; - // Create a translator here, we'll use it twice - let translator_raw = translations_manager - .get_translator_for_locale(locale.to_string()) - .await; - let translator_raw = match translator_raw { - Ok(translator_raw) => translator_raw, - Err(err) => { - // We know the locale is valid, so any failure here is a 500 - return HttpResponse::InternalServerError().body(err.to_string()); - } - }; - let translator = Rc::new(translator_raw); // Get the template to use let template = templates.get(&template_name); let template = match template { @@ -59,31 +46,21 @@ pub async fn page_data( return HttpResponse::InternalServerError().body("template not found".to_string()); } }; - let page_data = get_page_for_template_and_translator( + let page_data = get_page_for_template( path, locale, template, http_req, - Rc::clone(&translator), config_manager.get_ref(), + translations_manager.get_ref() ) .await; - let page_data = match page_data { - Ok(page_data) => page_data, + match page_data { + Ok(page_data) => HttpResponse::Ok().body(serde_json::to_string(&page_data).unwrap()), // We parse the error to return an appropriate status code - Err(err) => { - return HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) - .body(err.to_string()) - } - }; - let head_str = template.render_head_str(page_data.state.clone(), Rc::clone(&translator)); - let page_data_with_head = PageDataWithHead { - content: page_data.content, - state: page_data.state, - head: head_str, - }; - - HttpResponse::Ok().body(serde_json::to_string(&page_data_with_head).unwrap()) + Err(err) => HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) + .body(err.to_string()) + } } else { HttpResponse::NotFound().body("locale not supported".to_string()) } diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index cc851f49d8..dc5dc57590 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -18,10 +18,22 @@ pub async fn build_template( template: &Template, translator: Rc, config_manager: &impl ConfigManager, + exporting: bool ) -> Result<(Vec, bool)> { let mut single_page = false; let template_path = template.get_path(); + // If we're exporting, ensure that all the template's strategies are export-safe (not requiring a server) + if exporting && ( + template.revalidates() || + template.uses_incremental() || + template.uses_request_state() || + // We check amalgamation as well because it involves request state, even if that wasn't provided + template.can_amalgamate_states() + ) { + bail!(ErrorKind::TemplateNotExportable(template_path.clone())) + } + // Handle static path generation // Because we iterate over the paths, we need a base path if we're not generating custom ones (that'll be overriden if needed) let paths = match template.uses_build_paths() { @@ -55,12 +67,18 @@ pub async fn build_template( .await?; // Prerender the template using that state let prerendered = sycamore::render_to_string(|| { - template.render_for_template(Some(initial_state), Rc::clone(&translator)) + template.render_for_template(Some(initial_state.clone()), Rc::clone(&translator)) }); // Write that prerendered HTML to a static file config_manager .write(&format!("static/{}.html", full_path), &prerendered) .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(Some(initial_state), Rc::clone(&translator)); + config_manager + .write(&format!("static/{}.head.html", full_path), &head_str) + .await?; } // Handle revalidation, we need to parse any given time strings into datetimes @@ -88,10 +106,14 @@ pub async fn build_template( let prerendered = sycamore::render_to_string(|| { template.render_for_template(None, Rc::clone(&translator)) }); + let head_str = template.render_head_str(None, Rc::clone(&translator)); // Write that prerendered HTML to a static file config_manager .write(&format!("static/{}.html", full_path), &prerendered) .await?; + config_manager + .write(&format!("static/{}.head.html", full_path), &head_str) + .await?; } } @@ -102,12 +124,13 @@ async fn build_template_and_get_cfg( template: &Template, translator: Rc, config_manager: &impl ConfigManager, + exporting: bool ) -> Result> { let mut render_cfg = HashMap::new(); let template_root_path = template.get_path(); let is_incremental = template.uses_incremental(); - let (pages, single_page) = build_template(template, translator, config_manager).await?; + let (pages, single_page) = build_template(template, translator, config_manager, exporting).await?; // If the template represents a single page itself, we don't need any concatenation if single_page { render_cfg.insert(template_root_path.clone(), template_root_path.clone()); @@ -138,6 +161,7 @@ pub async fn build_templates_for_locale( templates: &[Template], translator_raw: Translator, config_manager: &impl ConfigManager, + exporting: bool ) -> Result<()> { let translator = Rc::new(translator_raw); // The render configuration stores a list of pages to the root paths of their templates @@ -149,6 +173,7 @@ pub async fn build_templates_for_locale( template, Rc::clone(&translator), config_manager, + exporting )); } let template_cfgs = try_join_all(futs).await?; @@ -169,11 +194,12 @@ async fn build_templates_and_translator_for_locale( locale: String, config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, + exporting: bool ) -> Result<()> { let translator = translations_manager .get_translator_for_locale(locale) .await?; - build_templates_for_locale(templates, translator, config_manager).await?; + build_templates_for_locale(templates, translator, config_manager, exporting).await?; Ok(()) } @@ -185,6 +211,7 @@ pub async fn build_app( locales: &Locales, config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, + exporting: bool ) -> Result<()> { let locales = locales.get_all(); let mut futs = Vec::new(); @@ -195,6 +222,7 @@ pub async fn build_app( locale.to_string(), config_manager, translations_manager, + exporting )); } // Build all locales in parallel diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 98b72f6187..04fdbb2dfb 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -42,12 +42,27 @@ error_chain! { display("the locale '{}' is not supported", locale) } - /// For when a necessary template feautre was expected but not present. This just pertains to rendering strategies, and shouldn't + /// For when a necessary template feature was expected but not present. This just pertains to rendering strategies, and shouldn't /// ever be sensitive. TemplateFeatureNotEnabled(name: String, feature: String) { description("a template feature required by a function called was not present") display("the template '{}' is missing the feature '{}'", name, feature) } + /// For when a template was using non-exportable features, but the user was trying to export. + TemplateNotExportable(name: String) { + description("attempted to export template with non-exportable features") + display("the template '{}' is using features that cannot be exported (only build state and build paths can be exported, you may wish to build instead)", name) + } + /// For when the HTML shell couldn't be found. + HtmlShellNotFound(path: String, err: String) { + description("html shell not found") + display("html shell couldn't be found at given path '{}', make sure that exists and that you have permission to read from there, error was: '{}'", path, err) + } + /// For when a template couldn't be found while exporting. + TemplateNotFound(path: String) { + description("template not found") + display("template '{}' couldn't be found, please try again", path) + } /// For when the given path wasn't found, a 404 should never be sensitive. PageNotFound(path: String) { description("the requested page was not found") diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs new file mode 100644 index 0000000000..3c7c66f5c9 --- /dev/null +++ b/packages/perseus/src/export.rs @@ -0,0 +1,79 @@ +use crate::errors::*; +use crate::ConfigManager; +use crate::TemplateMap; +use crate::SsrNode; +use crate::Locales; +use crate::html_shell::prep_html_shell; +use crate::get_render_cfg; +use std::fs; + +/// Creates a full HTML file, ready for initial loads, from the given data. +async fn create_full_html( + html_path: String, + json_path: Option, + html_shell: &str, + config_manager: &impl ConfigManager +) -> Result { + // Get the partial HTML content and a state to go with it (if applicable) + let content = config_manager.read(&html_path).await?; + let state = match json_path { + Some(json_path) => Some(config_manager.read(&json_path).await?), + None => None + }; + + todo!() +} + +/// 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`). +pub async fn export_app( + templates: TemplateMap, + html_shell_path: &str, + locales: &Locales, + config_manager: &impl ConfigManager +) -> Result<()> { + // 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(config_manager) + .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| ErrorKind::HtmlShellNotFound(html_shell_path.to_string(), err.to_string()))?; + let html_shell = prep_html_shell(raw_html_shell, &render_cfg); + + // Loop over every partial + for (path, template_path) in render_cfg { + // Get the template itself + let template = templates.get(&template_path); + let template = match template { + Some(template) => template, + None => bail!(ErrorKind::PageNotFound(template_path)) + }; + // Create a locale detection file for it if we're using i18n + // These just send the app shell, which will perform a redirect as necessary + // TODO test this on the i18n example + if locales.using_i18n { + config_manager.write(&format!("exported/{}", path), &html_shell).await?; + } + // Check if that template uses build state (in which case it should have a JSON file) + let has_json = template.uses_build_state(); + if locales.using_i18n { + // Loop through all the app's locales + todo!() + } else { + let html_path = format!("exported/{}.html", path); + let json_path = match has_json { + true => Some(format!("exported/{}.json", path)), + false => None + }; + // Create from those a full HTMl file that can be served for initial loads + let full_html = create_full_html(html_path, json_path, &html_shell, config_manager).await?; + } + // Read the HTML partial (doesn't have a shell yet) + // Interpolate the HTML partial into the shell + // Set the initial state accordingly as to the use of build state + // Interpolate that initial state into the document `` + // Write the full file + } + + Ok(()) +} diff --git a/packages/perseus/src/html_shell.rs b/packages/perseus/src/html_shell.rs new file mode 100644 index 0000000000..9cea6de414 --- /dev/null +++ b/packages/perseus/src/html_shell.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; +use std::env; + +/// Initializes the HTML shell by interpolating necessary scripts into it, as well as by adding the render configuration. +pub fn prep_html_shell(html_shell: String, render_cfg: &HashMap) -> String { + // Define the script that will load the Wasm bundle (inlined to avoid unnecessary extra requests) + let load_script = r#""#; + // We inject a script that defines the render config as a global variable, which we put just before the close of the head + // We also inject a delimiter comment that will be used to wall off the constant document head from the interpolated document head + // We also inject the above script to load the Wasm bundle (avoids extra trips) + // We also inject a global variable to identify that we're testing if we are (picked up by app shell to trigger helper DOM events) + let prepared = html_shell.replace( + "", + // It's safe to assume that something we just deserialized will serialize again in this case + &format!( + "\n{}\n\n", + serde_json::to_string(&render_cfg).unwrap(), + load_script, + testing_var=if env::var("PERSEUS_TESTING").is_ok() { + "window.__PERSEUS_TESTING = true;" + } else { + "" + } + ), + ); + + prepared +} diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index b34bb488dd..5983f270f3 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -61,6 +61,10 @@ mod test; pub mod translations_manager; /// Utilities regarding translators, including the default `FluentTranslator`. pub mod translator; +/// Utilities to do with exporting your app to purely static files. +pub mod export; +/// Utilities for manipulating the HTML shell. These are primarily used in exporting and serving. +pub mod html_shell; pub use http; pub use http::Request as HttpRequest; @@ -81,5 +85,6 @@ pub use crate::shell::app_shell; pub use crate::template::{States, StringResult, StringResultWithCause, Template, TemplateMap}; pub use crate::translations_manager::{FsTranslationsManager, TranslationsManager}; pub use crate::translator::{Translator, TRANSLATOR_FILE_EXT}; +pub use crate::export::export_app; pub use perseus_macro::test; diff --git a/packages/perseus/src/serve.rs b/packages/perseus/src/serve.rs index 7211187493..a95d2bef9a 100644 --- a/packages/perseus/src/serve.rs +++ b/packages/perseus/src/serve.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::rc::Rc; use sycamore::prelude::SsrNode; -/// Represents the data necessary to render a page. +/// Represents the data necessary to render a page, including document metadata. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PageData { /// Prerendered HTML content. @@ -21,16 +21,6 @@ pub struct PageData { /// The state for hydration. This is kept as a string for ease of typing. Some pages may not need state or generate it in another way, /// so this might be `None`. pub state: Option, -} - -/// Represents the data necessary to render a page with its metadata. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct PageDataWithHead { - /// Prerendered HTML content. - pub content: String, - /// The state for hydration. This is kept as a string for ease of typing. Some pages may not need state or generate it in another way, - /// so this might be `None`. - pub state: Option, /// The string to interpolate into the document's ``. pub head: String, } @@ -49,11 +39,14 @@ pub async fn get_render_cfg( async fn render_build_state( path_encoded: &str, config_manager: &impl ConfigManager, -) -> Result<(String, Option)> { +) -> Result<(String, String, Option)> { // Get the static HTML let html = config_manager .read(&format!("static/{}.html", path_encoded)) .await?; + let head = config_manager + .read(&format!("static/{}.head.html", path_encoded)) + .await?; // Get the static JSON let state = match config_manager .read(&format!("static/{}.json", path_encoded)) @@ -63,7 +56,7 @@ async fn render_build_state( Err(_) => None, }; - Ok((html, state)) + Ok((html, head, state)) } /// Renders a template that generated its state at request-time. Note that revalidation and ISR have no impact on SSR-rendered pages. async fn render_request_state( @@ -71,27 +64,35 @@ async fn render_request_state( translator: Rc, path: &str, req: Request, -) -> Result<(String, Option)> { +) -> Result<(String, String, Option)> { // Generate the initial state (this may generate an error, but there's no file that can't exist) let state = Some(template.get_request_state(path.to_string(), req).await?); // Use that to render the static HTML let html = - sycamore::render_to_string(|| template.render_for_template(state.clone(), translator)); + sycamore::render_to_string(|| template.render_for_template(state.clone(), Rc::clone(&translator))); + let head = template.render_head_str(state.clone(), Rc::clone(&translator)); - Ok((html, state)) + Ok((html, head, state)) } /// Checks if a template that uses ISR has already been cached. async fn get_incremental_cached( path_encoded: &str, config_manager: &impl ConfigManager, -) -> Option { +) -> Option<(String, String)> { let html_res = config_manager .read(&format!("static/{}.html", path_encoded)) .await; // We should only treat it as cached if it can be accessed and if we aren't in development (when everything should constantly reload) match html_res { - Ok(html) if !cfg!(debug_assertions) => Some(html), + Ok(html) if !cfg!(debug_assertions) => { + // If the HTML exists, the head must as well + let head = config_manager + .read(&format!("static/{}.html", path_encoded)) + .await + .unwrap(); + Some((html, head)) + }, Ok(_) | Err(_) => None, } } @@ -132,7 +133,7 @@ async fn revalidate( path: &str, path_encoded: &str, config_manager: &impl ConfigManager, -) -> Result<(String, Option)> { +) -> Result<(String, String, Option)> { // We need to regenerate and cache this page for future usage (until the next revalidation) let state = Some( template @@ -140,7 +141,8 @@ async fn revalidate( .await?, ); let html = - sycamore::render_to_string(|| template.render_for_template(state.clone(), translator)); + sycamore::render_to_string(|| template.render_for_template(state.clone(), Rc::clone(&translator))); + let head = template.render_head_str(state.clone(), Rc::clone(&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() { @@ -163,23 +165,32 @@ async fn revalidate( config_manager .write(&format!("static/{}.html", path_encoded), &html) .await?; + config_manager + .write(&format!("static/{}.head.html", path_encoded), &head) + .await?; - Ok((html, state)) + Ok((html, head, state)) } /// Internal logic behind `get_page`. The only differences are that this takes a full template rather than just a template name, which -/// can avoid an unnecessary lookup if you already know the template in full (e.g. initial load server-side routing), and that it takes -/// a pre-formed translator for similar reasons. +/// can avoid an unnecessary lookup if you already know the template in full (e.g. initial load server-side routing). // TODO possible further optimizations on this for futures? -pub async fn get_page_for_template_and_translator( +pub async fn get_page_for_template( // This must not contain the locale raw_path: &str, locale: &str, template: &Template, req: Request, - translator: Rc, config_manager: &impl ConfigManager, + translations_manager: &impl TranslationsManager ) -> Result { + // Get a translator for this locale (for sanity we hope the manager is caching) + let translator = Rc::new( + translations_manager + .get_translator_for_locale(locale.to_string()) + .await?, + ); + let mut path = raw_path; // If the path is empty, we're looking for the special `index` page if path.is_empty() { @@ -189,7 +200,9 @@ pub async fn get_page_for_template_and_translator( let path_encoded = format!("{}-{}", locale, urlencoding::encode(path).to_string()); // Only a single string of HTML is needed, and it will be overridden if necessary (priorities system) - let mut html: String = String::new(); + let mut html = String::new(); + // The same applies for the document metadata + let mut head = String::new(); // Multiple rendering strategies may need to amalgamate different states let mut states: States = States::new(); @@ -198,13 +211,13 @@ pub async fn get_page_for_template_and_translator( // If the template uses incremental generation, that is its own contained process if template.uses_incremental() { // Get the cached content if it exists (otherwise `None`) - let html_opt = get_incremental_cached(&path_encoded, config_manager).await; - match html_opt { + let html_and_head_opt = get_incremental_cached(&path_encoded, config_manager).await; + match html_and_head_opt { // It's cached - Some(html_val) => { + Some((html_val, head_val)) => { // Check if we need to revalidate if should_revalidate(template, &path_encoded, config_manager).await? { - let (html_val, state) = revalidate( + let (html_val, head_val, state) = revalidate( template, Rc::clone(&translator), path, @@ -214,13 +227,15 @@ pub async fn get_page_for_template_and_translator( .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 + html = html_val; + head = head_val; } states.build_state = state; } else { // 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 + html = html_val; + head = head_val; } // Get the static JSON (if it exists, but it should) states.build_state = match config_manager @@ -239,6 +254,7 @@ pub async fn get_page_for_template_and_translator( let html_val = sycamore::render_to_string(|| { template.render_for_template(state.clone(), Rc::clone(&translator)) }); + let head_val = template.render_head_str(state.clone(), Rc::clone(&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 @@ -265,18 +281,22 @@ pub async fn get_page_for_template_and_translator( config_manager .write(&format!("static/{}.html", path_encoded), &html_val) .await?; + config_manager + .write(&format!("static/{}.head.html", path_encoded), &head_val) + .await?; states.build_state = state; // 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 + html = html_val; + head = head_val; } } } } else { // Handle if we need to revalidate if should_revalidate(template, &path_encoded, config_manager).await? { - let (html_val, state) = revalidate( + let (html_val, head_val, state) = revalidate( template, Rc::clone(&translator), path, @@ -286,14 +306,16 @@ pub async fn get_page_for_template_and_translator( .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 + html = html_val; + head = head_val; } states.build_state = state; } else { - let (html_val, state) = render_build_state(&path_encoded, config_manager).await?; + let (html_val, head_val, state) = render_build_state(&path_encoded, config_manager).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 + html = html_val; + head = head_val; } states.build_state = state; } @@ -301,10 +323,11 @@ pub async fn get_page_for_template_and_translator( } // Handle request state if template.uses_request_state() { - let (html_val, state) = + let (html_val, head_val, state) = render_request_state(template, Rc::clone(&translator), path, req).await?; // Request-time HTML always overrides anything generated at build-time or incrementally (this has more information) html = html_val; + head = head_val; states.request_state = state; } @@ -312,6 +335,7 @@ pub async fn get_page_for_template_and_translator( // If the user has defined custom logic for this, we'll defer to that // Otherwise we go as with HTML, request trumps build // Of course, if only one state was defined, we'll just use that regardless (so `None` prioritization is impossible) + // If this is the case, the build content will still be served, and then it's up to the client to hydrate it with the new amalgamated state let state: Option; if !states.both_defined() { state = states.get_defined()?; @@ -325,6 +349,7 @@ pub async fn get_page_for_template_and_translator( let res = PageData { content: html, state, + head }; Ok(res) @@ -354,20 +379,14 @@ pub async fn get_page( // This shouldn't happen because the client should already have performed checks against the render config, but it's handled anyway None => bail!(ErrorKind::PageNotFound(path.to_string())), }; - // Get a translator for this locale (for sanity we hope the manager is caching) - let translator = Rc::new( - translations_manager - .get_translator_for_locale(locale.to_string()) - .await?, - ); - let res = get_page_for_template_and_translator( + let res = get_page_for_template( raw_path, locale, template, req, - translator, config_manager, + translations_manager ) .await?; Ok(res) diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index fda075b8a7..4540e69293 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -1,6 +1,6 @@ use crate::error_pages::ErrorPageData; use crate::errors::*; -use crate::serve::PageDataWithHead; +use crate::serve::PageData; use crate::template::Template; use crate::ClientTranslationsManager; use crate::ErrorPages; @@ -276,7 +276,7 @@ pub async fn app_shell( Ok(page_data_str) => match page_data_str { Some(page_data_str) => { // All good, deserialize the page data - let page_data = serde_json::from_str::(&page_data_str); + let page_data = serde_json::from_str::(&page_data_str); match page_data { Ok(page_data) => { // We have the page data ready, render everything