Skip to content

Commit

Permalink
feat(templates): ✨ prerendered <head> at build time where possible
Browse files Browse the repository at this point in the history
Also laid foundations for static exporting (still WIP).

Closes #22.
  • Loading branch information
arctic-hen7 committed Sep 24, 2021
1 parent 7c617b8 commit a8f9b18
Show file tree
Hide file tree
Showing 14 changed files with 301 additions and 131 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"cli",
"book",
"examples",
"testing"
"testing",
"templates"
]
}
7 changes: 6 additions & 1 deletion examples/basic/.perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
4 changes: 3 additions & 1 deletion examples/basic/.perseus/src/bin/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!");
Expand Down
42 changes: 42 additions & 0 deletions examples/basic/.perseus/src/bin/export.rs
Original file line number Diff line number Diff line change
@@ -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::<SsrNode>(),
&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
}
}
29 changes: 2 additions & 27 deletions packages/perseus-actix-web/src/configurer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,33 +73,8 @@ pub async fn configurer<C: ConfigManager + 'static, T: TranslationsManager + 'st
.expect("Couldn't get render configuration!");
// Get the index file and inject the render configuration into ahead of time
// Anything done here will affect any status code and all loads
// We do this by injecting 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 a 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 index_file = fs::read_to_string(&opts.index).expect("Couldn't get HTML index file!");
let load_script = r#"<script type="module">
import init, { run } from "/.perseus/bundle.js";
async function main() {
await init("/.perseus/bundle.wasm");
run();
}
main();
</script>"#;
let index_with_render_cfg = index_file.replace(
"</head>",
// It's safe to assume that something we just deserialized will serialize again in this case
&format!(
"<script>window.__PERSEUS_RENDER_CFG = '{}';{testing_var}</script>\n{}\n<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->\n</head>",
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
Expand Down
23 changes: 5 additions & 18 deletions packages/perseus-actix-web/src/initial_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,25 +104,14 @@ pub async fn initial_load<C: ConfigManager, T: TranslationsManager>(
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 {
Expand All @@ -133,12 +122,10 @@ pub async fn initial_load<C: ConfigManager, T: TranslationsManager>(
}
};

// Render the HTML head and interpolate it
let head_str =
template.render_head_str(page_data.state.clone(), Rc::clone(&translator));
// Interpolate the document `<head>`
let html_with_head = html_shell.replace(
"<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->",
&format!("<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->{}", head_str),
&format!("<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->{}", &page_data.head),
);

// Interpolate a global variable of the state so the app shell doesn't have to make any more trips
Expand Down
39 changes: 8 additions & 31 deletions packages/perseus-actix-web/src/page_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -38,18 +37,6 @@ pub async fn page_data<C: ConfigManager, T: TranslationsManager>(
.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 {
Expand All @@ -59,31 +46,21 @@ pub async fn page_data<C: ConfigManager, T: TranslationsManager>(
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())
}
Expand Down
34 changes: 31 additions & 3 deletions packages/perseus/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,22 @@ pub async fn build_template(
template: &Template<SsrNode>,
translator: Rc<Translator>,
config_manager: &impl ConfigManager,
exporting: bool
) -> Result<(Vec<String>, 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() {
Expand Down Expand Up @@ -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 `<head>` 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
Expand Down Expand Up @@ -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?;
}
}

Expand All @@ -102,12 +124,13 @@ async fn build_template_and_get_cfg(
template: &Template<SsrNode>,
translator: Rc<Translator>,
config_manager: &impl ConfigManager,
exporting: bool
) -> Result<HashMap<String, String>> {
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());
Expand Down Expand Up @@ -138,6 +161,7 @@ pub async fn build_templates_for_locale(
templates: &[Template<SsrNode>],
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
Expand All @@ -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?;
Expand All @@ -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(())
}
Expand All @@ -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();
Expand All @@ -195,6 +222,7 @@ pub async fn build_app(
locale.to_string(),
config_manager,
translations_manager,
exporting
));
}
// Build all locales in parallel
Expand Down
Loading

0 comments on commit a8f9b18

Please sign in to comment.