Skip to content

Commit

Permalink
feat: ✨ added static exporting (#23)
Browse files Browse the repository at this point in the history
* feat: ✨ `FsConfigManager` now creates directory structures automatically

* feat(templates): ✨ prerendered `<head>` 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
  • Loading branch information
arctic-hen7 authored Sep 24, 2021
1 parent 29344ad commit 4838ba4
Show file tree
Hide file tree
Showing 32 changed files with 835 additions and 188 deletions.
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"cli",
"book",
"examples",
"testing"
"testing",
"templates",
"exporting"
]
}
4 changes: 4 additions & 0 deletions docs/0.2.x/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/0.2.x/src/deploying/exporting.md
Original file line number Diff line number Diff line change
@@ -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!
5 changes: 5 additions & 0 deletions docs/0.2.x/src/deploying/intro.md
Original file line number Diff line number Diff line change
@@ -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.*
4 changes: 4 additions & 0 deletions docs/next/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/next/src/deploying/exporting.md
Original file line number Diff line number Diff line change
@@ -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!
5 changes: 5 additions & 0 deletions docs/next/src/deploying/intro.md
Original file line number Diff line number Diff line change
@@ -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.*
8 changes: 7 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 @@ -23,12 +24,17 @@ 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]
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
87 changes: 87 additions & 0 deletions examples/basic/.perseus/src/bin/export.rs
Original file line number Diff line number Diff line change
@@ -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::<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,
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
}
1 change: 1 addition & 0 deletions examples/i18n/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion examples/i18n/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use perseus::define_app;
define_app! {
templates: [
crate::templates::about::get_template::<G>(),
crate::templates::index::get_template::<G>()
crate::templates::index::get_template::<G>(),
crate::templates::post::get_template::<G>()
],
error_pages: crate::error_pages::get_error_pages(),
locales: {
Expand Down
1 change: 1 addition & 0 deletions examples/i18n/src/templates/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod about;
pub mod index;
pub mod post;
60 changes: 60 additions & 0 deletions examples/i18n/src/templates/post.rs
Original file line number Diff line number Diff line change
@@ -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<G>)]
pub fn post_page(props: PostPageProps) -> SycamoreTemplate<G> {
let title = props.title;
let content = props.content;
template! {
h1 {
(title)
}
p {
(content)
}
}
}

pub fn get_template<G: GenericNode>() -> Template<G> {
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<String> {
// 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<Vec<String>, String> {
Ok(vec!["test".to_string(), "blah/test/blah".to_string()])
}

pub fn template_fn<G: GenericNode>() -> perseus::template::TemplateFn<G> {
Rc::new(|props| {
template! {
PostPage(
serde_json::from_str::<PostPageProps>(&props.unwrap()).unwrap()
)
}
})
}
39 changes: 4 additions & 35 deletions packages/perseus-actix-web/src/configurer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -40,11 +40,6 @@ pub struct Options {
pub static_aliases: HashMap<String, String>,
}

async fn render_conf(
render_conf: web::Data<HashMap<String, String>>,
) -> web::Json<HashMap<String, String>> {
web::Json(render_conf.get_ref().clone())
}
async fn js_bundle(opts: web::Data<Options>) -> std::io::Result<NamedFile> {
NamedFile::open(&opts.js_bundle)
}
Expand Down Expand Up @@ -73,33 +68,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 All @@ -114,12 +84,11 @@ pub async fn configurer<C: ConfigManager + 'static, T: TranslationsManager + 'st
// This contains everything in the spirit of a pseudo-SPA
.route("/.perseus/bundle.js", web::get().to(js_bundle))
.route("/.perseus/bundle.wasm", web::get().to(wasm_bundle))
.route("/.perseus/render_conf.json", web::get().to(render_conf))
// This allows getting the static HTML/JSON of a page
// We stream both together in a single JSON object so SSR works (otherwise we'd have request IDs and weird caching...)
// A request to this should also provide the template name (routing should only be done once on the client) as a query parameter
.route(
"/.perseus/page/{locale}/{filename:.*}",
"/.perseus/page/{locale}/{filename:.*}.json",
web::get().to(page_data::<C, T>),
)
// This allows the app shell to fetch translations for a given page
Expand Down
Loading

0 comments on commit 4838ba4

Please sign in to comment.