From c8530cf47afcc45585ac346e3e717f516361ca7e Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 27 Jul 2021 19:16:32 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20basic=20sycamore=20?= =?UTF-8?q?ssg=20systems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added from private pre-dev repo --- .gitignore | 2 + Cargo.toml | 8 + bonnie.toml | 4 + examples/README.md | 7 + examples/showcase/.gitignore | 4 + examples/showcase/Cargo.toml | 5 + examples/showcase/app/Cargo.toml | 28 ++++ examples/showcase/app/index.html | 13 ++ examples/showcase/app/main.js | 6 + examples/showcase/app/src/bin/build.rs | 17 +++ examples/showcase/app/src/build.rs | 112 ++++++++++++++ examples/showcase/app/src/config_manager.rs | 63 ++++++++ examples/showcase/app/src/errors.rs | 29 ++++ examples/showcase/app/src/lib.rs | 77 ++++++++++ examples/showcase/app/src/page.rs | 153 ++++++++++++++++++++ examples/showcase/app/src/pages/about.rs | 17 +++ examples/showcase/app/src/pages/index.rs | 30 ++++ examples/showcase/app/src/pages/mod.rs | 3 + examples/showcase/app/src/pages/post.rs | 52 +++++++ examples/showcase/app/src/render_cfg.rs | 22 +++ examples/showcase/app/src/serve.rs | 49 +++++++ examples/showcase/app/src/shell.rs | 99 +++++++++++++ examples/showcase/bonnie.toml | 14 ++ examples/showcase/server/Cargo.toml | 13 ++ examples/showcase/server/src/main.rs | 50 +++++++ src/lib.rs | 7 + 26 files changed, 884 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 bonnie.toml create mode 100644 examples/README.md create mode 100644 examples/showcase/.gitignore create mode 100644 examples/showcase/Cargo.toml create mode 100644 examples/showcase/app/Cargo.toml create mode 100644 examples/showcase/app/index.html create mode 100644 examples/showcase/app/main.js create mode 100644 examples/showcase/app/src/bin/build.rs create mode 100644 examples/showcase/app/src/build.rs create mode 100644 examples/showcase/app/src/config_manager.rs create mode 100644 examples/showcase/app/src/errors.rs create mode 100644 examples/showcase/app/src/lib.rs create mode 100644 examples/showcase/app/src/page.rs create mode 100644 examples/showcase/app/src/pages/about.rs create mode 100644 examples/showcase/app/src/pages/index.rs create mode 100644 examples/showcase/app/src/pages/mod.rs create mode 100644 examples/showcase/app/src/pages/post.rs create mode 100644 examples/showcase/app/src/render_cfg.rs create mode 100644 examples/showcase/app/src/serve.rs create mode 100644 examples/showcase/app/src/shell.rs create mode 100644 examples/showcase/bonnie.toml create mode 100644 examples/showcase/server/Cargo.toml create mode 100644 examples/showcase/server/src/main.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..96ef6c0b94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000..010abd9f43 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "perseus" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/bonnie.toml b/bonnie.toml new file mode 100644 index 0000000000..f82e220c2e --- /dev/null +++ b/bonnie.toml @@ -0,0 +1,4 @@ +version="0.3.1" + +[scripts] +start = "echo \"No start script yet!\"" diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..d74e4a39f2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,7 @@ +# Examples + +This folder contains examples for Perseus, which are used to test the project and are excellent learning resources! If any of these don't work, please [open an issue](https://github.com/arctic-hen7/perseus/issues/choose) to let us know! + +These examples are all fully self-contained, and do not serve as examples in the traditional Cargo way, they are each indepedent crates to enable the use of build tools such as `wasm-pack`. + +- Showcase -- an app that demonstrates all the different features of Perseus, including SSR, SSG, and ISR (this example is actively used for testing) diff --git a/examples/showcase/.gitignore b/examples/showcase/.gitignore new file mode 100644 index 0000000000..57a7f51c89 --- /dev/null +++ b/examples/showcase/.gitignore @@ -0,0 +1,4 @@ +target/ +Cargo.lock +dist/ +pkg/ \ No newline at end of file diff --git a/examples/showcase/Cargo.toml b/examples/showcase/Cargo.toml new file mode 100644 index 0000000000..9159e6c9db --- /dev/null +++ b/examples/showcase/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "app", + "server" +] \ No newline at end of file diff --git a/examples/showcase/app/Cargo.toml b/examples/showcase/app/Cargo.toml new file mode 100644 index 0000000000..5cf539bc4b --- /dev/null +++ b/examples/showcase/app/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "perseus-showcase-app" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +sycamore = { version = "0.5.1", features = ["ssr"] } +sycamore-router = "0.5.1" +web-sys = { version = "0.3", features = ["Headers", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window"] } +wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } +wasm-bindgen-futures = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +typetag = "0.1" +error-chain = "0.12" +futures = "0.3" +console_error_panic_hook = "0.1.6" +urlencoding = "2.1" + +# This section is needed for WASM Pack (which we use instead of Trunk for flexibility) +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "ssg" +path = "src/bin/build.rs" diff --git a/examples/showcase/app/index.html b/examples/showcase/app/index.html new file mode 100644 index 0000000000..2b1a3ffa66 --- /dev/null +++ b/examples/showcase/app/index.html @@ -0,0 +1,13 @@ + + + + + + + Perseus Starter App + + + +
+ + diff --git a/examples/showcase/app/main.js b/examples/showcase/app/main.js new file mode 100644 index 0000000000..097e12c30f --- /dev/null +++ b/examples/showcase/app/main.js @@ -0,0 +1,6 @@ +import init, { run } from "./pkg/perseus_showcase_app.js"; +async function main() { + await init("/.perseus/bundle.wasm"); + run(); +} +main(); diff --git a/examples/showcase/app/src/bin/build.rs b/examples/showcase/app/src/bin/build.rs new file mode 100644 index 0000000000..25cee72454 --- /dev/null +++ b/examples/showcase/app/src/bin/build.rs @@ -0,0 +1,17 @@ +use perseus_showcase_app::{ + pages, + config_manager::{FsConfigManager, ConfigManager}, + build_pages +}; + +fn main() { + let config_manager = FsConfigManager::new(); + + build_pages!([ + pages::index::get_page(), + pages::about::get_page(), + pages::post::get_page() + ], &config_manager); + + println!("Static generation successfully completed!"); +} diff --git a/examples/showcase/app/src/build.rs b/examples/showcase/app/src/build.rs new file mode 100644 index 0000000000..cf37bceaad --- /dev/null +++ b/examples/showcase/app/src/build.rs @@ -0,0 +1,112 @@ +// This binary builds all the pages with SSG + +use serde::{Serialize, de::DeserializeOwned}; +use crate::{ + page::Page, + config_manager::ConfigManager, + render_cfg::RenderOpt +}; +use crate::errors::*; +use std::any::Any; + +/// Builds a page, writing static data as appropriate. This should be used as part of a larger build process. +pub fn build_page(page: Page, config_manager: &impl ConfigManager) -> Result> { + let mut render_opts: Vec = Vec::new(); + let page_path = page.get_path(); + + // Handle the boolean properties + if page.revalidates() { + render_opts.push(RenderOpt::Revalidated); + } + if page.uses_incremental() { + render_opts.push(RenderOpt::Incremental); + } + + // 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 page.uses_build_paths() { + true => { + render_opts.push(RenderOpt::StaticPaths); + page.get_build_paths()? + }, + false => vec![page_path.clone()] + }; + + // Iterate through the paths to generate initial states if needed + for path in paths.iter() { + // If needed, we'll contruct a full path that's URL encoded so we can easily save it as a file + // BUG: insanely nested paths won't work whatsoever if the filename is too long, maybe hash instead? + let full_path = match render_opts.contains(&RenderOpt::StaticPaths) { + true => urlencoding::encode(&format!("{}/{}", &page_path, path)).to_string(), + // We don't want to concatenate the name twice if we don't have to + false => page_path.clone() + }; + + // Handle static initial state generation + // We'll only write a static state if one is explicitly generated + if page.uses_build_state() { + render_opts.push(RenderOpt::StaticProps); + // We pass in the latter part of the path, without the base specifier (because that would be the same for everything in the template) + let initial_state = page.get_build_state(path.to_string())?; + let initial_state_str = serde_json::to_string(&initial_state).unwrap(); + // Write that intial state to a static JSON file + config_manager + .write(&format!("./dist/static/{}.json", full_path), &initial_state_str) + .unwrap(); + // Prerender the page using that state + let prerendered = sycamore::render_to_string( + || + page.render_for_template(Some(initial_state)) + ); + // Write that prerendered HTML to a static file + config_manager + .write(&format!("./dist/static/{}.html", full_path), &prerendered) + .unwrap(); + } + + // Handle server-side rendering + // By definition, everything here is done at request-time, so there's not really much to do + // Note also that if a page only uses SSR, it won't get prerendered at build time whatsoever + if page.uses_request_state() { + render_opts.push(RenderOpt::Server); + } + + // If the page is very basic, prerender without any state + if page.is_basic() { + render_opts.push(RenderOpt::StaticProps); + let prerendered = sycamore::render_to_string( + || + page.render_for_template(None) + ); + // Write that prerendered HTML to a static file + config_manager + .write(&format!("./dist/static/{}.html", full_path), &prerendered) + .unwrap(); + } + } + + Ok(render_opts) +} + +/// Runs the build process of building many different pages. This is done with a macro because typing for a function means we have to do +/// things on the heap. +/// (Any better solutions are welcome in PRs!) +#[macro_export] +macro_rules! build_pages { + ( + [$($page:expr),+], + $config_manager:expr + ) => { + let mut render_conf: $crate::render_cfg::RenderCfg = ::std::collections::HashMap::new(); + $( + render_conf.insert( + $page.get_path(), + $crate::build::build_page($page, $config_manager) + .unwrap() + ); + )+ + $config_manager + .write("./dist/render_conf.json", &serde_json::to_string(&render_conf).unwrap()) + .unwrap(); + }; +} diff --git a/examples/showcase/app/src/config_manager.rs b/examples/showcase/app/src/config_manager.rs new file mode 100644 index 0000000000..bff50a9f85 --- /dev/null +++ b/examples/showcase/app/src/config_manager.rs @@ -0,0 +1,63 @@ +// This file contains the logic for a universal interface to read and write to static files +// At simplest, this is just a filesystem interface, but it's more likely to be a CMS in production +// This has its own error management logic because the user may implement it separately + +use std::fs; +use error_chain::{error_chain, bail}; + +// This has no foreign links because everything to do with config management should be isolated and generic +error_chain! { + errors { + /// For when data wasn't found. + NotFound(name: String) { + description("data not found") + display("data with name '{}' not found", name) + } + /// For when data couldn't be read for some generic reason. + ReadFailed(name: String, err: String) { + description("data couldn't be read") + display("data with name '{}' couldn't be read, error was '{}'", name, err) + } + /// For when data couldn't be written for some generic reason. + WriteFailed(name: String, err: String) { + description("data couldn't be written") + display("data with name '{}' couldn't be written, error was '{}'", name, err) + } + } +} + +/// A trait for systems that manage where to put configuration files. At simplest, we'll just write them to static files, but they're +/// more likely to be stored on a CMS. +pub trait ConfigManager { + /// Reads data from the named asset. + fn read(&self, name: &str) -> Result; + /// Writes data to the named asset. This will create a new asset if on edoesn't exist already. + fn write(&self, name: &str, content: &str) -> Result<()>; +} + +#[derive(Default)] +pub struct FsConfigManager {} +impl FsConfigManager { + /// Creates a new filesystem configuration manager. This function only exists to preserve the API surface of the trait. + pub fn new() -> Self { + Self::default() + } +} +impl ConfigManager for FsConfigManager { + fn read(&self, name: &str) -> Result { + match fs::metadata(name) { + Ok(_) => fs::read_to_string(name).map_err( + |err| + ErrorKind::ReadFailed(name.to_string(), err.to_string()).into() + ), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => bail!(ErrorKind::NotFound(name.to_string())), + Err(err) => bail!(ErrorKind::ReadFailed(name.to_string(), err.to_string())) + } + } + fn write(&self, name: &str, content: &str) -> Result<()> { + fs::write(name, content).map_err( + |err| + ErrorKind::WriteFailed(name.to_string(), err.to_string()).into() + ) + } +} diff --git a/examples/showcase/app/src/errors.rs b/examples/showcase/app/src/errors.rs new file mode 100644 index 0000000000..fe37600eb5 --- /dev/null +++ b/examples/showcase/app/src/errors.rs @@ -0,0 +1,29 @@ +#![allow(missing_docs)] + +pub use error_chain::bail; +use error_chain::error_chain; + +// The `error_chain` setup for the whole crate +error_chain! { + // The custom errors for this crate (very broad) + errors { + /// For indistinct JavaScript errors. + JsErr(err: String) { + description("an error occurred while interfacing with javascript") + display("the following error occurred while interfacing with javascript: {:?}", err) + } + + PageFeatureNotEnabled(name: String, feature: String) { + description("a page feature required by a function called was not present") + display("the page '{}' is missing the feature '{}'", name, feature) + } + } + links { + ConfigManager(crate::config_manager::Error, crate::config_manager::ErrorKind); + } + // We work with many external libraries, all of which have their own errors + foreign_links { + Io(::std::io::Error); + Json(::serde_json::Error); + } +} diff --git a/examples/showcase/app/src/lib.rs b/examples/showcase/app/src/lib.rs new file mode 100644 index 0000000000..856658253d --- /dev/null +++ b/examples/showcase/app/src/lib.rs @@ -0,0 +1,77 @@ +pub mod errors; +pub mod pages; +mod shell; +pub mod serve; +pub mod render_cfg; +pub mod config_manager; +pub mod page; +pub mod build; + +use sycamore::prelude::*; +use sycamore_router::{Route, BrowserRouter}; +use wasm_bindgen::prelude::*; + +// Define our routes +#[derive(Route)] +enum AppRoute { + #[to("/")] + Index, + #[to("/about")] + About, + #[to("/post/")] + Post { + slug: String + }, + #[not_found] + NotFound +} + +// This is deliberately purely client-side rendered +#[wasm_bindgen] +pub fn run() -> Result<(), JsValue> { + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + // Get the root (for the router) we'll be injecting page content into + let root = web_sys::window() + .unwrap() + .document() + .unwrap() + .query_selector("#_perseus_root") + .unwrap() + .unwrap(); + + sycamore::render_to( + || + template! { + BrowserRouter(|route: AppRoute| { + match route { + AppRoute::Index => app_shell!({ + name => "index", + props => pages::index::IndexPageProps, + template => |props: Option| template! { + pages::index::IndexPage(props.unwrap()) + }, + }), + AppRoute::About => app_shell!({ + name => "about", + template => |_: Option<()>| template! { + pages::about::AboutPage() + }, + }), + AppRoute::Post { slug } => app_shell!({ + name => &format!("post/{}", slug), + props => pages::post::PostPageProps, + template => |props: Option| template! { + pages::post::PostPage(props.unwrap()) + }, + }), + AppRoute::NotFound => template! { + p {"Not Found."} + } + } + }) + }, + &root + ); + + Ok(()) +} diff --git a/examples/showcase/app/src/page.rs b/examples/showcase/app/src/page.rs new file mode 100644 index 0000000000..9fb0b5eb04 --- /dev/null +++ b/examples/showcase/app/src/page.rs @@ -0,0 +1,153 @@ +// This file contains logic to define how pages are rendered + +use crate::errors::*; +use serde::{Serialize, de::DeserializeOwned}; + +// A series of closure types that should not be typed out more than once +// TODO maybe make these public? +type TemplateFnReturn = sycamore::prelude::Template; +type TemplateFn = Box) -> TemplateFnReturn>; +type GetBuildPathsFn = Box Vec>; +type GetBuildStateFn = Box Props>; +type GetRequestStateFn = Box Props>; +type ShouldRevalidateFn = Box bool>; + +/// This allows the specification of all the page templates in an app and how to render them. If no rendering logic is provided at all, +/// the page will be prerendered at build-time with no state. All closures are stored on the heap to avoid hellish lifetime specification. +pub struct Page +{ + /// The path to the root of the template. Any build paths will be inserted under this. + path: String, + /// A function that will render your page. This will be provided the rendered properties, and will be used whenever your page needs + /// to be prerendered in some way. This should be very similar to the function that hydrates your page on the client side. + /// This will be executed inside `sycamore::render_to_string`, and should return a `Template`. This takes an `Option` + /// because otherwise efficient typing is almost impossible for pages without any properties (solutions welcome in PRs!). + template: TemplateFn, + /// A function that gets the paths to render for at built-time. This is equivalent to `get_static_paths` in NextJS. If + /// `incremental_path_rendering` is `true`, more paths can be rendered at request time on top of these. + get_build_paths: Option, + /// Defiens whether or not any new paths that match this template will be prerendered and cached in production. This allows you to + /// have potentially billions of pages and retain a super-fast build process. The first user will have an ever-so-slightly slower + /// experience, and everyone else gets the beneftis afterwards. + incremental_path_rendering: bool, + /// A function that gets the initial state to use to prerender the page at build time. This will be passed the path of the page, and + /// will be run for any sub-paths. This is equivalent to `get_static_props` in NextJS. + get_build_state: Option>, + /// A function that will run on every request to generate a state for that request. This allows server-side-rendering. This is equivalent + /// to `get_server_side_props` in NextJS. This can be used with `get_build_state`, though custom amalgamation logic must be provided. + // TODO add request data to be passed in here + get_request_state: Option>, + /// A function to be run on every request to check if a page prerendered at build-time should be prerendered again. This is equivalent + /// to incremental static rendering (ISR) in NextJS. If used with `revalidate_after`, this function will only be run after that time + /// period. This function will not be parsed anything specific to the request that invoked it. + should_revalidate: Option, + /// A length of time after which to prerender the page again. This is equivalent to ISR in NextJS. + revalidate_after: Option, +} +impl Page { + /// Creates a new page definition. + pub fn new(path: impl Into + std::fmt::Display) -> Self { + Self { + path: path.to_string(), + // We only need the `Props` generic here + template: Box::new(|_: Option| sycamore::template! {}), + get_build_paths: None, + incremental_path_rendering: false, + get_build_state: None, + get_request_state: None, + should_revalidate: None, + revalidate_after: None, + } + } + + // Render executors + /// Executes the user-given function that renders the page on the server-side (build or request time). + pub fn render_for_template(&self, props: Option) -> TemplateFnReturn { + (self.template)(props) + } + /// Gets the list of pages that should be prerendered for at build-time. + pub fn get_build_paths(&self) -> Result> { + if let Some(get_build_paths) = &self.get_build_paths { + // TODO support error handling for render functions + Ok(get_build_paths()) + } else { + bail!(ErrorKind::PageFeatureNotEnabled(self.path.clone(), "build_paths".to_string())) + } + } + /// Gets the initial state for a page. This needs to be passed the full path of the page, which may be one of those generated by + /// `.get_build_paths()`. + pub fn get_build_state(&self, path: String) -> Result { + if let Some(get_build_state) = &self.get_build_state { + // TODO support error handling for render functions + Ok(get_build_state(path)) + } else { + bail!(ErrorKind::PageFeatureNotEnabled(self.path.clone(), "build_state".to_string())) + } + } + + // Value getters + /// Gets the path of the page. + pub fn get_path(&self) -> String { + self.path.clone() + } + + // Render characteristic checkers + /// Checks if this page can revalidate existing prerendered pages. + pub fn revalidates(&self) -> bool { + self.should_revalidate.is_some() || self.revalidate_after.is_some() + } + /// Checks if this page can render more pages beyond those paths it explicitly defines. + pub fn uses_incremental(&self) -> bool { + self.incremental_path_rendering + } + /// Checks if this page is a template to generate paths beneath it. + pub fn uses_build_paths(&self) -> bool { + self.get_build_paths.is_some() + } + /// Checks if this page needs to do anything on requests for it. + pub fn uses_request_state(&self) -> bool { + self.get_request_state.is_some() + } + /// Checks if this page needs to do anything at build time. + pub fn uses_build_state(&self) -> bool { + self.get_build_state.is_some() + } + /// Checks if this page defines no rendering logic whatsoever. Such pages will be rendered using SSG. + pub fn is_basic(&self) -> bool { + !self.uses_build_paths() && + !self.uses_build_state() && + !self.uses_request_state() && + !self.revalidates() && + !self.uses_incremental() + } + + // Builder setters + pub fn template(mut self, val: TemplateFn) -> Page { + self.template = val; + self + } + pub fn build_paths_fn(mut self, val: GetBuildPathsFn) -> Page { + self.get_build_paths = Some(val); + self + } + pub fn incremental_path_rendering(mut self, val: bool) -> Page { + self.incremental_path_rendering = val; + self + } + pub fn build_state_fn(mut self, val: GetBuildStateFn) -> Page { + self.get_build_state = Some(val); + self + } + pub fn request_state_fn(mut self, val: GetRequestStateFn) -> Page { + self.get_request_state = Some(val); + self + } + pub fn should_revalidate(mut self, val: ShouldRevalidateFn) -> Page { + self.should_revalidate = Some(val); + self + } + pub fn revalidate_after(mut self, val: String) -> Page { + self.revalidate_after = Some(val); + self + } +} diff --git a/examples/showcase/app/src/pages/about.rs b/examples/showcase/app/src/pages/about.rs new file mode 100644 index 0000000000..962103be78 --- /dev/null +++ b/examples/showcase/app/src/pages/about.rs @@ -0,0 +1,17 @@ +use sycamore::prelude::*; +use crate::page::Page; + +#[component(AboutPage)] +pub fn about_page() -> Template { + template! { + p { "About." } + } +} + +pub fn get_page() -> Page<()> { + Page::new("about") + .template(Box::new(|_| template! { + AboutPage() + } + )) +} diff --git a/examples/showcase/app/src/pages/index.rs b/examples/showcase/app/src/pages/index.rs new file mode 100644 index 0000000000..f9d1c9c4ff --- /dev/null +++ b/examples/showcase/app/src/pages/index.rs @@ -0,0 +1,30 @@ +use sycamore::prelude::*; +use serde::{Serialize, Deserialize}; +use crate::page::Page; + +#[derive(Serialize, Deserialize, Debug)] +pub struct IndexPageProps { + pub greeting: String +} + +#[component(IndexPage)] +pub fn index_page(props: IndexPageProps) -> Template { + template! { + p {(props.greeting)} + a(href = "/about") { "About!" } + } +} + +pub fn get_page() -> Page { + Page::new("index") + .build_state_fn(Box::new(get_static_props)) + .template(Box::new(|props: Option| template! { + IndexPage(props.unwrap()) + })) +} + +pub fn get_static_props(_path: String) -> IndexPageProps { + IndexPageProps { + greeting: "Hello World!".to_string() + } +} diff --git a/examples/showcase/app/src/pages/mod.rs b/examples/showcase/app/src/pages/mod.rs new file mode 100644 index 0000000000..46c63e1501 --- /dev/null +++ b/examples/showcase/app/src/pages/mod.rs @@ -0,0 +1,3 @@ +pub mod index; +pub mod about; +pub mod post; diff --git a/examples/showcase/app/src/pages/post.rs b/examples/showcase/app/src/pages/post.rs new file mode 100644 index 0000000000..2d76381a30 --- /dev/null +++ b/examples/showcase/app/src/pages/post.rs @@ -0,0 +1,52 @@ +use sycamore::prelude::*; +use serde::{Serialize, Deserialize}; +use crate::page::Page; + +#[derive(Serialize, Deserialize)] +pub struct PostPageProps { + title: String, + content: String, +} + +#[component(PostPage)] +pub fn post_page(props: PostPageProps) -> Template { + let title = props.title; + let content = props.content; + template! { + h1 { + (title) + } + p { + (content) + } + } +} + +pub fn get_page() -> Page { + Page::new("post") + .build_paths_fn(Box::new(get_static_paths)) + .build_state_fn(Box::new(get_static_props)) + .incremental_path_rendering(true) + .template(Box::new(|props: Option| template! { + PostPage(props.unwrap()) + })) +} + +pub fn get_static_props(path: String) -> PostPageProps { + let path_vec: Vec<&str> = path.split('/').collect(); + let title_slug = path_vec[0]; + // This is just an example + let title = urlencoding::decode(title_slug).unwrap(); + let content = format!("This is a post entitled '{}'. Its original slug was '{}'.", title, title_slug); + + PostPageProps { + title: title.to_string(), + content + } +} +// TODO +pub fn get_static_paths() -> Vec { + vec![ + "test".to_string() + ] +} diff --git a/examples/showcase/app/src/render_cfg.rs b/examples/showcase/app/src/render_cfg.rs new file mode 100644 index 0000000000..75544362fe --- /dev/null +++ b/examples/showcase/app/src/render_cfg.rs @@ -0,0 +1,22 @@ +use std::collections::HashMap; +use serde::{Serialize, Deserialize}; + +/// The different options for rendering. Each page has at least one of these that specifies what should be done at build and request-time. +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum RenderOpt { + /// Creates a list of page paths all based on the same template at build-time. `StaticProps` is mandatory with this. + StaticPaths, + /// Any paths not pre-defined at build-time will be prerendered on the server as they would've been were they defined at build-time. + /// They'll then be cached for future requests. If you have a lot of paths to render, this is the best option rather than building them + /// all at build-time. This requires `StaticPaths` and thus `StaticProps`. + Incremental, + /// Prerenders props statically at build-time. + StaticProps, + /// Rerenders static pages based on some condition. This requires `StaticProps`. + Revalidated, + /// Prerenders props dynamically on the server at request-time. If used with `StaticProps`, the properties will be amalgamated and + /// the HTML will be rerendered. Note that this option decreases TTFB, and `StaticProps` should be preferred if possible. + Server, +} + +pub type RenderCfg = HashMap>; diff --git a/examples/showcase/app/src/serve.rs b/examples/showcase/app/src/serve.rs new file mode 100644 index 0000000000..ae4535d4a1 --- /dev/null +++ b/examples/showcase/app/src/serve.rs @@ -0,0 +1,49 @@ +// This file contains the universal logic for a serving process, regardless of framework + +use std::fs; +use serde::{Serialize, Deserialize}; +use crate::errors::*; +use crate::render_cfg::RenderCfg; +use crate::config_manager::ConfigManager; + +/// Represents the data necessary to render a page. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PageData { + /// 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 +} + +/// Gets the configuration of how to render each page. +pub fn get_render_cfg() -> Result { + let content = fs::read_to_string("../app/dist/render_conf.json")?; + let cfg = serde_json::from_str::(&content)?; + + Ok(cfg) +} + +/// Gets the HTML/JSON data for the given page path. This will call SSG/SSR/etc., whatever is needed for that page. +pub fn get_page(raw_path: &str, render_cfg: &RenderCfg, config_manager: &impl ConfigManager) -> Result { + // Remove `/` from the path by encoding it as a URL (that's what we store) + let path = urlencoding::encode(raw_path).to_string(); + // TODO support SSR + + // Get the static HTML + let html = config_manager.read(&format!("../app/dist/static/{}.html", path))?; + // Get the static JSON + let state = config_manager.read(&format!("../app/dist/static/{}.json", path)); + + // Combine everything into one JSON object + let res = PageData { + content: html, + state: match state { + Ok(state) => Some(state), + // TODO bail on errors that aren't `NotFound` + Err(err) => None + }, + }; + + Ok(res) +} diff --git a/examples/showcase/app/src/shell.rs b/examples/showcase/app/src/shell.rs new file mode 100644 index 0000000000..43f5f5fda5 --- /dev/null +++ b/examples/showcase/app/src/shell.rs @@ -0,0 +1,99 @@ +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::{JsFuture}; +use web_sys::{Request, RequestInit, RequestMode, Response}; +use crate::errors::*; + +pub async fn fetch(url: String) -> Result> { + let js_err_handler = |err: JsValue| ErrorKind::JsErr(format!("{:?}", err)); + let mut opts = RequestInit::new(); + opts + .method("GET") + .mode(RequestMode::Cors); + + let request = Request::new_with_str_and_init(&url, &opts).map_err(js_err_handler)?; + + let window = web_sys::window().unwrap(); + // Get the response as a future and await it + let res_value = JsFuture::from(window.fetch_with_request(&request)).await.map_err(js_err_handler)?; + // Turn that into a proper response object + let res: Response = res_value.dyn_into().unwrap(); + // If the status is 404, we should return that the request worked but no file existed + if res.status() == 404 { + return Ok(None); + } + // Get the body thereof + let body_promise = res.text().map_err(js_err_handler)?; + let body = JsFuture::from(body_promise).await.map_err(js_err_handler)?; + + // Convert that into a string (this will be `None` if it wasn't a string in the JS) + // TODO return error if string serialization fails + Ok(body.as_string()) +} + +// This fetches the static resources for a page and renders it +#[macro_export] +macro_rules! app_shell { + ({ + name => $name:expr, + $(props => $props:ty,)? + template => $template:expr, + }) => { + { + // Get the container as a DOM element + let container = NodeRef::new(); + // Spawn a Rust futures thread in the background to fetch the static HTML/JSON + ::wasm_bindgen_futures::spawn_local(cloned!((container) => async move { + // Get the static page data + // If this doesn't exist, then it's a 404 (but we went here by explicit navigation, so there's been an error, should go to a special 404 page) + let page_data_str = $crate::shell::fetch(format!("/.perseus/page/{}", $name)).await; + let page_data_str = match page_data_str { + Ok(page_data) => match page_data { + Some(page_data) => page_data, + None => todo!("404 not yet implemented") + }, + Err(err) => todo!("error unimplemented") + }; + let page_data = serde_json::from_str::<$crate::serve::PageData>(&page_data_str); + let page_data = match page_data { + Ok(page_data) => page_data, + Err(err) => todo!("page data serialization error unimplemented") + }; + + // Interpolate the HTML directly into the document (we'll hydrate it later) + let container_elem = container.get::().unchecked_into::(); + container_elem.set_inner_html(&page_data.content); + + + let state = match page_data.state { + Some(state_str) => { + let mut state = Option::None; + $( + // Serialize that state into a valid properties struct + let state_res = ::serde_json::from_str::<$props>(&state_str); + state = match state_res { + Ok(state) => Some(state), + Err(err) => todo!("serialization error unimplemented") + }; + )? + state + }, + None => None + }; + + // Hydrate that static code using the acquired state + // BUG (Sycamore): this will double-render if the component is just text (no nodes) + sycamore::hydrate_to( + || $template(state), + &container.get::().inner_element() + ); + })); + + // This is where the static content will be rendered + // BUG: white flash of death until Sycamore can suspend the router until the static content is ready + template! { + div(ref = container) + } + } + }; +} diff --git a/examples/showcase/bonnie.toml b/examples/showcase/bonnie.toml new file mode 100644 index 0000000000..e682547265 --- /dev/null +++ b/examples/showcase/bonnie.toml @@ -0,0 +1,14 @@ +version="0.3.1" + +[scripts] +build.cmd = [ + "cd app", + "cargo run --bin ssg", + "wasm-pack build --target web", + "rollup ./main.js --format iife --file ./pkg/bundle.js" +] +build.subcommands.--watch = "find . -not -path \"./target/*\" -not -path \"./.git/*\" | entr -s \"bonnie build\"" +serve = [ + "cd server", + "cargo watch -w ../ -x \"run\"" +] diff --git a/examples/showcase/server/Cargo.toml b/examples/showcase/server/Cargo.toml new file mode 100644 index 0000000000..9c9383c116 --- /dev/null +++ b/examples/showcase/server/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "perseus-showcase-server" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "3.3" +actix-files = "0.5" +urlencoding = "2.1" +serde_json = "1" +perseus-showcase-app = { path = "../app" } diff --git a/examples/showcase/server/src/main.rs b/examples/showcase/server/src/main.rs new file mode 100644 index 0000000000..e0ee07f93b --- /dev/null +++ b/examples/showcase/server/src/main.rs @@ -0,0 +1,50 @@ +use actix_web::{web, App, HttpRequest, HttpServer, Result as ActixResult, error}; +use actix_files::{NamedFile}; +use perseus_showcase_app::serve::{get_render_cfg, get_page}; +use perseus_showcase_app::render_cfg::RenderCfg; +use perseus_showcase_app::config_manager::FsConfigManager; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| { + App::new() + .data( + get_render_cfg().expect("Couldn't get render configuration!") + ) + .data( + FsConfigManager::new() + ) + // TODO chunk JS and WASM bundles + // These allow getting the basic app code (not including the static data) + // 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)) + // 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...) + .route("/.perseus/page/{filename:.*}", web::get().to(page_data)) + // For everything else, we'll serve the app shell directly + .default_service(web::route().to(initial)) + }) + .bind(("localhost", 8080))? + .run() + .await +} + +async fn initial() -> std::io::Result { + NamedFile::open("../app/index.html") +} +async fn js_bundle() -> std::io::Result { + NamedFile::open("../app/pkg/bundle.js") +} +async fn wasm_bundle() -> std::io::Result { + NamedFile::open("../app/pkg/perseus_showcase_app_bg.wasm") +} +async fn page_data(req: HttpRequest, render_cfg: web::Data, config_manager: web::Data) -> ActixResult { + let path = req.match_info().query("filename"); + // TODO match different types of errors here + let page_data = get_page(path, &render_cfg, config_manager.get_ref()).map_err(error::ErrorNotFound)?; + + Ok( + serde_json::to_string(&page_data).unwrap() + ) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000..31e1bb209f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +mod tests { + #[test] + fn it_works() { + assert_eq!(2 + 2, 4); + } +}