From 2949ea4dfacc4da832faa36604652a5b1d54845e Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Sun, 21 Jul 2024 14:10:12 +0200 Subject: [PATCH] Support Static Site Generation (SSG) (#16) * refactor: init routes within App::new * feat: handle build and server run within App struct * feat: prevent dynamic routes SSG * feat: support SSG * chore: remove debugger * feat: update version to v0.6.0 --- crates/tuono/Cargo.toml | 3 +- crates/tuono/src/app.rs | 78 ++++++++++++++++++- crates/tuono/src/cli.rs | 65 ++++++++++++++-- crates/tuono/src/route.rs | 57 ++++++++++++++ crates/tuono/src/source_builder.rs | 10 +-- crates/tuono_lib/Cargo.toml | 4 +- crates/tuono_lib_macros/Cargo.toml | 2 +- packages/fs-router-vite-plugin/package.json | 2 +- packages/lazy-fn-vite-plugin/package.json | 2 +- packages/tuono/package.json | 2 +- .../src/router/hooks/useServerSideProps.tsx | 3 +- 11 files changed, 204 insertions(+), 24 deletions(-) diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index 23618a9b..02250cc9 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono" -version = "0.5.0" +version = "0.6.0" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" @@ -30,4 +30,5 @@ glob = "0.3.1" regex = "1.10.4" reqwest = {version = "0.12.4", features =["blocking", "json"]} serde_json = "1.0" +fs_extra = "1.3.0" diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs index 92fe1af9..d9f64e8a 100644 --- a/crates/tuono/src/app.rs +++ b/crates/tuono/src/app.rs @@ -2,6 +2,9 @@ use glob::glob; use glob::GlobError; use std::collections::{hash_map::Entry, HashMap}; use std::path::PathBuf; +use std::process::Child; +use std::process::Command; +use std::process::Stdio; use crate::route::Route; @@ -18,13 +21,17 @@ impl App { pub fn new() -> Self { let base_path = std::env::current_dir().unwrap(); - App { + let mut app = App { route_map: HashMap::new(), base_path, - } + }; + + app.collect_routes(); + + app } - pub fn collect_routes(&mut self) { + fn collect_routes(&mut self) { glob(self.base_path.join("src/routes/**/*.*").to_str().unwrap()) .expect("Failed to read glob pattern") .for_each(|entry| { @@ -74,6 +81,26 @@ impl App { route_map.insert(route); } } + + pub fn has_dynamic_routes(&self) -> bool { + self.route_map.iter().any(|(_, route)| route.is_dynamic) + } + + pub fn build_react_prod(&self) { + Command::new("./node_modules/.bin/tuono-build-prod") + .output() + .expect("Failed to build the react source"); + } + + pub fn run_rust_server(&self) -> Child { + Command::new("cargo") + .arg("run") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to run the rust server") + } } #[cfg(test)] @@ -225,4 +252,49 @@ mod tests { } }) } + + #[test] + fn has_dynamic_routes_works() { + let mut app = App::new(); + app.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/index.rs", + "/home/user/Documents/tuono/src/routes/posts/[post].rs", + ]; + + routes + .into_iter() + .for_each(|route| app.collect_route(Ok(PathBuf::from(route)))); + + assert!(app.has_dynamic_routes()); + + let mut app2 = App::new(); + app2.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/[post].rs", + "/home/user/Documents/tuono/src/routes/posts/[post].rs", + ]; + + routes + .into_iter() + .for_each(|route| app2.collect_route(Ok(PathBuf::from(route)))); + + assert!(app2.has_dynamic_routes()); + + let mut app3 = App::new(); + app3.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/index.rs", + "/home/user/Documents/tuono/src/routes/posts/index.rs", + ]; + + routes + .into_iter() + .for_each(|route| app3.collect_route(Ok(PathBuf::from(route)))); + + assert!(!app3.has_dynamic_routes()) + } } diff --git a/crates/tuono/src/cli.rs b/crates/tuono/src/cli.rs index 48095cf9..22a0e807 100644 --- a/crates/tuono/src/cli.rs +++ b/crates/tuono/src/cli.rs @@ -1,5 +1,9 @@ +use fs_extra::dir::{copy, CopyOptions}; +use std::path::PathBuf; +use std::thread::sleep; +use std::time::Duration; + use clap::{Parser, Subcommand}; -use std::process::Command; use crate::app::App; use crate::mode::Mode; @@ -53,15 +57,62 @@ pub fn app() -> std::io::Result<()> { } Actions::Build { ssg } => { init_tuono_folder(Mode::Prod)?; - let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod"); - let _ = &vite_build.output()?; + let app = App::new(); + + if ssg && app.has_dynamic_routes() { + // TODO: allow dynamic routes static generation + println!("Cannot statically build dynamic routes"); + return Ok(()); + } + + app.build_react_prod(); if ssg { println!("SSG: generation started"); - let mut app = App::new(); - app.collect_routes(); - dbg!(app); - } + + let static_dir = PathBuf::from("out/static"); + + if static_dir.is_dir() { + std::fs::remove_dir_all(&static_dir) + .expect("Failed to clear the out/static folder"); + } + + std::fs::create_dir(&static_dir).expect("Failed to create static output dir"); + + copy( + "./out/client", + static_dir, + &CopyOptions::new().overwrite(true).content_only(true), + ) + .expect("Failed to clone assets into static output folder"); + + let mut rust_server = app.run_rust_server(); + + let reqwest = reqwest::blocking::Client::builder() + .user_agent("") + .build() + .expect("Failed to build reqwest client"); + + // Wait for server + let mut is_server_ready = false; + + while !is_server_ready { + if reqwest.get("http://localhost:3000").send().is_ok() { + is_server_ready = true + } + // TODO: add maximum tries + sleep(Duration::from_secs(1)) + } + + app.route_map + .iter() + .for_each(|(_, route)| route.save_ssg_html(&reqwest)); + + // Close server + let _ = rust_server.kill(); + }; + + println!("Build successfully finished"); } Actions::New { folder_name, diff --git a/crates/tuono/src/route.rs b/crates/tuono/src/route.rs index c75ad6d3..2c748517 100644 --- a/crates/tuono/src/route.rs +++ b/crates/tuono/src/route.rs @@ -1,4 +1,10 @@ +use fs_extra::dir::create_all; use regex::Regex; +use reqwest::blocking::Client; +use reqwest::Url; +use std::fs::File; +use std::io; +use std::path::PathBuf; fn has_dynamic_path(route: &str) -> bool { let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex"); @@ -66,6 +72,57 @@ impl Route { pub fn update_axum_info(&mut self) { self.axum_info = Some(AxumInfo::new(self.path.clone())) } + + pub fn save_ssg_html(&self, reqwest: &Client) { + let path = &self.path.replace("index", ""); + + let mut response = reqwest + .get(format!("http://localhost:3000{path}")) + .send() + .unwrap(); + + let file_path = PathBuf::from(format!("out/static{}.html", &self.path)); + + let parent_dir = file_path.parent().unwrap(); + + if !parent_dir.is_dir() { + create_all(parent_dir, false).expect("Failed to create parent directories"); + } + + let mut file = File::create(file_path).expect("Failed to create the HTML file"); + + io::copy(&mut response, &mut file).expect("Failed to write the HTML on the file"); + + // Saving also the server response + if self.axum_info.is_some() { + let data_file_path = + PathBuf::from(&format!("out/static/__tuono/data{}/data.json", path)); + + let data_parent_dir = data_file_path.parent().unwrap(); + + if !data_parent_dir.is_dir() { + create_all(data_parent_dir, false) + .expect("Failed to create data parent directories"); + } + + let base = Url::parse("http://localhost:3000/__tuono/data/data.json").unwrap(); + + let path = if path == "/" { "" } else { path }; + + let pathname = &format!("/__tuono/data{path}/data.json"); + + let url = base + .join(pathname) + .expect("Failed to build the reqwest URL"); + + let mut response = reqwest.get(url).send().unwrap(); + + let mut data_file = + File::create(data_file_path).expect("Failed to create the JSON file"); + + io::copy(&mut response, &mut data_file).expect("Failed to write the JSON on the file"); + } + } } #[cfg(test)] diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 8e2f1f93..6065ff5b 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -76,8 +76,9 @@ fn create_routes_declaration(routes: &HashMap) -> String { route_declarations.push_str(&format!( r#".route("{axum_route}", get({module_import}::route))"# )); + let slash = if axum_route.ends_with('/') { "" } else { "/" }; route_declarations.push_str(&format!( - r#".route("/__tuono/data{axum_route}", get({module_import}::api))"# + r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"# )); } } @@ -106,12 +107,9 @@ fn create_modules_declaration(routes: &HashMap) -> String { pub fn bundle_axum_source(mode: Mode) -> io::Result<()> { let base_path = std::env::current_dir().unwrap(); - let mut source_builder = App::new(); + let app = App::new(); - source_builder.collect_routes(); - - dbg!(&source_builder); - let bundled_file = generate_axum_source(&source_builder, mode); + let bundled_file = generate_axum_source(&app, mode); create_main_file(&base_path, &bundled_file); diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index e65843b2..c7061031 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib" -version = "0.5.0" +version = "0.6.0" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" @@ -32,7 +32,7 @@ regex = "1.10.5" either = "1.13.0" tower-http = {version = "0.5.2", features = ["fs"]} -tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.5.0"} +tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.6.0"} # Match the same version used by axum tokio-tungstenite = "0.21.0" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } diff --git a/crates/tuono_lib_macros/Cargo.toml b/crates/tuono_lib_macros/Cargo.toml index 19245bf8..dcc3e6e6 100644 --- a/crates/tuono_lib_macros/Cargo.toml +++ b/crates/tuono_lib_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib_macros" -version = "0.5.0" +version = "0.6.0" edition = "2021" description = "The react/rust fullstack framework" keywords = [ "react", "typescript", "fullstack", "web", "ssr"] diff --git a/packages/fs-router-vite-plugin/package.json b/packages/fs-router-vite-plugin/package.json index 2a70b74b..c3279abc 100644 --- a/packages/fs-router-vite-plugin/package.json +++ b/packages/fs-router-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-fs-router-vite-plugin", - "version": "0.5.0", + "version": "0.6.0", "description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/lazy-fn-vite-plugin/package.json b/packages/lazy-fn-vite-plugin/package.json index a84a569e..3b01e33b 100644 --- a/packages/lazy-fn-vite-plugin/package.json +++ b/packages/lazy-fn-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-lazy-fn-vite-plugin", - "version": "0.5.0", + "version": "0.6.0", "description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 770b9f93..d029e747 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.5.0", + "version": "0.6.0", "description": "The react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/tuono/src/router/hooks/useServerSideProps.tsx b/packages/tuono/src/router/hooks/useServerSideProps.tsx index 69c8ac29..e2b370eb 100644 --- a/packages/tuono/src/router/hooks/useServerSideProps.tsx +++ b/packages/tuono/src/router/hooks/useServerSideProps.tsx @@ -24,7 +24,8 @@ interface TuonoApi { } const fetchClientSideData = async (): Promise => { - const res = await fetch(`/__tuono/data${location.pathname}`) + const slash = location.pathname.endsWith('/') ? '' : '/' + const res = await fetch(`/__tuono/data${location.pathname}${slash}data.json`) const data: TuonoApi = await res.json() return data }