diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs new file mode 100644 index 00000000..92fe1af9 --- /dev/null +++ b/crates/tuono/src/app.rs @@ -0,0 +1,228 @@ +use glob::glob; +use glob::GlobError; +use std::collections::{hash_map::Entry, HashMap}; +use std::path::PathBuf; + +use crate::route::Route; + +const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"]; +const IGNORE_FILES: [&str; 1] = ["__root"]; + +#[derive(Debug)] +pub struct App { + pub route_map: HashMap, + pub base_path: PathBuf, +} + +impl App { + pub fn new() -> Self { + let base_path = std::env::current_dir().unwrap(); + + App { + route_map: HashMap::new(), + base_path, + } + } + + pub fn collect_routes(&mut self) { + glob(self.base_path.join("src/routes/**/*.*").to_str().unwrap()) + .expect("Failed to read glob pattern") + .for_each(|entry| { + if self.should_collect_route(&entry) { + self.collect_route(entry) + } + }) + } + + fn should_collect_route(&self, entry: &Result) -> bool { + let file_extension = entry.as_ref().unwrap().extension().unwrap(); + let file_name = entry.as_ref().unwrap().file_stem().unwrap(); + + if IGNORE_EXTENSIONS.iter().any(|val| val == &file_extension) { + return false; + } + + if IGNORE_FILES.iter().any(|val| val == &file_name) { + return false; + } + true + } + + fn collect_route(&mut self, path_buf: Result) { + let entry = path_buf.unwrap(); + let base_path_str = self.base_path.to_str().unwrap(); + let path = entry + .to_str() + .unwrap() + .replace(&format!("{base_path_str}/src/routes"), "") + .replace(".rs", "") + .replace(".tsx", ""); + + if entry.extension().unwrap() == "rs" { + if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) { + let mut route = Route::new(path); + route.update_axum_info(); + route_map.insert(route); + } else { + let route = self.route_map.get_mut(&path).unwrap(); + route.update_axum_info(); + } + return; + } + if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) { + let route = Route::new(path); + route_map.insert(route); + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn should_collect_routes() { + let mut app = App::new(); + app.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/about.rs", + "/home/user/Documents/tuono/src/routes/index.rs", + "/home/user/Documents/tuono/src/routes/posts/index.rs", + "/home/user/Documents/tuono/src/routes/posts/[post].rs", + "/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs", + ]; + + routes + .into_iter() + .for_each(|route| app.collect_route(Ok(PathBuf::from(route)))); + + let results = [ + ("/index", "index"), + ("/about", "about"), + ("/posts/index", "posts_index"), + ("/posts/[post]", "posts_dyn_post"), + ("/posts/UPPERCASE", "posts_uppercase"), + ]; + + results.into_iter().for_each(|(path, module_import)| { + assert_eq!( + app.route_map + .get(path) + .unwrap() + .axum_info + .as_ref() + .unwrap() + .module_import, + String::from(module_import) + ) + }) + } + + #[test] + fn should_create_multi_level_axum_paths() { + let mut app = App::new(); + app.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/about.rs", + "/home/user/Documents/tuono/src/routes/index.rs", + "/home/user/Documents/tuono/src/routes/posts/index.rs", + "/home/user/Documents/tuono/src/routes/posts/any-post.rs", + "/home/user/Documents/tuono/src/routes/posts/[post].rs", + ]; + + routes + .into_iter() + .for_each(|route| app.collect_route(Ok(PathBuf::from(route)))); + + let results = [ + ("/index", "/"), + ("/about", "/about"), + ("/posts/index", "/posts"), + ("/posts/any-post", "/posts/any-post"), + ("/posts/[post]", "/posts/:post"), + ]; + + results.into_iter().for_each(|(path, expected_path)| { + assert_eq!( + app.route_map + .get(path) + .unwrap() + .axum_info + .as_ref() + .unwrap() + .axum_route, + String::from(expected_path) + ) + }) + } + + #[test] + fn should_ignore_whitelisted_extensions() { + let mut app = App::new(); + app.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/about.css", + "/home/user/Documents/tuono/src/routes/index.scss", + "/home/user/Documents/tuono/src/routes/posts/index.sass", + ]; + + routes.into_iter().for_each(|route| { + if app.should_collect_route(&Ok(PathBuf::from(route))) { + app.collect_route(Ok(PathBuf::from(route))) + } + }); + + assert!(app.route_map.is_empty()) + } + + #[test] + fn should_ignore_whitelisted_files() { + let mut app = App::new(); + app.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/__root.tsx", + "/home/user/Documents/tuono/src/routes/posts/__root.tsx", + ]; + + routes.into_iter().for_each(|route| { + if app.should_collect_route(&Ok(PathBuf::from(route))) { + app.collect_route(Ok(PathBuf::from(route))) + } + }); + + assert!(app.route_map.is_empty()) + } + + #[test] + fn should_correctly_parse_routes_with_server_handler() { + let mut app = App::new(); + app.base_path = "/home/user/Documents/tuono".into(); + + let routes = [ + "/home/user/Documents/tuono/src/routes/about.rs", + "/home/user/Documents/tuono/src/routes/about.tsx", + "/home/user/Documents/tuono/src/routes/index.tsx", + ]; + + routes + .into_iter() + .for_each(|route| app.collect_route(Ok(PathBuf::from(route)))); + + let results = [("/about", true), ("/index", false)]; + + results + .into_iter() + .for_each(|(path, expected_has_server_handler)| { + if expected_has_server_handler { + assert!(app.route_map.get(path).unwrap().axum_info.is_some()) + } else { + assert!(app.route_map.get(path).unwrap().axum_info.is_none()) + } + }) + } +} diff --git a/crates/tuono/src/cli.rs b/crates/tuono/src/cli.rs new file mode 100644 index 00000000..48095cf9 --- /dev/null +++ b/crates/tuono/src/cli.rs @@ -0,0 +1,75 @@ +use clap::{Parser, Subcommand}; +use std::process::Command; + +use crate::app::App; +use crate::mode::Mode; +use crate::scaffold_project; +use crate::source_builder::{bundle_axum_source, check_tuono_folder, create_client_entry_files}; +use crate::watch; + +#[derive(Subcommand, Debug)] +enum Actions { + /// Start the development environment + Dev, + /// Build the production assets + Build { + #[arg(short, long = "static")] + /// Statically generate the website HTML + ssg: bool, + }, + /// Scaffold a new project + New { + /// The folder in which load the project. Default is the current directory. + folder_name: Option, + /// The template to use to scaffold the project. The template should match one of the tuono + /// examples + #[arg(short, long)] + template: Option, + }, +} + +#[derive(Parser, Debug)] +#[command(version, about = "The react/rust fullstack framework")] +struct Args { + #[command(subcommand)] + action: Actions, +} + +fn init_tuono_folder(mode: Mode) -> std::io::Result<()> { + check_tuono_folder()?; + bundle_axum_source(mode)?; + create_client_entry_files()?; + + Ok(()) +} + +pub fn app() -> std::io::Result<()> { + let args = Args::parse(); + + match args.action { + Actions::Dev => { + init_tuono_folder(Mode::Dev)?; + watch::watch().unwrap(); + } + Actions::Build { ssg } => { + init_tuono_folder(Mode::Prod)?; + let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod"); + let _ = &vite_build.output()?; + + if ssg { + println!("SSG: generation started"); + let mut app = App::new(); + app.collect_routes(); + dbg!(app); + } + } + Actions::New { + folder_name, + template, + } => { + scaffold_project::create_new_project(folder_name, template); + } + } + + Ok(()) +} diff --git a/crates/tuono/src/lib.rs b/crates/tuono/src/lib.rs index 91e8a17a..75f1bad6 100644 --- a/crates/tuono/src/lib.rs +++ b/crates/tuono/src/lib.rs @@ -1,66 +1,7 @@ -use clap::{Parser, Subcommand}; -use std::process::Command; - -mod source_builder; -use source_builder::{bundle_axum_source, create_client_entry_files}; - -use crate::source_builder::{check_tuono_folder, Mode}; - +mod app; +pub mod cli; +mod mode; +mod route; mod scaffold_project; +mod source_builder; mod watch; - -#[derive(Subcommand, Debug)] -enum Actions { - /// Start the development environment - Dev, - /// Build the production assets - Build, - /// Scaffold a new project - New { - /// The folder in which load the project. Default is the current directory. - folder_name: Option, - /// The template to use to scaffold the project. The template should match one of the tuono - /// examples - #[arg(short, long)] - template: Option, - }, -} - -#[derive(Parser, Debug)] -#[command(version, about = "The react/rust fullstack framework")] -struct Args { - #[command(subcommand)] - action: Actions, -} - -fn init_tuono_folder(mode: Mode) -> std::io::Result<()> { - check_tuono_folder()?; - bundle_axum_source(mode)?; - create_client_entry_files()?; - - Ok(()) -} - -pub fn cli() -> std::io::Result<()> { - let args = Args::parse(); - - match args.action { - Actions::Dev => { - init_tuono_folder(Mode::Dev)?; - watch::watch().unwrap(); - } - Actions::Build => { - init_tuono_folder(Mode::Prod)?; - let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod"); - let _ = &vite_build.output()?; - } - Actions::New { - folder_name, - template, - } => { - scaffold_project::create_new_project(folder_name, template); - } - } - - Ok(()) -} diff --git a/crates/tuono/src/main.rs b/crates/tuono/src/main.rs index ed1b29f2..a145c832 100644 --- a/crates/tuono/src/main.rs +++ b/crates/tuono/src/main.rs @@ -1,5 +1,5 @@ -use tuono::cli; +use tuono::cli::app; fn main() { - cli().unwrap(); + app().expect("Failed to start the CLI") } diff --git a/crates/tuono/src/mode.rs b/crates/tuono/src/mode.rs new file mode 100644 index 00000000..e54cd212 --- /dev/null +++ b/crates/tuono/src/mode.rs @@ -0,0 +1,28 @@ +#[derive(PartialEq, Eq)] +pub enum Mode { + Prod, + Dev, +} + +impl Mode { + pub fn as_str<'a>(&self) -> &'a str { + if *self == Mode::Dev { + return "Mode::Dev"; + } + "Mode::Prod" + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn should_correctly_print_the_mode_as_str() { + let dev = Mode::Dev.as_str(); + let prod = Mode::Prod.as_str(); + assert_eq!(dev, "Mode::Dev"); + assert_eq!(prod, "Mode::Prod"); + } +} diff --git a/crates/tuono/src/route.rs b/crates/tuono/src/route.rs new file mode 100644 index 00000000..c75ad6d3 --- /dev/null +++ b/crates/tuono/src/route.rs @@ -0,0 +1,108 @@ +use regex::Regex; + +fn has_dynamic_path(route: &str) -> bool { + let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex"); + regex.is_match(route) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct AxumInfo { + // Path for importing the module + pub module_import: String, + // path for the the axum router + pub axum_route: String, +} + +impl AxumInfo { + pub fn new(path: String) -> Self { + // Remove first slash + let mut module = path.chars(); + module.next(); + + let axum_route = path.replace("/index", ""); + + if axum_route.is_empty() { + return AxumInfo { + module_import: module.as_str().to_string().replace('/', "_"), + axum_route: "/".to_string(), + }; + } + + if has_dynamic_path(&path) { + return AxumInfo { + module_import: module + .as_str() + .to_string() + .replace('/', "_") + .replace('[', "dyn_") + .replace(']', ""), + axum_route: axum_route.replace('[', ":").replace(']', ""), + }; + } + + AxumInfo { + module_import: module.as_str().to_string().replace('/', "_").to_lowercase(), + axum_route, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Route { + path: String, + pub is_dynamic: bool, + pub axum_info: Option, +} + +impl Route { + pub fn new(cleaned_path: String) -> Self { + Route { + path: cleaned_path.clone(), + axum_info: None, + is_dynamic: has_dynamic_path(&cleaned_path), + } + } + + pub fn update_axum_info(&mut self) { + self.axum_info = Some(AxumInfo::new(self.path.clone())) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn should_find_dynamic_paths() { + let routes = [ + ("/home/user/Documents/tuono/src/routes/about.rs", false), + ("/home/user/Documents/tuono/src/routes/index.rs", false), + ( + "/home/user/Documents/tuono/src/routes/posts/index.rs", + false, + ), + ( + "/home/user/Documents/tuono/src/routes/posts/[post].rs", + true, + ), + ]; + + routes + .into_iter() + .for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1)); + } + + #[test] + fn should_correctly_create_the_axum_infos() { + let info = AxumInfo::new("/index".to_string()); + + assert_eq!(info.axum_route, "/"); + assert_eq!(info.module_import, "index"); + + let dyn_info = AxumInfo::new("/[posts]".to_string()); + + assert_eq!(dyn_info.axum_route, "/:posts"); + assert_eq!(dyn_info.module_import, "dyn_posts"); + } +} diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 2b7f3fef..8e2f1f93 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -1,27 +1,13 @@ -use glob::glob; -use glob::GlobError; -use regex::Regex; use std::collections::HashMap; use std::fs; use std::io; use std::io::prelude::*; use std::path::Path; -use std::path::PathBuf; -#[derive(PartialEq, Eq)] -pub enum Mode { - Prod, - Dev, -} - -impl Mode { - pub fn as_str<'a>(&self) -> &'a str { - if *self == Mode::Dev { - return "Mode::Dev"; - } - "Mode::Prod" - } -} +use crate::app::App; +use crate::mode::Mode; +use crate::route::AxumInfo; +use crate::route::Route; pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono // Do not manually update this file @@ -63,91 +49,9 @@ async fn main() { } "##; -const ROOT_FOLDER: &str = "src/routes"; +const ROUTE_FOLDER: &str = "src/routes"; const DEV_FOLDER: &str = ".tuono"; -#[derive(Debug, PartialEq, Eq)] -struct Route { - /// Every module import is the path with a _ instead of / - pub module_import: String, - pub axum_route: String, -} - -fn has_dynamic_path(route: &str) -> bool { - let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex"); - regex.is_match(route) -} - -impl Route { - pub fn new(path: &str) -> Self { - let route_name = path.replace(".rs", ""); - // Remove first slash - let mut module = route_name.as_str().chars(); - module.next(); - - let axum_route = path.replace("/index.rs", "").replace(".rs", ""); - - if axum_route.is_empty() { - return Route { - module_import: module.as_str().to_string().replace('/', "_"), - axum_route: "/".to_string(), - }; - } - - if has_dynamic_path(&route_name) { - return Route { - module_import: module - .as_str() - .to_string() - .replace('/', "_") - .replace('[', "dyn_") - .replace(']', ""), - axum_route: axum_route.replace('[', ":").replace(']', ""), - }; - } - - Route { - module_import: module.as_str().to_string().replace('/', "_").to_lowercase(), - axum_route, - } - } -} - -struct SourceBuilder { - route_map: HashMap, - base_path: PathBuf, -} - -impl SourceBuilder { - pub fn new() -> Self { - let base_path = std::env::current_dir().unwrap(); - - SourceBuilder { - route_map: HashMap::new(), - base_path, - } - } - - fn collect_routes(&mut self) { - glob(self.base_path.join("src/routes/**/*.rs").to_str().unwrap()) - .unwrap() - .for_each(|entry| self.collect_route(entry)) - } - - fn collect_route(&mut self, path_buf: Result) { - let entry = path_buf.unwrap(); - let base_path_str = self.base_path.to_str().unwrap(); - let path = entry - .to_str() - .unwrap() - .replace(&format!("{base_path_str}/src/routes"), ""); - - let route = Route::new(&path); - - self.route_map.insert(PathBuf::from(path), route); - } -} - fn create_main_file(base_path: &Path, bundled_file: &String) { let mut data_file = fs::File::create(base_path.join(".tuono/main.rs")).expect("creation failed"); @@ -157,37 +61,43 @@ fn create_main_file(base_path: &Path, bundled_file: &String) { .expect("write failed"); } -fn create_routes_declaration(routes: &HashMap) -> String { +fn create_routes_declaration(routes: &HashMap) -> String { let mut route_declarations = String::from("// ROUTE_BUILDER\n"); for (_, route) in routes.iter() { - let Route { - axum_route, - module_import, - } = &route; + let Route { axum_info, .. } = &route; - route_declarations.push_str(&format!( - r#".route("{axum_route}", get({module_import}::route))"# - )); - route_declarations.push_str(&format!( - r#".route("/__tuono/data{axum_route}", get({module_import}::api))"# - )); + if axum_info.is_some() { + let AxumInfo { + axum_route, + module_import, + } = axum_info.as_ref().unwrap(); + + route_declarations.push_str(&format!( + r#".route("{axum_route}", get({module_import}::route))"# + )); + route_declarations.push_str(&format!( + r#".route("/__tuono/data{axum_route}", get({module_import}::api))"# + )); + } } route_declarations } -fn create_modules_declaration(routes: &HashMap) -> String { +fn create_modules_declaration(routes: &HashMap) -> String { let mut route_declarations = String::from("// MODULE_IMPORTS\n"); for (path, route) in routes.iter() { - let module_name = &route.module_import; - let path_str = path.to_str().unwrap(); - route_declarations.push_str(&format!( - r#"#[path="../{ROOT_FOLDER}{path_str}"] -mod {module_name}; -"# - )) + if route.axum_info.is_some() { + let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap(); + + route_declarations.push_str(&format!( + r#"#[path="../{ROUTE_FOLDER}{path}.rs"] + mod {module_import}; + "# + )) + } } route_declarations @@ -196,10 +106,11 @@ mod {module_name}; pub fn bundle_axum_source(mode: Mode) -> io::Result<()> { let base_path = std::env::current_dir().unwrap(); - let mut source_builder = SourceBuilder::new(); + let mut source_builder = App::new(); source_builder.collect_routes(); + dbg!(&source_builder); let bundled_file = generate_axum_source(&source_builder, mode); create_main_file(&base_path, &bundled_file); @@ -207,7 +118,7 @@ pub fn bundle_axum_source(mode: Mode) -> io::Result<()> { Ok(()) } -fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String { +fn generate_axum_source(source_builder: &App, mode: Mode) -> String { AXUM_ENTRY_POINT .replace( "// ROUTE_BUILDER\n", @@ -246,103 +157,9 @@ mod tests { use super::*; - #[test] - fn should_find_dynamic_paths() { - let routes = [ - ("/home/user/Documents/tuono/src/routes/about.rs", false), - ("/home/user/Documents/tuono/src/routes/index.rs", false), - ( - "/home/user/Documents/tuono/src/routes/posts/index.rs", - false, - ), - ( - "/home/user/Documents/tuono/src/routes/posts/[post].rs", - true, - ), - ]; - - routes - .into_iter() - .for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1)); - } - - #[test] - fn should_collect_routes() { - let mut source_builder = SourceBuilder::new(); - source_builder.base_path = "/home/user/Documents/tuono".into(); - - let routes = [ - "/home/user/Documents/tuono/src/routes/about.rs", - "/home/user/Documents/tuono/src/routes/index.rs", - "/home/user/Documents/tuono/src/routes/posts/index.rs", - "/home/user/Documents/tuono/src/routes/posts/[post].rs", - "/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs", - ]; - - routes - .into_iter() - .for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route)))); - - let results = [ - ("/index.rs", "index"), - ("/about.rs", "about"), - ("/posts/index.rs", "posts_index"), - ("/posts/[post].rs", "posts_dyn_post"), - ("/posts/UPPERCASE.rs", "posts_uppercase"), - ]; - - results.into_iter().for_each(|(path, module_import)| { - assert_eq!( - source_builder - .route_map - .get(&PathBuf::from(path)) - .unwrap() - .module_import, - String::from(module_import) - ) - }) - } - - #[test] - fn should_create_multi_level_axum_paths() { - let mut source_builder = SourceBuilder::new(); - source_builder.base_path = "/home/user/Documents/tuono".into(); - - let routes = [ - "/home/user/Documents/tuono/src/routes/about.rs", - "/home/user/Documents/tuono/src/routes/index.rs", - "/home/user/Documents/tuono/src/routes/posts/index.rs", - "/home/user/Documents/tuono/src/routes/posts/any-post.rs", - "/home/user/Documents/tuono/src/routes/posts/[post].rs", - ]; - - routes - .into_iter() - .for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route)))); - - let results = [ - ("/index.rs", "/"), - ("/about.rs", "/about"), - ("/posts/index.rs", "/posts"), - ("/posts/any-post.rs", "/posts/any-post"), - ("/posts/[post].rs", "/posts/:post"), - ]; - - results.into_iter().for_each(|(path, expected_path)| { - assert_eq!( - source_builder - .route_map - .get(&PathBuf::from(path)) - .unwrap() - .axum_route, - String::from(expected_path) - ) - }) - } - #[test] fn should_set_the_correct_mode() { - let source_builder = SourceBuilder::new(); + let source_builder = App::new(); let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;")); @@ -351,12 +168,4 @@ mod tests { assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;")); } - - #[test] - fn should_correctly_print_the_mode_as_str() { - let dev = Mode::Dev.as_str(); - let prod = Mode::Prod.as_str(); - assert_eq!(dev, "Mode::Dev"); - assert_eq!(prod, "Mode::Prod"); - } } diff --git a/crates/tuono/src/watch.rs b/crates/tuono/src/watch.rs index 78edce5f..eda3ac2c 100644 --- a/crates/tuono/src/watch.rs +++ b/crates/tuono/src/watch.rs @@ -6,7 +6,8 @@ use watchexec::Watchexec; use watchexec_signals::Signal; use watchexec_supervisor::job::{start_job, Job}; -use crate::source_builder::{bundle_axum_source, Mode}; +use crate::mode::Mode; +use crate::source_builder::bundle_axum_source; fn watch_react_src() -> Job { start_job(Arc::new(Command {