Compare commits

...

5 Commits

Author SHA1 Message Date
Valerio Ageno 1726312d34 Support nested roots (#18)
* feat: remove isLoader route property

* fix: has-parent-route fs-router util

* feat: handle routeGenerator nested __root

* feat: enable multiple roots with nested dynamic paths

* feat: enable nested routes

* fix: prevent route path creation for __root

* feat: prevent wrong path on subfolder index routes

* chore: cleaned tutorial example folder

* feat: update version to v0.8.0
2024-07-23 19:13:18 +02:00
Valerio Ageno 444d1479b0 Add support for MDX (#17)
* feat: add support for MDX

* fix: prevent empty server handler error message

* doc: add MDX mention to README

* feat: update version to v0.7.0
2024-07-22 07:59:43 +02:00
Valerio Ageno 2949ea4dfa 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
2024-07-21 14:10:12 +02:00
Valerio Ageno 8ab292b2f9 Update pull_request_template.md 2024-07-15 13:40:49 +02:00
Valerio Ageno 9d8bfd9826 Refactor application struct representation (#15)
* feat: add ssg module and CLI argument

* refactor: tuono lib.rs structure

* refactor: create App struct

* feat: extend file extensions to read in src/routes/

* feat: load all routes in the app.route_map

* refactor: creaded AxumInfo struct
2024-07-14 20:58:03 +02:00
55 changed files with 1537 additions and 425 deletions
+8 -15
View File
@@ -1,3 +1,11 @@
## Context
<!--
Explain the context and why you're making that change. What is the problem
you're trying to solve? If a new feature is being added, describe the intended
use case that feature fulfills.
-->
## Description
<!--
@@ -8,18 +16,3 @@ Bug fixes and new features should include tests.
Contributors guide: https://github.com/Valerioageno/tuono/blob/main/CONTRIBUTING.md
-->
## Context
<!--
Explain the context and why you're making that change. What is the problem
you're trying to solve? If a new feature is being added, describe the intended
use case that feature fulfills.
-->
## Solution
<!--
Summarize the solution and provide any necessary context needed to understand
the code change.
-->
+1 -1
View File
@@ -6,7 +6,7 @@ members = [
"crates/tuono_lib_macros",
]
exclude = [
"examples/playground",
"examples/mdx",
"examples/tuono",
"examples/tutorial"
]
+1
View File
@@ -38,6 +38,7 @@ by Tuono based on the files defined within the `./src/routes` directory.
- 🍭 CSS modules
- 🧬 Server Side Rendering
- 🧵 Multi thread backend
- ⌨️ MDX support
- ⚙️ Build optimizations
- Custom APIs*
- Image optimization*
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.5.0"
version = "0.8.0"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
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"
+300
View File
@@ -0,0 +1,300 @@
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;
const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"];
const IGNORE_FILES: [&str; 1] = ["__root"];
#[derive(Debug)]
pub struct App {
pub route_map: HashMap<String, Route>,
pub base_path: PathBuf,
}
impl App {
pub fn new() -> Self {
let base_path = std::env::current_dir().unwrap();
let mut app = App {
route_map: HashMap::new(),
base_path,
};
app.collect_routes();
app
}
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<PathBuf, GlobError>) -> 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<PathBuf, GlobError>) {
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);
}
}
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)]
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())
}
})
}
#[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())
}
}
+126
View File
@@ -0,0 +1,126 @@
use fs_extra::dir::{copy, CopyOptions};
use std::path::PathBuf;
use std::thread::sleep;
use std::time::Duration;
use clap::{Parser, Subcommand};
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<String>,
/// The template to use to scaffold the project. The template should match one of the tuono
/// examples
#[arg(short, long)]
template: Option<String>,
},
}
#[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 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 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,
template,
} => {
scaffold_project::create_new_project(folder_name, template);
}
}
Ok(())
}
+5 -64
View File
@@ -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<String>,
/// The template to use to scaffold the project. The template should match one of the tuono
/// examples
#[arg(short, long)]
template: Option<String>,
},
}
#[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(())
}
+2 -2
View File
@@ -1,5 +1,5 @@
use tuono::cli;
use tuono::cli::app;
fn main() {
cli().unwrap();
app().expect("Failed to start the CLI")
}
+28
View File
@@ -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");
}
}
+165
View File
@@ -0,0 +1,165 @@
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");
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<AxumInfo>,
}
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()))
}
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)]
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");
}
}
+76 -230
View File
@@ -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
@@ -47,7 +33,8 @@ pub const AXUM_ENTRY_POINT: &str = r##"
// File automatically generated
// Do not manually change it
use tuono_lib::{tokio, Mode, Server, axum::Router, axum::routing::get};
use tuono_lib::{tokio, Mode, Server, axum::Router};
// AXUM_GET_ROUTE_HANDLER
const MODE: Mode = /*MODE*/;
@@ -63,91 +50,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<PathBuf, Route>,
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<PathBuf, GlobError>) {
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 +62,44 @@ fn create_main_file(base_path: &Path, bundled_file: &String) {
.expect("write failed");
}
fn create_routes_declaration(routes: &HashMap<PathBuf, Route>) -> String {
fn create_routes_declaration(routes: &HashMap<String, Route>) -> 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))"#
));
let slash = if axum_route.ends_with('/') { "" } else { "/" };
route_declarations.push_str(&format!(
r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"#
));
}
}
route_declarations
}
fn create_modules_declaration(routes: &HashMap<PathBuf, Route>) -> String {
fn create_modules_declaration(routes: &HashMap<String, Route>) -> 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,28 +108,41 @@ 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 app = App::new();
source_builder.collect_routes();
let bundled_file = generate_axum_source(&source_builder, mode);
let bundled_file = generate_axum_source(&app, mode);
create_main_file(&base_path, &bundled_file);
Ok(())
}
fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String {
AXUM_ENTRY_POINT
fn generate_axum_source(app: &App, mode: Mode) -> String {
let src = AXUM_ENTRY_POINT
.replace(
"// ROUTE_BUILDER\n",
&create_routes_declaration(&source_builder.route_map),
&create_routes_declaration(&app.route_map),
)
.replace(
"// MODULE_IMPORTS\n",
&create_modules_declaration(&source_builder.route_map),
&create_modules_declaration(&app.route_map),
)
.replace("/*MODE*/", mode.as_str())
.replace("/*MODE*/", mode.as_str());
let has_server_handlers = app
.route_map
.iter()
.filter(|(_, route)| route.axum_info.is_some())
.collect::<Vec<(&String, &Route)>>()
.is_empty();
if !has_server_handlers {
return src.replace(
"// AXUM_GET_ROUTE_HANDLER",
"use tuono_lib::axum::routing::get;",
);
}
src
}
pub fn check_tuono_folder() -> io::Result<()> {
@@ -246,103 +171,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;"));
@@ -353,10 +184,25 @@ mod tests {
}
#[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");
fn should_not_load_the_axum_get_function() {
let source_builder = App::new();
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;"));
}
#[test]
fn should_load_the_axum_get_function() {
let mut source_builder = App::new();
let mut route = Route::new(String::from("index.tsx"));
route.update_axum_info();
source_builder
.route_map
.insert(String::from("index.rs"), route);
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;"));
}
}
+2 -1
View File
@@ -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 {
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.5.0"
version = "0.8.0"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
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.8.0"}
# Match the same version used by axum
tokio-tungstenite = "0.21.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib_macros"
version = "0.5.0"
version = "0.8.0"
edition = "2021"
description = "The react/rust fullstack framework"
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
+9
View File
@@ -16,4 +16,13 @@ $ tuono new [NAME] --template [TEMPLATE]
`[TEMPLATE]` is the folder name.
## Troubleshooting for contributors
There is a common error that happens during example development due to
pnpm workspace about the findings of different react versions.
To overcome this issue run within the single example folder:
```
pnpm install --ignore-workspace && pnpm upgrade --save
```
+13
View File
@@ -0,0 +1,13 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.tuono
out
target
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "tuono"
version = "0.0.1"
edition = "2021"
[[bin]]
name = "tuono"
path = ".tuono/main.rs"
[dependencies]
tuono_lib = { path = "../../crates/tuono_lib/"}
serde = { version = "1.0.202", features = ["derive"] }
+9
View File
@@ -0,0 +1,9 @@
# Tuono starter
This is the starter tuono project. To download it run in your terminal:
```shell
$ tuono new [NAME]
```
+16
View File
@@ -0,0 +1,16 @@
{
"name": "tuono",
"description": "The react/rust fullstack framework",
"version": "0.0.1",
"dependencies": {
"@mdx-js/react": "^3.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"tuono": "link:../../packages/tuono"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
import { MDXProvider } from '@mdx-js/react'
interface RootRouteProps {
children: ReactNode
}
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
return (
<main className="main">
<MDXProvider components={{}}>{children}</MDXProvider>
</main>
)
}
+1
View File
@@ -0,0 +1 @@
# Index page
+109
View File
@@ -0,0 +1,109 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Poppins', sans-serif;
font-weight: 400;
font-style: normal;
}
body {
background: #fbfbfb;
}
main {
width: 673px;
margin: 20px auto;
}
.header {
display: flex;
gap: 20px;
}
.header a {
color: black;
font-size: 18px;
font-weight: 600;
text-decoration: none;
z-index: 2;
}
.title-wrap {
height: 200px;
}
.title {
position: absolute;
font-size: 200px;
line-height: 200px;
z-index: 0;
letter-spacing: -2px;
margin-left: -8px;
user-select: none;
pointer-events: none;
}
.title span {
opacity: 0;
}
.button {
width: 140px;
height: 30px;
border: solid 3px black;
border-radius: 10px;
color: black;
text-decoration: none;
display: flex;
justify-content: center;
align-items: center;
transition: 0.2s;
}
.button:hover {
color: white;
background: black;
}
.logo {
margin-left: 240px;
position: relative;
top: 25px;
}
.logo img {
position: absolute;
}
.rust {
animation: rotate 6s ease-in-out infinite;
}
.react {
top: 33px;
left: 28px;
animation: rotate 6s linear infinite reverse;
}
.subtitle {
font-size: 30px;
line-height: 30px;
letter-spacing: -1px;
}
.subtitle-wrap {
display: flex;
justify-content: space-between;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+2 -2
View File
@@ -8,8 +8,8 @@
"format": "turbo format --filter tuono",
"format:check": "turbo format:check --filter tuono",
"types": "turbo types --filter tuono",
"test": "turbo test --filter tuono",
"test:watch": "turbo test:watch --filter tuono"
"test": "turbo test",
"test:watch": "turbo test:watch"
},
"workspaces": [
"tuono",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-fs-router-vite-plugin",
"version": "0.5.0",
"version": "0.8.0",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { buildRouteConfig } from './build-route-config'
const routes = [
{
filePath: 'posts/my-post.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/my-post.tsx',
routePath: '/posts/my-post',
variableName: 'PostsMyPost',
parent: {
filePath: 'posts/__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
path: '/posts/__root',
cleanedPath: '/posts',
children: undefined,
},
path: '/posts/my-post',
cleanedPath: '/posts/my-post',
},
{
filePath: 'posts/index.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
routePath: '/posts/',
variableName: 'PostsIndex',
parent: {
filePath: 'posts/__root.tsx',
fullPath:
'/home/valerio/Documents/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
path: '/posts/__root',
cleanedPath: '/posts',
children: undefined,
},
path: '/posts/',
cleanedPath: '/posts/',
},
{
filePath: 'posts/[post].tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
routePath: '/posts/',
variableName: 'PostspostIndex',
parent: {
filePath: 'posts/__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
path: '/posts/__root',
cleanedPath: '/posts',
children: undefined,
},
path: '/posts/',
cleanedPath: '/posts/',
},
]
describe('buildRouteConfig works', async () => {
it('Should build the correct config', () => {
const expectedConfig =
'PostsMyPostRoute,PostsIndexRoute,PostspostIndexRoute'
const config = buildRouteConfig(routes)
expect(config).toStrictEqual(expectedConfig)
})
})
@@ -0,0 +1,17 @@
import { spaces } from './utils'
import type { RouteNode } from './types'
export function buildRouteConfig(nodes: RouteNode[], depth = 1): string {
const children = nodes.map((node) => {
const route = `${node.variableName}Route`
if (node.children?.length) {
const childConfigs = buildRouteConfig(node.children, depth + 1)
return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])`
}
return route
})
return children.filter(Boolean).join(`,`)
}
+17 -78
View File
@@ -1,15 +1,17 @@
import * as fsp from 'fs/promises'
import path from 'path'
import { buildRouteConfig } from './build-route-config'
import { hasParentRoute } from './has-parent-route'
import {
cleanPath,
determineNodePath,
multiSortBy,
spaces,
replaceBackslash,
removeExt,
routePathToVariable,
removeGroups,
removeLastSlash,
removeUnderscores,
removeLayoutSegments,
} from './utils'
@@ -18,6 +20,7 @@ import type { Config, RouteNode } from './types'
import { ROUTES_FOLDER, ROOT_PATH_ID, GENERATED_ROUTE_TREE } from './constants'
import { format } from 'prettier'
import { sortRouteNodes } from './sort-route-nodes'
let latestTask = 0
@@ -46,7 +49,7 @@ async function getRouteNodes(
if (dirent.isDirectory()) {
await recurse(relativePath)
} else if (fullPath.match(/\.(tsx|ts|jsx|js)$/)) {
} else if (fullPath.match(/\.(tsx|ts|jsx|js|mdx)$/)) {
const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath)
let routePath =
@@ -56,9 +59,6 @@ async function getRouteNodes(
// Remove the index from the route path and
// if the route path is empty, use `/'
const isLoader = routePath.includes('-loading')
if (routePath === 'index') {
routePath = '/'
}
@@ -69,7 +69,6 @@ async function getRouteNodes(
filePath,
fullPath,
routePath,
isLoader,
variableName,
})
} else if (fullPath.match(/\.(rs)$/)) {
@@ -95,38 +94,6 @@ async function getRouteNodes(
return { routeNodes, rustHandlersNodes }
}
export function hasParentRoute(
routes: RouteNode[],
node: RouteNode,
routePathToCheck: string | undefined,
): RouteNode | null {
if (!routePathToCheck || routePathToCheck === '/') {
return null
}
const sortedNodes = multiSortBy(routes, [
(d): number => d.routePath.length * -1,
(d): string | undefined => d.variableName,
]).filter((d) => d.routePath !== `/${ROOT_PATH_ID}`)
for (const route of sortedNodes) {
if (route.routePath === '/') continue
if (
routePathToCheck.startsWith(`${route.routePath}/`) &&
route.routePath !== routePathToCheck
) {
return route
}
}
const segments = routePathToCheck.split('/')
segments.pop() // Remove the last segment
const parentRoutePath = segments.join('/')
return hasParentRoute(routes, node, parentRoutePath)
}
export async function routeGenerator(config = defaultConfig): Promise<void> {
if (!isFirst) {
isFirst = true
@@ -149,25 +116,12 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
await getRouteNodes(config)
const preRouteNodes = multiSortBy(beforeRouteNodes, [
(d): number => (d.routePath === '/' ? -1 : 1),
(d): number => d.routePath.split('/').length,
(d): number => (d.filePath.match(/[./]index[.]/) ? 1 : -1),
(d): number =>
d.filePath.match(
/[./](component|errorComponent|pendingComponent|loader|lazy)[.]/,
)
? 1
: -1,
(d): number => (d.filePath.match(/[./]route[.]/) ? -1 : 1),
(d): number => (d.routePath.endsWith('/') ? -1 : 1),
(d): string => d.routePath,
]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || ''))
const preRouteNodes = sortRouteNodes(beforeRouteNodes)
const routeNodes: RouteNode[] = []
// Loop over the flat list of routeNodes and
// build up a tree based on the routeNodes' routePath
const routeNodes: RouteNode[] = []
const handleNode = async (node: RouteNode): Promise<void> => {
const parentRoute = hasParentRoute(routeNodes, node, node.routePath)
@@ -175,8 +129,8 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
node.path = determineNodePath(node)
node.cleanedPath = removeGroups(
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
node.cleanedPath = removeLastSlash(
removeGroups(removeUnderscores(removeLayoutSegments(node.path)) ?? ''),
)
if (node.parent) {
@@ -190,25 +144,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
await handleNode(node)
}
function buildRouteConfig(nodes: RouteNode[], depth = 1): string {
const children = nodes.map((node) => {
if (node.isLayout) {
return
}
const route = `${node.variableName}Route`
if (node.children?.length) {
const childConfigs = buildRouteConfig(node.children, depth + 1)
return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])`
}
return route
})
return children.filter(Boolean).join(`,`)
}
const routeConfigChildrenText = buildRouteConfig(routeNodes)
const sortedRouteNodes = multiSortBy(routeNodes, [
@@ -220,6 +155,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
const imports = [
...sortedRouteNodes.map((node) => {
const extension = node.filePath.endsWith('mdx') ? '.mdx' : ''
return `const ${
node.variableName
}Import = dynamic(() => import('./${replaceBackslash(
@@ -230,13 +166,16 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
),
false,
),
)}'))`
)}${extension}'))`
}),
].join('\n')
const createRoutes = [
...sortedRouteNodes.map((node) => {
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import })`
const isRoot = node.routePath.endsWith(ROOT_PATH_ID)
const rootDeclaration = isRoot ? ', isRoot: true' : ''
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import${rootDeclaration} })`
}),
].join('\n')
@@ -246,7 +185,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
return [
`const ${node.variableName}Route = ${node.variableName}.update({
${[
`path: '${node.cleanedPath}'`,
!node.path?.endsWith(ROOT_PATH_ID) && `path: '${node.cleanedPath}'`,
`getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
rustHandlersNodes.includes(node.path || '')
? 'hasHandler: true'
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { hasParentRoute } from './has-parent-route'
const routes = [
{
filePath: 'posts/[post].tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/[post].tsx',
routePath: '/posts/[post]',
variableName: 'Postspost',
path: '/posts/[post]',
cleanedPath: '/posts/[post]',
},
{
filePath: 'posts/__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
path: '/posts/__root',
cleanedPath: '/posts',
},
{
filePath: 'index.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/index.tsx',
routePath: '/',
variableName: 'Index',
path: '/',
cleanedPath: '/',
},
{
filePath: 'posts/my-post.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/my-post.tsx',
routePath: '/posts/my-post',
variableName: 'PostsMyPost',
path: '/posts/my-post',
cleanedPath: '/posts/my-post',
},
]
const parent = {
filePath: 'posts/__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
path: '/posts/__root',
cleanedPath: '/posts',
}
const myPost = {
filePath: 'posts/my-post.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/my-post.tsx',
routePath: '/posts/my-post',
variableName: 'PostsMyPost',
path: '/posts/my-post',
cleanedPath: '/posts/my-post',
}
const dynamicRoute = {
filePath: 'posts/[post].tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/[post].tsx',
routePath: '/posts/[post]',
variableName: 'Postspost',
path: '/posts/[post]',
cleanedPath: '/posts/[post]',
}
describe('hasParentRoute works', async () => {
it('Should detect parent route', () => {
const parentRoute = hasParentRoute(routes, myPost, myPost.path)
expect(parentRoute).toStrictEqual(parent)
})
it('Should detect parent route for dynamic routes', () => {
const parentRoute = hasParentRoute(routes, dynamicRoute, dynamicRoute.path)
expect(parentRoute).toStrictEqual(parent)
})
})
@@ -0,0 +1,37 @@
import { ROOT_PATH_ID } from './constants'
import { multiSortBy } from './utils'
import type { RouteNode } from './types'
export function hasParentRoute(
routes: RouteNode[],
node: RouteNode,
routePathToCheck = '/',
): RouteNode | null {
const segments = routePathToCheck.split('/')
segments.pop() // Remove the last segment
const parentRoutePath = segments.join('/')
if (!parentRoutePath || parentRoutePath === '/') {
return null
}
const sortedNodes = multiSortBy(routes, [
(d): number => d.routePath.length * -1,
(d): string | undefined => d.variableName,
])
// Exclude base __root file
.filter((d) => d.routePath !== `/${ROOT_PATH_ID}`)
for (const route of sortedNodes) {
if (route.routePath === '/') continue
if (
route.routePath.startsWith(parentRoutePath) &&
route.routePath.endsWith(ROOT_PATH_ID)
) {
return route
}
}
return hasParentRoute(routes, node, parentRoutePath)
}
@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest'
import { sortRouteNodes } from './sort-route-nodes'
const routes = [
{
filePath: 'index.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/index.tsx',
routePath: '/',
variableName: 'Index',
path: '/',
cleanedPath: '/',
},
{
filePath: 'about.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/about.tsx',
routePath: '/about',
variableName: 'About',
path: '/about',
cleanedPath: '/about',
},
{
filePath: '__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/__root.tsx',
routePath: '/__root',
variableName: 'root',
},
{
filePath: 'posts/[post].tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/[post].tsx',
routePath: '/posts/[post]',
variableName: 'Postspost',
},
{
filePath: 'posts/my-post.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/my-post.tsx',
routePath: '/posts/my-post',
variableName: 'PostsMyPost',
},
{
filePath: 'posts/index.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
routePath: '/posts/',
variableName: 'PostsIndex',
},
{
filePath: 'posts/__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
},
]
const expectedSorting = [
{
filePath: 'index.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/index.tsx',
routePath: '/',
variableName: 'Index',
path: '/',
cleanedPath: '/',
},
{
filePath: 'about.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/about.tsx',
routePath: '/about',
variableName: 'About',
path: '/about',
cleanedPath: '/about',
},
{
filePath: 'posts/__root.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
routePath: '/posts/__root',
variableName: 'Postsroot',
},
{
filePath: 'posts/my-post.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/my-post.tsx',
routePath: '/posts/my-post',
variableName: 'PostsMyPost',
},
{
filePath: 'posts/index.tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
routePath: '/posts/',
variableName: 'PostsIndex',
},
{
filePath: 'posts/[post].tsx',
fullPath:
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/[post].tsx',
routePath: '/posts/[post]',
variableName: 'Postspost',
},
]
describe('sortRouteNodes works', async () => {
it('Should correctly sort the nodes', () => {
const sorted = sortRouteNodes(routes)
expect(sorted).toStrictEqual(expectedSorting)
})
})
@@ -0,0 +1,17 @@
import type { RouteNode } from './types'
import { multiSortBy } from './utils'
import { ROOT_PATH_ID } from './constants'
// Routes need to be sorted in order to iterate over the handleNode fn
// with first the items that might be parent routes
export const sortRouteNodes = (routes: RouteNode[]): RouteNode[] =>
multiSortBy(routes, [
(d): number => (d.routePath === '/' ? -1 : 1),
(d): number => d.routePath.split('/').length,
// Dynamic route
(d): number => (d.routePath.endsWith(']') ? 1 : -1),
(d): number => (d.filePath.match(/[./]index[.]/) ? 1 : -1),
(d): number => (d.filePath.match(/[./]route[.]/) ? -1 : 1),
(d): number => (d.routePath.endsWith('/') ? -1 : 1),
(d): string => d.routePath,
]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || ''))
@@ -5,7 +5,6 @@ export interface RouteNode {
path?: string
cleanedPath?: string
isLayout?: boolean
isLoader: boolean
children?: RouteNode[]
parent?: RouteNode
variableName?: string
@@ -100,6 +100,12 @@ export function trimPathLeft(pathToTrim: string): string {
return pathToTrim === '/' ? pathToTrim : pathToTrim.replace(/^\/{1,}/, '')
}
export function removeLastSlash(str: string): string {
if (str.length > 1 && str.endsWith('/'))
return str.substring(0, str.length - 1)
return str
}
/**
* The `node.path` is used as the `id` in the route definition.
* This function checks if the given node has a parent and if so, it determines the correct path for the given node.
@@ -0,0 +1,29 @@
// This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono'
import RootImport from './routes/__root'
const AboutImport = dynamic(() => import('./routes/about.mdx'))
const IndexImport = dynamic(() => import('./routes/index'))
const rootRoute = createRoute({ isRoot: true, component: RootImport })
const About = createRoute({ component: AboutImport })
const Index = createRoute({ component: IndexImport })
// Create/Update Routes
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
})
// Create and export the route tree
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
@@ -0,0 +1 @@
/** */
@@ -0,0 +1 @@
/** */
@@ -0,0 +1,67 @@
// This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono'
import RootImport from './routes/__root'
const PostsrootImport = dynamic(() => import('./routes/posts/__root'))
const AboutImport = dynamic(() => import('./routes/about'))
const IndexImport = dynamic(() => import('./routes/index'))
const PostspostImport = dynamic(() => import('./routes/posts/[post]'))
const PostsIndexImport = dynamic(() => import('./routes/posts/index'))
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
const rootRoute = createRoute({ isRoot: true, component: RootImport })
const Postsroot = createRoute({ component: PostsrootImport, isRoot: true })
const About = createRoute({ component: AboutImport })
const Index = createRoute({ component: IndexImport })
const Postspost = createRoute({ component: PostspostImport })
const PostsIndex = createRoute({ component: PostsIndexImport })
const PostsMyPost = createRoute({ component: PostsMyPostImport })
// Create/Update Routes
const PostsrootRoute = Postsroot.update({
getParentRoute: () => rootRoute,
})
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
})
const PostspostRoute = Postspost.update({
path: '/posts/[post]',
getParentRoute: () => PostsrootRoute,
})
const PostsIndexRoute = PostsIndex.update({
path: '/posts',
getParentRoute: () => PostsrootRoute,
})
const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post',
getParentRoute: () => PostsrootRoute,
})
// Create and export the route tree
export const routeTree = rootRoute.addChildren([
IndexRoute,
AboutRoute,
PostsrootRoute.addChildren([
PostsMyPostRoute,
PostsIndexRoute,
PostspostRoute,
]),
PostsMyPostRoute,
PostsIndexRoute,
PostspostRoute,
])
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-lazy-fn-vite-plugin",
"version": "0.5.0",
"version": "0.8.0",
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono",
"version": "0.5.0",
"version": "0.8.0",
"description": "The react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -84,6 +84,7 @@
"@babel/template": "^7.24.0",
"@babel/traverse": "^7.24.1",
"@babel/types": "^7.24.0",
"@mdx-js/rollup": "^3.0.1",
"@types/babel__core": "^7.20.5",
"@types/node": "^20.12.7",
"@vitejs/plugin-react-swc": "^3.7.0",
+7 -1
View File
@@ -2,6 +2,7 @@ import { build, createServer, InlineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import ViteFsRouter from 'tuono-fs-router-vite-plugin'
import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
import mdx from '@mdx-js/rollup'
const BASE_CONFIG: InlineConfig = {
root: '.tuono',
@@ -9,7 +10,12 @@ const BASE_CONFIG: InlineConfig = {
publicDir: '../public',
cacheDir: 'cache',
envDir: '../',
plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()],
plugins: [
{ enforce: 'pre', ...mdx() },
react(),
ViteFsRouter(),
LazyLoadingPlugin(),
],
}
const VITE_PORT = 3001
@@ -0,0 +1,56 @@
import * as React from 'react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { RouteMatch } from './RouteMatch'
import { cleanup, render, screen } from '@testing-library/react'
import type { Route } from '../route'
import '@testing-library/jest-dom'
interface Props {
children: React.ReactNode
}
const root = {
isRoot: true,
component: ({ children }: Props) => (
<div data-testid="root">root route {children}</div>
),
} as Route
const parent = {
component: ({ children }: Props) => (
<div data-testid="parent">parent route {children}</div>
),
options: {
getParentRoute: () => root,
},
} as Route
const route = {
component: () => <p data-testid="route">current route</p>,
options: {
getParentRoute: () => parent,
},
} as Route
describe('Test RouteMatch component', () => {
afterEach(() => {
cleanup()
})
test('It should correctly render nested routes', () => {
vi.mock('../hooks/useServerSideProps.tsx', () => ({
useServerSideProps: (): { data: any; isLoading: boolean } => {
return {
data: undefined,
isLoading: false,
}
},
}))
render(<RouteMatch route={route} serverSideProps={{}} />)
expect(screen.getByTestId('root')).toHaveTextContent(
'root route parent route current route',
)
expect(screen.getByTestId('route')).toHaveTextContent('current route')
})
})
@@ -19,20 +19,63 @@ export const RouteMatch = ({
}: MatchProps): JSX.Element => {
const { data, isLoading } = useServerSideProps(route, serverSideProps)
if (!route.isRoot) {
const Root = route.options.getParentRoute()
return (
<Root.component data={data} isLoading={isLoading}>
<React.Suspense>
<route.options.component data={data} isLoading={isLoading} />
</React.Suspense>
</Root.component>
)
}
const routes = React.useMemo(() => {
const components = loadParentComponents(route)
components.push(route)
return components
}, [route.id])
return (
<React.Suspense>
<route.options.component data={data} isLoading={isLoading} />
</React.Suspense>
<TraverseRootComponents routes={routes} data={data} isLoading={isLoading} />
)
}
interface TraverseRootComponentsProps {
routes: Route[]
data: any
isLoading: boolean
children?: React.ReactNode
index?: number
}
interface ParentProps {
children: React.ReactNode
data: any
isLoading: boolean
}
const TraverseRootComponents = ({
routes,
data,
isLoading,
index = 0,
}: TraverseRootComponentsProps): JSX.Element => {
const Parent = React.memo(
routes[index]?.component as unknown as (props: ParentProps) => JSX.Element,
)
return (
<Parent data={data} isLoading={isLoading}>
{Boolean(routes.length > index) && (
<TraverseRootComponents
routes={routes}
data={data}
isLoading={isLoading}
index={index + 1}
/>
)}
</Parent>
)
}
const loadParentComponents = (route: Route, loader: Route[] = []): Route[] => {
const parentComponent = route.options?.getParentRoute?.()
loader.push(parentComponent)
if (!parentComponent.isRoot) {
return loadParentComponents(parentComponent, loader)
}
return loader.reverse()
}
@@ -24,7 +24,8 @@ interface TuonoApi {
}
const fetchClientSideData = async (): Promise<TuonoApi> => {
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
}
+4 -9
View File
@@ -56,12 +56,7 @@ export class Route {
const customId = this.options?.id || path
// Strip the parentId prefix from the first level of children
let id = isRoot
? rootRouteId
: joinPaths([
this.parentRoute.id === rootRouteId ? '' : this.parentRoute.id,
customId,
])
let id = isRoot ? rootRouteId : joinPaths([customId])
if (path === rootRouteId) {
path = '/'
@@ -71,12 +66,11 @@ export class Route {
id = joinPaths(['/', id])
}
const fullPath =
id === rootRouteId ? '/' : joinPaths([this.parentRoute.fullPath, path])
const fullPath = id === rootRouteId ? '/' : path
this.path = path
this.id = id
this.fullPath = fullPath
this.fullPath = fullPath || ''
}
addChildren(routes: Route[]): Route {
@@ -91,6 +85,7 @@ export class Route {
}
}
// TODO: not use yet. To be updated in tuono-fs-router-vite-plugin package
export function createRootRoute(options: RouteOptions): Route {
return new Route({ ...options, isRoot: true })
}