Skip to content

Commit c8530cf

Browse files
committed
feat: ✨ added basic sycamore ssg systems
Added from private pre-dev repo
1 parent 1d424b5 commit c8530cf

26 files changed

+884
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
Cargo.lock

Cargo.toml

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "perseus"
3+
version = "0.1.0"
4+
edition = "2018"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]

bonnie.toml

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
version="0.3.1"
2+
3+
[scripts]
4+
start = "echo \"No start script yet!\""

examples/README.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Examples
2+
3+
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!
4+
5+
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`.
6+
7+
- Showcase -- an app that demonstrates all the different features of Perseus, including SSR, SSG, and ISR (this example is actively used for testing)

examples/showcase/.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
target/
2+
Cargo.lock
3+
dist/
4+
pkg/

examples/showcase/Cargo.toml

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[workspace]
2+
members = [
3+
"app",
4+
"server"
5+
]

examples/showcase/app/Cargo.toml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "perseus-showcase-app"
3+
version = "0.1.0"
4+
edition = "2018"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]
9+
sycamore = { version = "0.5.1", features = ["ssr"] }
10+
sycamore-router = "0.5.1"
11+
web-sys = { version = "0.3", features = ["Headers", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window"] }
12+
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
13+
wasm-bindgen-futures = "0.4"
14+
serde = { version = "1", features = ["derive"] }
15+
serde_json = "1"
16+
typetag = "0.1"
17+
error-chain = "0.12"
18+
futures = "0.3"
19+
console_error_panic_hook = "0.1.6"
20+
urlencoding = "2.1"
21+
22+
# This section is needed for WASM Pack (which we use instead of Trunk for flexibility)
23+
[lib]
24+
crate-type = ["cdylib", "rlib"]
25+
26+
[[bin]]
27+
name = "ssg"
28+
path = "src/bin/build.rs"

examples/showcase/app/index.html

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Perseus Starter App</title>
8+
<script src="/.perseus/bundle.js" defer></script>
9+
</head>
10+
<body>
11+
<div id="_perseus_root"></div>
12+
</body>
13+
</html>

examples/showcase/app/main.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import init, { run } from "./pkg/perseus_showcase_app.js";
2+
async function main() {
3+
await init("/.perseus/bundle.wasm");
4+
run();
5+
}
6+
main();
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
use perseus_showcase_app::{
2+
pages,
3+
config_manager::{FsConfigManager, ConfigManager},
4+
build_pages
5+
};
6+
7+
fn main() {
8+
let config_manager = FsConfigManager::new();
9+
10+
build_pages!([
11+
pages::index::get_page(),
12+
pages::about::get_page(),
13+
pages::post::get_page()
14+
], &config_manager);
15+
16+
println!("Static generation successfully completed!");
17+
}

examples/showcase/app/src/build.rs

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// This binary builds all the pages with SSG
2+
3+
use serde::{Serialize, de::DeserializeOwned};
4+
use crate::{
5+
page::Page,
6+
config_manager::ConfigManager,
7+
render_cfg::RenderOpt
8+
};
9+
use crate::errors::*;
10+
use std::any::Any;
11+
12+
/// Builds a page, writing static data as appropriate. This should be used as part of a larger build process.
13+
pub fn build_page<Props: Serialize + DeserializeOwned + Any>(page: Page<Props>, config_manager: &impl ConfigManager) -> Result<Vec<RenderOpt>> {
14+
let mut render_opts: Vec<RenderOpt> = Vec::new();
15+
let page_path = page.get_path();
16+
17+
// Handle the boolean properties
18+
if page.revalidates() {
19+
render_opts.push(RenderOpt::Revalidated);
20+
}
21+
if page.uses_incremental() {
22+
render_opts.push(RenderOpt::Incremental);
23+
}
24+
25+
// Handle static path generation
26+
// Because we iterate over the paths, we need a base path if we're not generating custom ones (that'll be overriden if needed)
27+
let paths = match page.uses_build_paths() {
28+
true => {
29+
render_opts.push(RenderOpt::StaticPaths);
30+
page.get_build_paths()?
31+
},
32+
false => vec![page_path.clone()]
33+
};
34+
35+
// Iterate through the paths to generate initial states if needed
36+
for path in paths.iter() {
37+
// If needed, we'll contruct a full path that's URL encoded so we can easily save it as a file
38+
// BUG: insanely nested paths won't work whatsoever if the filename is too long, maybe hash instead?
39+
let full_path = match render_opts.contains(&RenderOpt::StaticPaths) {
40+
true => urlencoding::encode(&format!("{}/{}", &page_path, path)).to_string(),
41+
// We don't want to concatenate the name twice if we don't have to
42+
false => page_path.clone()
43+
};
44+
45+
// Handle static initial state generation
46+
// We'll only write a static state if one is explicitly generated
47+
if page.uses_build_state() {
48+
render_opts.push(RenderOpt::StaticProps);
49+
// We pass in the latter part of the path, without the base specifier (because that would be the same for everything in the template)
50+
let initial_state = page.get_build_state(path.to_string())?;
51+
let initial_state_str = serde_json::to_string(&initial_state).unwrap();
52+
// Write that intial state to a static JSON file
53+
config_manager
54+
.write(&format!("./dist/static/{}.json", full_path), &initial_state_str)
55+
.unwrap();
56+
// Prerender the page using that state
57+
let prerendered = sycamore::render_to_string(
58+
||
59+
page.render_for_template(Some(initial_state))
60+
);
61+
// Write that prerendered HTML to a static file
62+
config_manager
63+
.write(&format!("./dist/static/{}.html", full_path), &prerendered)
64+
.unwrap();
65+
}
66+
67+
// Handle server-side rendering
68+
// By definition, everything here is done at request-time, so there's not really much to do
69+
// Note also that if a page only uses SSR, it won't get prerendered at build time whatsoever
70+
if page.uses_request_state() {
71+
render_opts.push(RenderOpt::Server);
72+
}
73+
74+
// If the page is very basic, prerender without any state
75+
if page.is_basic() {
76+
render_opts.push(RenderOpt::StaticProps);
77+
let prerendered = sycamore::render_to_string(
78+
||
79+
page.render_for_template(None)
80+
);
81+
// Write that prerendered HTML to a static file
82+
config_manager
83+
.write(&format!("./dist/static/{}.html", full_path), &prerendered)
84+
.unwrap();
85+
}
86+
}
87+
88+
Ok(render_opts)
89+
}
90+
91+
/// 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
92+
/// things on the heap.
93+
/// (Any better solutions are welcome in PRs!)
94+
#[macro_export]
95+
macro_rules! build_pages {
96+
(
97+
[$($page:expr),+],
98+
$config_manager:expr
99+
) => {
100+
let mut render_conf: $crate::render_cfg::RenderCfg = ::std::collections::HashMap::new();
101+
$(
102+
render_conf.insert(
103+
$page.get_path(),
104+
$crate::build::build_page($page, $config_manager)
105+
.unwrap()
106+
);
107+
)+
108+
$config_manager
109+
.write("./dist/render_conf.json", &serde_json::to_string(&render_conf).unwrap())
110+
.unwrap();
111+
};
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// This file contains the logic for a universal interface to read and write to static files
2+
// At simplest, this is just a filesystem interface, but it's more likely to be a CMS in production
3+
// This has its own error management logic because the user may implement it separately
4+
5+
use std::fs;
6+
use error_chain::{error_chain, bail};
7+
8+
// This has no foreign links because everything to do with config management should be isolated and generic
9+
error_chain! {
10+
errors {
11+
/// For when data wasn't found.
12+
NotFound(name: String) {
13+
description("data not found")
14+
display("data with name '{}' not found", name)
15+
}
16+
/// For when data couldn't be read for some generic reason.
17+
ReadFailed(name: String, err: String) {
18+
description("data couldn't be read")
19+
display("data with name '{}' couldn't be read, error was '{}'", name, err)
20+
}
21+
/// For when data couldn't be written for some generic reason.
22+
WriteFailed(name: String, err: String) {
23+
description("data couldn't be written")
24+
display("data with name '{}' couldn't be written, error was '{}'", name, err)
25+
}
26+
}
27+
}
28+
29+
/// A trait for systems that manage where to put configuration files. At simplest, we'll just write them to static files, but they're
30+
/// more likely to be stored on a CMS.
31+
pub trait ConfigManager {
32+
/// Reads data from the named asset.
33+
fn read(&self, name: &str) -> Result<String>;
34+
/// Writes data to the named asset. This will create a new asset if on edoesn't exist already.
35+
fn write(&self, name: &str, content: &str) -> Result<()>;
36+
}
37+
38+
#[derive(Default)]
39+
pub struct FsConfigManager {}
40+
impl FsConfigManager {
41+
/// Creates a new filesystem configuration manager. This function only exists to preserve the API surface of the trait.
42+
pub fn new() -> Self {
43+
Self::default()
44+
}
45+
}
46+
impl ConfigManager for FsConfigManager {
47+
fn read(&self, name: &str) -> Result<String> {
48+
match fs::metadata(name) {
49+
Ok(_) => fs::read_to_string(name).map_err(
50+
|err|
51+
ErrorKind::ReadFailed(name.to_string(), err.to_string()).into()
52+
),
53+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => bail!(ErrorKind::NotFound(name.to_string())),
54+
Err(err) => bail!(ErrorKind::ReadFailed(name.to_string(), err.to_string()))
55+
}
56+
}
57+
fn write(&self, name: &str, content: &str) -> Result<()> {
58+
fs::write(name, content).map_err(
59+
|err|
60+
ErrorKind::WriteFailed(name.to_string(), err.to_string()).into()
61+
)
62+
}
63+
}

examples/showcase/app/src/errors.rs

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#![allow(missing_docs)]
2+
3+
pub use error_chain::bail;
4+
use error_chain::error_chain;
5+
6+
// The `error_chain` setup for the whole crate
7+
error_chain! {
8+
// The custom errors for this crate (very broad)
9+
errors {
10+
/// For indistinct JavaScript errors.
11+
JsErr(err: String) {
12+
description("an error occurred while interfacing with javascript")
13+
display("the following error occurred while interfacing with javascript: {:?}", err)
14+
}
15+
16+
PageFeatureNotEnabled(name: String, feature: String) {
17+
description("a page feature required by a function called was not present")
18+
display("the page '{}' is missing the feature '{}'", name, feature)
19+
}
20+
}
21+
links {
22+
ConfigManager(crate::config_manager::Error, crate::config_manager::ErrorKind);
23+
}
24+
// We work with many external libraries, all of which have their own errors
25+
foreign_links {
26+
Io(::std::io::Error);
27+
Json(::serde_json::Error);
28+
}
29+
}

examples/showcase/app/src/lib.rs

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
pub mod errors;
2+
pub mod pages;
3+
mod shell;
4+
pub mod serve;
5+
pub mod render_cfg;
6+
pub mod config_manager;
7+
pub mod page;
8+
pub mod build;
9+
10+
use sycamore::prelude::*;
11+
use sycamore_router::{Route, BrowserRouter};
12+
use wasm_bindgen::prelude::*;
13+
14+
// Define our routes
15+
#[derive(Route)]
16+
enum AppRoute {
17+
#[to("/")]
18+
Index,
19+
#[to("/about")]
20+
About,
21+
#[to("/post/<slug>")]
22+
Post {
23+
slug: String
24+
},
25+
#[not_found]
26+
NotFound
27+
}
28+
29+
// This is deliberately purely client-side rendered
30+
#[wasm_bindgen]
31+
pub fn run() -> Result<(), JsValue> {
32+
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
33+
// Get the root (for the router) we'll be injecting page content into
34+
let root = web_sys::window()
35+
.unwrap()
36+
.document()
37+
.unwrap()
38+
.query_selector("#_perseus_root")
39+
.unwrap()
40+
.unwrap();
41+
42+
sycamore::render_to(
43+
||
44+
template! {
45+
BrowserRouter(|route: AppRoute| {
46+
match route {
47+
AppRoute::Index => app_shell!({
48+
name => "index",
49+
props => pages::index::IndexPageProps,
50+
template => |props: Option<pages::index::IndexPageProps>| template! {
51+
pages::index::IndexPage(props.unwrap())
52+
},
53+
}),
54+
AppRoute::About => app_shell!({
55+
name => "about",
56+
template => |_: Option<()>| template! {
57+
pages::about::AboutPage()
58+
},
59+
}),
60+
AppRoute::Post { slug } => app_shell!({
61+
name => &format!("post/{}", slug),
62+
props => pages::post::PostPageProps,
63+
template => |props: Option<pages::post::PostPageProps>| template! {
64+
pages::post::PostPage(props.unwrap())
65+
},
66+
}),
67+
AppRoute::NotFound => template! {
68+
p {"Not Found."}
69+
}
70+
}
71+
})
72+
},
73+
&root
74+
);
75+
76+
Ok(())
77+
}

0 commit comments

Comments
 (0)