Skip to content

Commit

Permalink
feat: ✨ added basic sycamore ssg systems
Browse files Browse the repository at this point in the history
Added from private pre-dev repo
  • Loading branch information
arctic-hen7 committed Jul 27, 2021
1 parent 1d424b5 commit c8530cf
Show file tree
Hide file tree
Showing 26 changed files with 884 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/target
Cargo.lock
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
4 changes: 4 additions & 0 deletions bonnie.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version="0.3.1"

[scripts]
start = "echo \"No start script yet!\""
7 changes: 7 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions examples/showcase/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
target/
Cargo.lock
dist/
pkg/
5 changes: 5 additions & 0 deletions examples/showcase/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[workspace]
members = [
"app",
"server"
]
28 changes: 28 additions & 0 deletions examples/showcase/app/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions examples/showcase/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Perseus Starter App</title>
<script src="/.perseus/bundle.js" defer></script>
</head>
<body>
<div id="_perseus_root"></div>
</body>
</html>
6 changes: 6 additions & 0 deletions examples/showcase/app/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import init, { run } from "./pkg/perseus_showcase_app.js";
async function main() {
await init("/.perseus/bundle.wasm");
run();
}
main();
17 changes: 17 additions & 0 deletions examples/showcase/app/src/bin/build.rs
Original file line number Diff line number Diff line change
@@ -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!");
}
112 changes: 112 additions & 0 deletions examples/showcase/app/src/build.rs
Original file line number Diff line number Diff line change
@@ -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<Props: Serialize + DeserializeOwned + Any>(page: Page<Props>, config_manager: &impl ConfigManager) -> Result<Vec<RenderOpt>> {
let mut render_opts: Vec<RenderOpt> = 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();
};
}
63 changes: 63 additions & 0 deletions examples/showcase/app/src/config_manager.rs
Original file line number Diff line number Diff line change
@@ -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<String>;
/// 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<String> {
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()
)
}
}
29 changes: 29 additions & 0 deletions examples/showcase/app/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
77 changes: 77 additions & 0 deletions examples/showcase/app/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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/<slug>")]
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<pages::index::IndexPageProps>| 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<pages::post::PostPageProps>| template! {
pages::post::PostPage(props.unwrap())
},
}),
AppRoute::NotFound => template! {
p {"Not Found."}
}
}
})
},
&root
);

Ok(())
}
Loading

0 comments on commit c8530cf

Please sign in to comment.