From 4838ba43611b0156afa5c84d2454ca6cbbf5f5a1 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Fri, 24 Sep 2021 19:01:48 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20static=20exporting?= =?UTF-8?q?=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ✨ `FsConfigManager` now creates directory structures automatically * feat(templates): ✨ prerendered `` at build time where possible Also laid foundations for static exporting (still WIP). Closes #22. * feat(exporting): ✨ made exporting system create full html files for initial loads * feat(exporting): ✨ made exporting system create json partials for subsequent loads * style: 🎨 ran `cargo fmt` * feat(exporting): ✨ created cli export command Still WIP... * refactor(testing): ♻️ changed exporting emoji to 📦 * fix(exporting): 🐛 wrote initial load files as `.html` so they aren't considered octet streams This means servers have to strip extensions. * feat(exporting): ✨ made exporting systems copy `static` directory Support for static aliases coming next. * refactor(routing): ♻️ added `.json` extension to page partials Makes interpretation easier for most static file servers. * refactor: 🔥 removed unnecessary serving of `render_conf.json` * feat(exporting): ✨ added export support for static aliases * style: 🎨 ran `cargo fmt` * docs(book): 📝 added docs on static exporting * docs(book): 📝 merged `next` docs into `0.2.x` * docs(book): 📝 added docs for exporting file extension pitfall * refactor: 🚨 addressed clippy warnings --- .vscode/settings.json | 4 +- docs/0.2.x/src/SUMMARY.md | 4 + docs/0.2.x/src/deploying/exporting.md | 13 ++ docs/0.2.x/src/deploying/intro.md | 5 + docs/next/src/SUMMARY.md | 4 + docs/next/src/deploying/exporting.md | 13 ++ docs/next/src/deploying/intro.md | 5 + examples/basic/.perseus/Cargo.toml | 8 +- examples/basic/.perseus/src/bin/build.rs | 4 +- examples/basic/.perseus/src/bin/export.rs | 87 ++++++++ examples/i18n/Cargo.toml | 1 + examples/i18n/src/lib.rs | 3 +- examples/i18n/src/templates/mod.rs | 1 + examples/i18n/src/templates/post.rs | 60 +++++ packages/perseus-actix-web/src/configurer.rs | 39 +--- .../perseus-actix-web/src/initial_load.rs | 61 +---- packages/perseus-actix-web/src/page_data.rs | 37 +-- packages/perseus-cli/src/bin/main.rs | 16 +- packages/perseus-cli/src/build.rs | 3 +- packages/perseus-cli/src/errors.rs | 10 + packages/perseus-cli/src/export.rs | 211 ++++++++++++++++++ packages/perseus-cli/src/lib.rs | 2 + packages/perseus-cli/src/serve.rs | 4 +- packages/perseus/src/build.rs | 35 ++- packages/perseus/src/config_manager.rs | 8 + packages/perseus/src/errors.rs | 17 +- packages/perseus/src/export.rs | 152 +++++++++++++ packages/perseus/src/html_shell.rs | 79 +++++++ packages/perseus/src/lib.rs | 5 + packages/perseus/src/serve.rs | 118 ++++++---- packages/perseus/src/shell.rs | 12 +- packages/perseus/src/translations_manager.rs | 2 +- 32 files changed, 835 insertions(+), 188 deletions(-) create mode 100644 docs/0.2.x/src/deploying/exporting.md create mode 100644 docs/0.2.x/src/deploying/intro.md create mode 100644 docs/next/src/deploying/exporting.md create mode 100644 docs/next/src/deploying/intro.md create mode 100644 examples/basic/.perseus/src/bin/export.rs create mode 100644 examples/i18n/src/templates/post.rs create mode 100644 packages/perseus-cli/src/export.rs create mode 100644 packages/perseus/src/export.rs create mode 100644 packages/perseus/src/html_shell.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 3da58605b6..b0032284cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,8 @@ "cli", "book", "examples", - "testing" + "testing", + "templates", + "exporting" ] } diff --git a/docs/0.2.x/src/SUMMARY.md b/docs/0.2.x/src/SUMMARY.md index 8c2662f378..9e023266c3 100644 --- a/docs/0.2.x/src/SUMMARY.md +++ b/docs/0.2.x/src/SUMMARY.md @@ -34,6 +34,10 @@ - [Fantoccini Basics](./testing/fantoccini-basics.md) - [Manual Testing](./testing/manual.md) - [Styling](./styling.md) +- [Deploying](./deploying/intro.md) + - [Static Exporting](./deploying/exporting.md) + - [Server Deployment]() + - [Serverless Deployment]() - [Migrating from v0.1.x](./updating.md) *** # Advanced diff --git a/docs/0.2.x/src/deploying/exporting.md b/docs/0.2.x/src/deploying/exporting.md new file mode 100644 index 0000000000..5f6cc4b0a6 --- /dev/null +++ b/docs/0.2.x/src/deploying/exporting.md @@ -0,0 +1,13 @@ +# Static Exporting + +The easiest way to deploy Perseus is as a set of static files, which is supported if your app uses only the _build state_ and _build paths_ strategies, or none at all. If you use _incremental generation_, _revalidation_, or _request state_ in any of your templates though, you can't export your app to static file, because these strategies require a custom server. For these cases, please continue to the rest of this section to learn how to deploy your more complex setup. + +However, if your app only needs to run server-side computations at build-time, then you can export it to a set of static files without changing anything, simply by running `perseus export`. This will create a new directory called `.perseus/dist/exported`, the contents of which can be served on a system like [GitHub Pages](https:://pages.github.com). Your app should behave in the exact same way with exporting as with normal serving. If this isn't the case, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). + +There is only one known difference between the behavior of your exported site and your normally served site, and that's regarding [static aliases](../static-content.md). In a normal serving scenario, any static aliases that conflicted with a Perseus page or internal asset would be ignored, but, in an exporting context, **any static aliases that conflict with Perseus pages will override them**! If you suspect this might be happening to you, try exporting without those aliases and make sure the URL of your alias file doesn't already exist (in which case it would be a Perseus component). + +## File Extensions + +One slight hiccup with Perseus' static exporting system comes with regards to the `.html` file extension. Perseus' server expects that pages shouldn't have such extensions (hence `/about` rather than `/about.html`), but, when statically generated, they must have these extensions in the filesystem. So, if you don't want these extensions for your users (and if you want consistent behavior between exporting and serving), it's up to whatever system you're hosting your files with to strip these extensions. Many systems do this automatically, though some (like Python's `http.server`) do not. + +One of the best systems for testing static exporting on your local machine is the [`serve`](https://github.com/versel/serve) JavaScript package, which can be run from the command-line without touching any JavaScript, and it handles this problem automatically. However, other solutions certainly exist if you don't want any JS polluting your system! diff --git a/docs/0.2.x/src/deploying/intro.md b/docs/0.2.x/src/deploying/intro.md new file mode 100644 index 0000000000..eaedaa0b18 --- /dev/null +++ b/docs/0.2.x/src/deploying/intro.md @@ -0,0 +1,5 @@ +# Deploying + +Perseus is a complex system, but we aim to make deploying it as easy as possible. This section will describe a few different types of Perseus deployments, and how they can be managed. + +*Note: Perseus deployment is still under design and development, so this information in particular is subject to rapid change before v1.0.0.* diff --git a/docs/next/src/SUMMARY.md b/docs/next/src/SUMMARY.md index 8c2662f378..9e023266c3 100644 --- a/docs/next/src/SUMMARY.md +++ b/docs/next/src/SUMMARY.md @@ -34,6 +34,10 @@ - [Fantoccini Basics](./testing/fantoccini-basics.md) - [Manual Testing](./testing/manual.md) - [Styling](./styling.md) +- [Deploying](./deploying/intro.md) + - [Static Exporting](./deploying/exporting.md) + - [Server Deployment]() + - [Serverless Deployment]() - [Migrating from v0.1.x](./updating.md) *** # Advanced diff --git a/docs/next/src/deploying/exporting.md b/docs/next/src/deploying/exporting.md new file mode 100644 index 0000000000..5f6cc4b0a6 --- /dev/null +++ b/docs/next/src/deploying/exporting.md @@ -0,0 +1,13 @@ +# Static Exporting + +The easiest way to deploy Perseus is as a set of static files, which is supported if your app uses only the _build state_ and _build paths_ strategies, or none at all. If you use _incremental generation_, _revalidation_, or _request state_ in any of your templates though, you can't export your app to static file, because these strategies require a custom server. For these cases, please continue to the rest of this section to learn how to deploy your more complex setup. + +However, if your app only needs to run server-side computations at build-time, then you can export it to a set of static files without changing anything, simply by running `perseus export`. This will create a new directory called `.perseus/dist/exported`, the contents of which can be served on a system like [GitHub Pages](https:://pages.github.com). Your app should behave in the exact same way with exporting as with normal serving. If this isn't the case, please [open an issue](https://github.com/arctic-hen7/perseus/issues/new/choose). + +There is only one known difference between the behavior of your exported site and your normally served site, and that's regarding [static aliases](../static-content.md). In a normal serving scenario, any static aliases that conflicted with a Perseus page or internal asset would be ignored, but, in an exporting context, **any static aliases that conflict with Perseus pages will override them**! If you suspect this might be happening to you, try exporting without those aliases and make sure the URL of your alias file doesn't already exist (in which case it would be a Perseus component). + +## File Extensions + +One slight hiccup with Perseus' static exporting system comes with regards to the `.html` file extension. Perseus' server expects that pages shouldn't have such extensions (hence `/about` rather than `/about.html`), but, when statically generated, they must have these extensions in the filesystem. So, if you don't want these extensions for your users (and if you want consistent behavior between exporting and serving), it's up to whatever system you're hosting your files with to strip these extensions. Many systems do this automatically, though some (like Python's `http.server`) do not. + +One of the best systems for testing static exporting on your local machine is the [`serve`](https://github.com/versel/serve) JavaScript package, which can be run from the command-line without touching any JavaScript, and it handles this problem automatically. However, other solutions certainly exist if you don't want any JS polluting your system! diff --git a/docs/next/src/deploying/intro.md b/docs/next/src/deploying/intro.md new file mode 100644 index 0000000000..eaedaa0b18 --- /dev/null +++ b/docs/next/src/deploying/intro.md @@ -0,0 +1,5 @@ +# Deploying + +Perseus is a complex system, but we aim to make deploying it as easy as possible. This section will describe a few different types of Perseus deployments, and how they can be managed. + +*Note: Perseus deployment is still under design and development, so this information in particular is subject to rapid change before v1.0.0.* diff --git a/examples/basic/.perseus/Cargo.toml b/examples/basic/.perseus/Cargo.toml index dfb7a6fb58..1ae5b8737e 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 @@ -23,6 +24,7 @@ serde_json = "1" # Possibly don't need? console_error_panic_hook = "0.1.6" urlencoding = "2.1" futures = "0.3" +fs_extra = "1" # This section is needed for Wasm Pack (which we use instead of Trunk for flexibility) [lib] @@ -30,5 +32,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..4785df17a2 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..968d6967b1 --- /dev/null +++ b/examples/basic/.perseus/src/bin/export.rs @@ -0,0 +1,87 @@ +use app::{ + get_config_manager, get_locales, get_static_aliases, get_templates_map, get_templates_vec, + get_translations_manager, APP_ROOT, +}; +use fs_extra::dir::{copy as copy_dir, CopyOptions}; +use futures::executor::block_on; +use perseus::{build_app, export_app, SsrNode}; +use std::fs; +use std::path::PathBuf; + +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, + APP_ROOT, + &config_manager, + &translations_manager, + ); + if let Err(err) = block_on(export_fut) { + eprintln!("Static exporting failed: '{}'.", err); + return 1; + } + + // Copy the `static` directory into the export package if it exists + // We don't use a config manager here because static files are always handled on-disk in Perseus (for now) + let static_dir = PathBuf::from("../static"); + if static_dir.exists() { + if let Err(err) = copy_dir(&static_dir, "dist/exported/.perseus/", &CopyOptions::new()) { + eprintln!( + "Static exporting failed: 'couldn't copy static directory: '{}''", + err.to_string() + ); + return 1; + } + } + // Loop through any static aliases and copy them in too + // Unlike with the server, these could override pages! + // We'll copy from the alias to the path (it could be a directory or a file) + // Remember: `alias` has a leading `/`! + for (alias, path) in get_static_aliases() { + let from = PathBuf::from(path); + let to = format!("dist/exported{}", alias); + + if from.is_dir() { + if let Err(err) = copy_dir(&from, &to, &CopyOptions::new()) { + eprintln!( + "Static exporting failed: 'couldn't copy static alias directory: '{}''", + err.to_string() + ); + return 1; + } + } else if let Err(err) = fs::copy(&from, &to) { + eprintln!( + "Static exporting failed: 'couldn't copy static alias file: '{}''", + err.to_string() + ); + return 1; + } + } + + println!("Static exporting successfully completed!"); + 0 +} diff --git a/examples/i18n/Cargo.toml b/examples/i18n/Cargo.toml index 9163aceb6a..b43a1bc40e 100644 --- a/examples/i18n/Cargo.toml +++ b/examples/i18n/Cargo.toml @@ -15,6 +15,7 @@ sycamore-router = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" fluent-bundle = "0.15" +urlencoding = "2.1" [dev-dependencies] fantoccini = "0.17" diff --git a/examples/i18n/src/lib.rs b/examples/i18n/src/lib.rs index 8e99ec5216..1726ebcc2a 100644 --- a/examples/i18n/src/lib.rs +++ b/examples/i18n/src/lib.rs @@ -6,7 +6,8 @@ use perseus::define_app; define_app! { templates: [ crate::templates::about::get_template::(), - crate::templates::index::get_template::() + crate::templates::index::get_template::(), + crate::templates::post::get_template::() ], error_pages: crate::error_pages::get_error_pages(), locales: { diff --git a/examples/i18n/src/templates/mod.rs b/examples/i18n/src/templates/mod.rs index 9b9cf18fc5..f6331322ad 100644 --- a/examples/i18n/src/templates/mod.rs +++ b/examples/i18n/src/templates/mod.rs @@ -1,2 +1,3 @@ pub mod about; pub mod index; +pub mod post; diff --git a/examples/i18n/src/templates/post.rs b/examples/i18n/src/templates/post.rs new file mode 100644 index 0000000000..24049b963e --- /dev/null +++ b/examples/i18n/src/templates/post.rs @@ -0,0 +1,60 @@ +use perseus::{StringResultWithCause, Template}; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; +use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; + +#[derive(Serialize, Deserialize)] +pub struct PostPageProps { + title: String, + content: String, +} + +#[component(PostPage)] +pub fn post_page(props: PostPageProps) -> SycamoreTemplate { + let title = props.title; + let content = props.content; + template! { + h1 { + (title) + } + p { + (content) + } + } +} + +pub fn get_template() -> Template { + Template::new("post") + .build_paths_fn(Rc::new(get_static_paths)) + .build_state_fn(Rc::new(get_static_props)) + .template(template_fn()) +} + +pub async fn get_static_props(path: String) -> StringResultWithCause { + // This is just an example + let title = urlencoding::decode(&path).unwrap(); + let content = format!( + "This is a post entitled '{}'. Its original slug was '{}'.", + title, path + ); + + Ok(serde_json::to_string(&PostPageProps { + title: title.to_string(), + content, + }) + .unwrap()) +} + +pub async fn get_static_paths() -> Result, String> { + Ok(vec!["test".to_string(), "blah/test/blah".to_string()]) +} + +pub fn template_fn() -> perseus::template::TemplateFn { + Rc::new(|props| { + template! { + PostPage( + serde_json::from_str::(&props.unwrap()).unwrap() + ) + } + }) +} diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index 8c2d2c8fcd..b2819e905f 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -4,10 +4,10 @@ use crate::translations::translations; use actix_files::{Files, NamedFile}; use actix_web::{web, HttpRequest}; use perseus::{ - get_render_cfg, ConfigManager, ErrorPages, Locales, SsrNode, TemplateMap, TranslationsManager, + get_render_cfg, html_shell::prep_html_shell, ConfigManager, ErrorPages, Locales, SsrNode, + TemplateMap, TranslationsManager, }; 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. @@ -40,11 +40,6 @@ pub struct Options { pub static_aliases: HashMap, } -async fn render_conf( - render_conf: web::Data>, -) -> web::Json> { - web::Json(render_conf.get_ref().clone()) -} async fn js_bundle(opts: web::Data) -> std::io::Result { NamedFile::open(&opts.js_bundle) } @@ -73,33 +68,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 @@ -114,12 +84,11 @@ pub async fn configurer), ) // This allows the app shell to fetch translations for a given page diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index fd7cc07de7..b816f3fabc 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -2,10 +2,11 @@ use crate::conv_req::convert_req; use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use perseus::error_pages::ErrorPageData; +use perseus::html_shell::interpolate_page_data; use perseus::router::{match_route, RouteInfo, RouteVerdict}; use perseus::{ - err_to_status_code, serve::get_page_for_template_and_translator, ConfigManager, ErrorPages, - SsrNode, TranslationsManager, Translator, + err_to_status_code, serve::get_page_for_template, ConfigManager, ErrorPages, SsrNode, + TranslationsManager, Translator, }; use std::collections::HashMap; use std::rc::Rc; @@ -104,25 +105,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,46 +123,7 @@ 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)); - let html_with_head = html_shell.replace( - "", - &format!("{}", head_str), - ); - - // 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` - let state_var = format!("", { - if let Some(state) = &page_data.state { - state - // If we don't escape quotes, we get runtime syntax errors - .replace(r#"'"#, r#"\'"#) - .replace(r#"""#, r#"\""#) - } 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 html_with_state = - html_with_head.replace("", &format!("{}\n", state_var)); - - // Figure out exactly what we're interpolating in terms of content - // The user MUST place have a `
` of this exact form (documented explicitly) - // We permit either double or single quotes - let html_to_replace_double = format!("
", &opts.root_id); - let html_to_replace_single = format!("
", &opts.root_id); - let html_replacement = format!( - // We give the content a specific ID so that it can be deleted if an error page needs to be rendered on the client-side - "{}
{}
", - &html_to_replace_double, - &page_data.content - ); - // Now interpolate that HTML into the HTML shell - let final_html = html_with_state - .replace(&html_to_replace_double, &html_replacement) - .replace(&html_to_replace_single, &html_replacement); + let final_html = interpolate_page_data(&html_shell, &page_data, &opts.root_id); HttpResponse::Ok() .content_type("text/html") diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index cfa69bfa0b..9438a5a075 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -2,12 +2,9 @@ use crate::conv_req::convert_req; 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}, - ConfigManager, TranslationsManager, + err_to_status_code, serve::get_page_for_template, ConfigManager, TranslationsManager, }; use serde::Deserialize; -use std::rc::Rc; #[derive(Deserialize)] pub struct PageDataReq { @@ -38,18 +35,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 +44,23 @@ 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()) + 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()) + } } else { HttpResponse::NotFound().body("locale not supported".to_string()) } diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index b85e83a265..71a4013cf9 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -1,7 +1,7 @@ use perseus_cli::errors::*; use perseus_cli::{ - build, check_env, delete_artifacts, delete_bad_dir, eject, has_ejected, help, prepare, serve, - PERSEUS_VERSION, + build, check_env, delete_artifacts, delete_bad_dir, eject, export, has_ejected, help, prepare, + serve, PERSEUS_VERSION, }; use std::env; use std::io::Write; @@ -83,6 +83,14 @@ fn core(dir: PathBuf) -> Result { delete_artifacts(dir.clone(), "static")?; let exit_code = build(dir, &prog_args)?; Ok(exit_code) + } else if prog_args[0] == "export" { + // Set up the '.perseus/' directory if needed + prepare(dir.clone())?; + // Delete old build/exportation artifacts + delete_artifacts(dir.clone(), "static")?; + delete_artifacts(dir.clone(), "exported")?; + let exit_code = export(dir, &prog_args)?; + Ok(exit_code) } else if prog_args[0] == "serve" { // Set up the '.perseus/' directory if needed prepare(dir.clone())?; @@ -107,7 +115,7 @@ fn core(dir: PathBuf) -> Result { } else if prog_args[0] == "prep" { // This command is deliberately undocumented, it's only used for testing // Set up the '.perseus/' directory if needed - prepare(dir.clone())?; + prepare(dir)?; Ok(0) } else if prog_args[0] == "eject" { // Set up the '.perseus/' directory if needed @@ -119,7 +127,7 @@ fn core(dir: PathBuf) -> Result { // The user only wants to remove distribution artifacts // We don't delete `render_conf.json` because it's literally impossible for that to be the source of a problem right now delete_artifacts(dir.clone(), "static")?; - delete_artifacts(dir.clone(), "pkg")?; + delete_artifacts(dir, "pkg")?; } else { // This command deletes the `.perseus/` directory completely, which musn't happen if the user has ejected if has_ejected(dir.clone()) && prog_args[1] != "--force" { diff --git a/packages/perseus-cli/src/build.rs b/packages/perseus-cli/src/build.rs index 09c51e4a74..8ca7a23739 100644 --- a/packages/perseus-cli/src/build.rs +++ b/packages/perseus-cli/src/build.rs @@ -72,7 +72,7 @@ pub fn build_internal( let sg_target = target.clone(); let wb_spinner = spinners.insert(1, ProgressBar::new_spinner()); let wb_spinner = cfg_spinner(wb_spinner, &wb_msg); - let wb_target = target.clone(); + let wb_target = target; let sg_thread = spawn_thread(move || { handle_exit_code!(run_stage( vec![&format!( @@ -106,7 +106,6 @@ pub fn build_internal( /// Builds the subcrates to get a directory that we can serve. Returns an exit code. pub fn build(dir: PathBuf, _prog_args: &[String]) -> Result { let spinners = MultiProgress::new(); - // TODO support watching files let (sg_thread, wb_thread) = build_internal(dir.clone(), &spinners, 2)?; let sg_res = sg_thread diff --git a/packages/perseus-cli/src/errors.rs b/packages/perseus-cli/src/errors.rs index fd285e1937..53e985a7c5 100644 --- a/packages/perseus-cli/src/errors.rs +++ b/packages/perseus-cli/src/errors.rs @@ -99,6 +99,16 @@ error_chain! { description("can't clean after ejection unless `--force` is provided") display("The `clean` command removes the entire `.perseus/` directory, and you've already ejected, meaning that you can make modifications to that directory. If you proceed with this command, any modifications you've made to `.perseus/` will be PERMANENTLY lost! If you're sure you want to proceed, run `perseus clean --force`.") } + /// For when copying an asset into the export package failed. + MoveExportAssetFailed(to: String, from: String, err: String) { + description("couldn't copy asset for exporting") + display("Couldn't copy asset from '{}' to '{}' for exporting. Error was: '{}'.", to, from, err) + } + /// For when creating the export directory structure failed. + ExportDirStructureFailed { + description("couldn't create necessary directory structure for exporting") + display("Couldn't create directory structure necessary for exporting. Please ensure that you have the necessary permissions to write in this folder.") + } } } diff --git a/packages/perseus-cli/src/export.rs b/packages/perseus-cli/src/export.rs new file mode 100644 index 0000000000..7b84c78b48 --- /dev/null +++ b/packages/perseus-cli/src/export.rs @@ -0,0 +1,211 @@ +use crate::cmd::{cfg_spinner, run_stage}; +use crate::errors::*; +use crate::thread::{spawn_thread, ThreadHandle}; +use console::{style, Emoji}; +use indicatif::{MultiProgress, ProgressBar}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +// Emojis for stages +static EXPORTING: Emoji<'_, '_> = Emoji("📦", ""); +static BUILDING: Emoji<'_, '_> = Emoji("🏗️ ", ""); // Yes, there's a space here, for some reason it's needed... + +/// Returns the exit code if it's non-zero. +macro_rules! handle_exit_code { + ($code:expr) => { + let (_, _, code) = $code; + if code != 0 { + return $crate::errors::Result::Ok(code); + } + }; +} + +/// An internal macro for copying files into the export package. The `from` and `to` that this accepts should be extensions of the +/// `target`, and they'll be `.join()`ed on. +macro_rules! copy_file { + ($from:expr, $to:expr, $target:expr) => { + if let Err(err) = fs::copy($target.join($from), $target.join($to)) { + bail!(ErrorKind::MoveExportAssetFailed( + $from.to_string(), + $to.to_string(), + err.to_string() + )); + } + }; +} + +/// Finalizes the export by copying assets. This is very different from the finalization process of normal building. +pub fn finalize_export(target: &Path) -> Result<()> { + // Move the `pkg/` directory into `dist/pkg/` as usual + let pkg_dir = target.join("dist/pkg"); + if pkg_dir.exists() { + if let Err(err) = fs::remove_dir_all(&pkg_dir) { + bail!(ErrorKind::MovePkgDirFailed(err.to_string())); + } + } + // The `fs::rename()` function will fail on Windows if the destination already exists, so this should work (we've just deleted it as per https://github.com/rust-lang/rust/issues/31301#issuecomment-177117325) + if let Err(err) = fs::rename(target.join("pkg"), target.join("dist/pkg")) { + bail!(ErrorKind::MovePkgDirFailed(err.to_string())); + } + + // Copy files over (the directory structure should already exist from exporting the pages) + copy_file!( + "dist/pkg/perseus_cli_builder.js", + "dist/exported/.perseus/bundle.js", + target + ); + copy_file!( + "dist/pkg/perseus_cli_builder_bg.wasm", + "dist/exported/.perseus/bundle.wasm", + target + ); + // Copy any JS snippets over (if the directory doesn't exist though, don't do anything) + // This takes a target of the `dist/` directory, and then extends on that + fn copy_snippets(ext: &str, parent: &Path) -> Result<()> { + // We read from the parent directory (`.perseus`), extended with `ext` + if let Ok(snippets) = fs::read_dir(&parent.join(ext)) { + for file in snippets { + let path = match file { + Ok(file) => file.path(), + Err(err) => bail!(ErrorKind::MoveExportAssetFailed( + "js snippet".to_string(), + "exportable js snippet".to_string(), + err.to_string() + )), + }; + // Recurse on any directories and copy any files + if path.is_dir() { + // We continue to pass on the parent, but we add the filename of this directory to the extension + copy_snippets( + &format!("{}/{}", ext, path.file_name().unwrap().to_str().unwrap()), + parent, + )?; + } else { + // `ext` holds the folder structure of this file, which we'll preserve + // We must remove the prefix though (which is hardcoded in the initial invocation of this function) + let dir_tree = ext.strip_prefix("dist/pkg/snippets").unwrap(); + // This is to avoid `//` + let dir_tree = if dir_tree.is_empty() { + String::new() + } else if dir_tree.starts_with('/') { + dir_tree.to_string() + } else { + format!("/{}", dir_tree) + }; + let filename = path.file_name().unwrap().to_str().unwrap(); + let final_dir_tree = + parent.join(format!("dist/exported/.perseus/snippets{}", dir_tree)); + let path_to_copy_to = parent.join(&format!( + "dist/exported/.perseus/snippets{}/{}", + dir_tree, filename + )); + // Create the directory structure needed for this + if fs::create_dir_all(&final_dir_tree).is_err() { + bail!(ErrorKind::ExportDirStructureFailed); + } + copy_file!( + path.to_str().unwrap(), + path_to_copy_to.to_str().unwrap(), + parent + ); + } + } + } + + Ok(()) + } + copy_snippets("dist/pkg/snippets", target)?; + + Ok(()) +} + +/// Actually exports the user's code, program arguments having been interpreted. This needs to know how many steps there are in total +/// because the serving logic also uses it. This also takes a `MultiProgress` to interact with so it can be used truly atomically. +/// This returns handles for waiting on the component threads so we can use it composably. +#[allow(clippy::type_complexity)] +pub fn export_internal( + dir: PathBuf, + spinners: &MultiProgress, + num_steps: u8, +) -> Result<( + ThreadHandle Result, Result>, + ThreadHandle Result, Result>, +)> { + let target = dir.join(".perseus"); + + // Exporting pages message + let ep_msg = format!( + "{} {} Exporting your app's pages", + style(format!("[1/{}]", num_steps)).bold().dim(), + EXPORTING + ); + // Wasm building message + let wb_msg = format!( + "{} {} Building your app to Wasm", + style(format!("[2/{}]", num_steps)).bold().dim(), + BUILDING + ); + + // We parallelize the first two spinners (static generation and Wasm building) + // We make sure to add them at the top (the server spinner may have already been instantiated) + let ep_spinner = spinners.insert(0, ProgressBar::new_spinner()); + let ep_spinner = cfg_spinner(ep_spinner, &ep_msg); + let ep_target = target.clone(); + let wb_spinner = spinners.insert(1, ProgressBar::new_spinner()); + let wb_spinner = cfg_spinner(wb_spinner, &wb_msg); + let wb_target = target; + let ep_thread = spawn_thread(move || { + handle_exit_code!(run_stage( + vec![&format!( + "{} run --bin perseus-exporter", + env::var("PERSEUS_CARGO_PATH").unwrap_or_else(|_| "cargo".to_string()) + )], + &ep_target, + &ep_spinner, + &ep_msg + )?); + + Ok(0) + }); + let wb_thread = spawn_thread(move || { + handle_exit_code!(run_stage( + vec![&format!( + "{} build --target web", + env::var("PERSEUS_WASM_PACK_PATH").unwrap_or_else(|_| "wasm-pack".to_string()) + )], + &wb_target, + &wb_spinner, + &wb_msg + )?); + + Ok(0) + }); + + Ok((ep_thread, wb_thread)) +} + +/// Builds the subcrates to get a directory that we can serve. Returns an exit code. +pub fn export(dir: PathBuf, _prog_args: &[String]) -> Result { + let spinners = MultiProgress::new(); + + let (ep_thread, wb_thread) = export_internal(dir.clone(), &spinners, 2)?; + let ep_res = ep_thread + .join() + .map_err(|_| ErrorKind::ThreadWaitFailed)??; + if ep_res != 0 { + return Ok(ep_res); + } + let wb_res = wb_thread + .join() + .map_err(|_| ErrorKind::ThreadWaitFailed)??; + if wb_res != 0 { + return Ok(wb_res); + } + + // And now we can run the finalization stage + finalize_export(&dir.join(".perseus"))?; + + // We've handled errors in the component threads, so the exit code is now zero + Ok(0) +} diff --git a/packages/perseus-cli/src/lib.rs b/packages/perseus-cli/src/lib.rs index a2444f1567..d39600a30d 100644 --- a/packages/perseus-cli/src/lib.rs +++ b/packages/perseus-cli/src/lib.rs @@ -31,6 +31,7 @@ mod build; mod cmd; mod eject; pub mod errors; +mod export; mod help; mod prepare; mod serve; @@ -46,6 +47,7 @@ use std::path::PathBuf; pub const PERSEUS_VERSION: &str = env!("CARGO_PKG_VERSION"); pub use build::build; pub use eject::{eject, has_ejected}; +pub use export::export; pub use help::help; pub use prepare::{check_env, prepare}; pub use serve::serve; diff --git a/packages/perseus-cli/src/serve.rs b/packages/perseus-cli/src/serve.rs index 21c068ff52..4b5141f64c 100644 --- a/packages/perseus-cli/src/serve.rs +++ b/packages/perseus-cli/src/serve.rs @@ -54,7 +54,7 @@ fn build_server( // We deliberately insert the spinner at the end of the list let sb_spinner = spinners.insert(num_steps - 1, ProgressBar::new_spinner()); let sb_spinner = cfg_spinner(sb_spinner, &sb_msg); - let sb_target = target.clone(); + let sb_target = target; let sb_thread = spawn_thread(move || { let (stdout, _stderr) = handle_exit_code!(run_stage( vec![&format!( @@ -203,7 +203,7 @@ pub fn serve(dir: PathBuf, prog_args: &[String]) -> Result { // Now actually run that executable path if we should if should_run { - let exit_code = run_server(Arc::clone(&exec), dir.clone(), did_build)?; + let exit_code = run_server(Arc::clone(&exec), dir, did_build)?; Ok(exit_code) } else { // The user doesn't want to run the server, so we'll give them the executable path instead diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index cc851f49d8..5260c8f7c6 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,14 @@ 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 +162,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 +174,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 +195,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 +212,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 +223,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/config_manager.rs b/packages/perseus/src/config_manager.rs index fe18ae4996..c899c7fc83 100644 --- a/packages/perseus/src/config_manager.rs +++ b/packages/perseus/src/config_manager.rs @@ -38,6 +38,8 @@ pub trait ConfigManager: Clone { /// The default config manager. This will store static files in the specified location on disk. This should be suitable for nearly all /// development and serverful use-cases. Serverless is another matter though (more development needs to be done). +/// +/// Note: the `.write()` methods on this implementation will create any missing parent directories automatically. #[derive(Clone)] pub struct FsConfigManager { root_path: String, @@ -61,8 +63,14 @@ impl ConfigManager for FsConfigManager { Err(err) => bail!(ErrorKind::ReadFailed(name.to_string(), err.to_string())), } } + // This creates a directory structure as necessary async fn write(&self, name: &str, content: &str) -> Result<()> { let asset_path = format!("{}/{}", self.root_path, name); + let mut dir_tree: Vec<&str> = asset_path.split('/').collect(); + dir_tree.pop(); + + fs::create_dir_all(dir_tree.join("/")) + .map_err(|err| ErrorKind::WriteFailed(asset_path.clone(), err.to_string()))?; fs::write(&asset_path, content) .map_err(|err| ErrorKind::WriteFailed(asset_path, err.to_string()).into()) } 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..64e7fc85d0 --- /dev/null +++ b/packages/perseus/src/export.rs @@ -0,0 +1,152 @@ +use crate::errors::*; +use crate::get_render_cfg; +use crate::html_shell::{interpolate_page_data, prep_html_shell}; +use crate::serve::PageData; +use crate::ConfigManager; +use crate::Locales; +use crate::SsrNode; +use crate::TemplateMap; +use crate::TranslationsManager; +use std::fs; + +/// Gets the static page data. +async fn get_static_page_data( + path: &str, + has_state: bool, + config_manager: &impl ConfigManager, +) -> Result { + // Get the partial HTML content and a state to go with it (if applicable) + let content = config_manager + .read(&format!("static/{}.html", path)) + .await?; + let head = config_manager + .read(&format!("static/{}.head.html", path)) + .await?; + let state = match has_state { + true => Some( + config_manager + .read(&format!("static/{}.json", path)) + .await?, + ), + false => None, + }; + // Create an instance of `PageData` + Ok(PageData { + content, + state, + head, + }) +} + +/// 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, + root_id: &str, + config_manager: &impl ConfigManager, + translations_manager: &impl TranslationsManager, +) -> 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 { + // We need the encoded path to reference flattened build artifacts + let path_encoded = urlencoding::encode(&path).to_string(); + // 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 + if locales.using_i18n { + config_manager + .write(&format!("exported/{}.html", path), &html_shell) + .await?; + } + // Check if that template uses build state (in which case it should have a JSON file) + let has_state = template.uses_build_state(); + if locales.using_i18n { + // Loop through all the app's locales + for locale in locales.get_all() { + let page_data = get_static_page_data( + &format!("{}-{}", locale, &path_encoded), + has_state, + config_manager, + ) + .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 = interpolate_page_data(&html_shell, &page_data, root_id); + // We don't add an extension because this will be queried directly + config_manager + .write(&format!("exported/{}/{}.html", locale, &path), &full_html) + .await?; + + // Serialize the page data to JSON and write it as a partial (fetched by the app shell for subsequent loads) + let partial = serde_json::to_string(&page_data).unwrap(); + config_manager + .write( + &format!("exported/.perseus/page/{}/{}.json", locale, &path_encoded), + &partial, + ) + .await?; + } + } else { + let page_data = get_static_page_data( + &format!("{}-{}", locales.default, &path_encoded), + has_state, + config_manager, + ) + .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 = interpolate_page_data(&html_shell, &page_data, root_id); + // We don't add an extension because this will be queried directly by the browser + config_manager + .write(&format!("exported/{}.html", &path), &full_html) + .await?; + + // Serialize the page data to JSON and write it as a partial (fetched by the app shell for subsequent loads) + let partial = serde_json::to_string(&page_data).unwrap(); + config_manager + .write( + &format!( + "exported/.perseus/page/{}/{}.json", + locales.default, &path_encoded + ), + &partial, + ) + .await?; + } + } + // If we're using i18n, loop through the locales + if locales.using_i18n { + for locale in locales.get_all() { + // Get the translations string for that + let translations_str = translations_manager + .get_translations_str_for_locale(locale.to_string()) + .await?; + // Write it to an asset so that it can be served directly + config_manager + .write( + &format!("exported/.perseus/translations/{}", locale), + &translations_str, + ) + .await?; + } + } + // Copying in bundles from the filesystem is left to the CLI command for exporting, so we're done! + + Ok(()) +} diff --git a/packages/perseus/src/html_shell.rs b/packages/perseus/src/html_shell.rs new file mode 100644 index 0000000000..518ab316e0 --- /dev/null +++ b/packages/perseus/src/html_shell.rs @@ -0,0 +1,79 @@ +use crate::serve::PageData; +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 +} + +/// Interpolates content, metadata, and state into the HTML shell, ready to be sent to the user for initial loads. This should be passed +/// an HTMl shell prepared with `prep_html_shell`. This also takes the HTML `id` of the element in the shell to interpolate content +/// into. +pub fn interpolate_page_data(html_shell: &str, page_data: &PageData, root_id: &str) -> String { + // Interpolate the document `` + let html_with_head = html_shell.replace( + "", + &format!("{}", &page_data.head), + ); + + // 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` + let state_var = format!("", { + if let Some(state) = &page_data.state { + state + // If we don't escape quotes, we get runtime syntax errors + .replace(r#"'"#, r#"\'"#) + .replace(r#"""#, r#"\""#) + } 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 html_with_state = html_with_head.replace("", &format!("{}\n", state_var)); + + // Figure out exactly what we're interpolating in terms of content + // The user MUST place have a `
` of this exact form (documented explicitly) + // We permit either double or single quotes + let html_to_replace_double = format!("
", root_id); + let html_to_replace_single = format!("
", root_id); + let html_replacement = format!( + // We give the content a specific ID so that it can be deleted if an error page needs to be rendered on the client-side + "{}
{}
", + &html_to_replace_double, + &page_data.content + ); + // Now interpolate that HTML into the HTML shell + html_with_state + .replace(&html_to_replace_double, &html_replacement) + .replace(&html_to_replace_single, &html_replacement) +} diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index b34bb488dd..6d381ad3cd 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -44,6 +44,10 @@ mod decode_time_str; /// Utilities regarding the formation of error pages for HTTP status codes, like a `404 Not Found` page. pub mod error_pages; pub mod errors; +/// 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; mod locale_detector; mod locales; mod log; @@ -74,6 +78,7 @@ pub use crate::client_translations_manager::ClientTranslationsManager; pub use crate::config_manager::{ConfigManager, FsConfigManager}; pub use crate::error_pages::ErrorPages; pub use crate::errors::{err_to_status_code, ErrorCause}; +pub use crate::export::export_app; pub use crate::locale_detector::detect_locale; pub use crate::locales::Locales; pub use crate::serve::{get_page, get_render_cfg}; diff --git a/packages/perseus/src/serve.rs b/packages/perseus/src/serve.rs index 7211187493..ccf7eee697 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,36 @@ 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)); + let html = 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,15 +134,17 @@ 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 .get_build_state(format!("{}/{}", template.get_path(), path)) .await?, ); - let html = - sycamore::render_to_string(|| template.render_for_template(state.clone(), translator)); + let html = 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 +167,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 +202,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 +213,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 +229,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 +256,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 +283,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 +308,17 @@ 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 +326,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 +338,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 +352,7 @@ pub async fn get_page_for_template_and_translator( let res = PageData { content: html, state, + head, }; Ok(res) @@ -354,20 +382,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..58e2e04f90 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; @@ -263,9 +263,15 @@ pub async fn app_shell( // If we have no initial state, we should proceed as usual, fetching the content and state from the server InitialState::NotPresent => { checkpoint("initial_state_not_present"); + // If we're getting data about the index page, explicitly set it to that + // This can be handled by the Perseus server (and is), but not by static exporting + let path = match path.is_empty() { + true => "index".to_string(), + false => path, + }; // Get the static page data let asset_url = format!( - "/.perseus/page/{}/{}?template_name={}", + "/.perseus/page/{}/{}.json?template_name={}", locale, path.to_string(), template.get_path() @@ -276,7 +282,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 diff --git a/packages/perseus/src/translations_manager.rs b/packages/perseus/src/translations_manager.rs index 4e5fd6edbf..85f3b59f86 100644 --- a/packages/perseus/src/translations_manager.rs +++ b/packages/perseus/src/translations_manager.rs @@ -116,7 +116,7 @@ impl TranslationsManager for FsTranslationsManager { Err(err) if err.kind() == std::io::ErrorKind::NotFound => { bail!(ErrorKind::NotFound(asset_path)) } - Err(err) => bail!(ErrorKind::ReadFailed(locale.to_string(), err.to_string())), + Err(err) => bail!(ErrorKind::ReadFailed(locale, err.to_string())), }; Ok(translations_str) }