mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 13:52:46 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae7fea1236 | |||
| 9f333962dc | |||
| a6cbfdf20c |
@@ -43,7 +43,7 @@ jobs:
|
|||||||
NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
|
NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
|
||||||
echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
|
echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
check_example:
|
check_rust:
|
||||||
needs:
|
needs:
|
||||||
- example_list
|
- example_list
|
||||||
|
|
||||||
@@ -87,11 +87,26 @@ jobs:
|
|||||||
working-directory: ./examples/${{ matrix.example_name }}
|
working-directory: ./examples/${{ matrix.example_name }}
|
||||||
run: ../../target/debug/tuono build
|
run: ../../target/debug/tuono build
|
||||||
|
|
||||||
|
check_typescript:
|
||||||
|
name: Check typescript
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install NodeJS Dependencies
|
||||||
|
uses: ./.github/actions/install-node-dependencies
|
||||||
|
|
||||||
|
- name: Build typescript
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: pnpm run format
|
run: pnpm run examples:format
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm run typecheck
|
run: pnpm run examples:typecheck
|
||||||
|
|
||||||
ci_ok:
|
ci_ok:
|
||||||
name: Examples CI OK
|
name: Examples CI OK
|
||||||
@@ -100,7 +115,8 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
needs:
|
needs:
|
||||||
- example_list
|
- example_list
|
||||||
- check_example
|
- check_rust
|
||||||
|
- check_typescript
|
||||||
steps:
|
steps:
|
||||||
- name: Exit with error if some jobs are not successful
|
- name: Exit with error if some jobs are not successful
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono"
|
name = "tuono"
|
||||||
version = "0.19.7"
|
version = "0.19.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "Superfast React fullstack framework"
|
description = "Superfast React fullstack framework"
|
||||||
@@ -18,13 +18,11 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.4", features = ["derive", "cargo"] }
|
clap = { version = "4.5.4", features = ["derive", "cargo"] }
|
||||||
syn = { version = "2.0.100", features = ["full"] }
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
|
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
|
||||||
miette = "7.2.0"
|
miette = "7.2.0"
|
||||||
|
|
||||||
colored = "3.0.0"
|
colored = "2.1.0"
|
||||||
once_cell = "1.19.0"
|
|
||||||
watchexec = "5.0.0"
|
watchexec = "5.0.0"
|
||||||
watchexec-signals = "4.0.0"
|
watchexec-signals = "4.0.0"
|
||||||
watchexec-events = "4.0.0"
|
watchexec-events = "4.0.0"
|
||||||
@@ -37,10 +35,9 @@ reqwest = { version = "0.12.4", features = ["blocking", "json"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
fs_extra = "1.3.0"
|
fs_extra = "1.3.0"
|
||||||
http = "1.1.0"
|
http = "1.1.0"
|
||||||
tuono_internal = {path = "../tuono_internal", version = "0.19.7"}
|
tuono_internal = {path = "../tuono_internal", version = "0.19.2"}
|
||||||
spinners = "4.1.1"
|
spinners = "4.1.1"
|
||||||
console = "0.16.0"
|
console = "0.15.10"
|
||||||
convert_case = "0.8.0"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wiremock = "0.6.2"
|
wiremock = "0.6.2"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::mode::Mode;
|
use crate::mode::Mode;
|
||||||
use crate::route::Route;
|
use crate::route::Route;
|
||||||
use glob::{GlobError, glob};
|
use glob::GlobError;
|
||||||
|
use glob::glob;
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use std::collections::hash_set::HashSet;
|
use std::collections::hash_set::HashSet;
|
||||||
use std::collections::{HashMap, hash_map::Entry};
|
use std::collections::{HashMap, hash_map::Entry};
|
||||||
@@ -164,9 +165,10 @@ impl App {
|
|||||||
std::net::TcpListener::bind(format!("{}:{}", config.server.host, vite_port));
|
std::net::TcpListener::bind(format!("{}:{}", config.server.host, vite_port));
|
||||||
|
|
||||||
if let Err(_e) = vite_listener {
|
if let Err(_e) = vite_listener {
|
||||||
eprintln!("Error: Failed to bind to port {vite_port}");
|
eprintln!("Error: Failed to bind to port {}", vite_port);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Please ensure that port {vite_port} is not already in use by another process or application."
|
"Please ensure that port {} is not already in use by another process or application.",
|
||||||
|
vite_port
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -252,6 +254,7 @@ impl App {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
use once_cell::sync::Lazy;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::error;
|
|
||||||
use tuono_internal::tuono_println;
|
use tuono_internal::tuono_println;
|
||||||
use watchexec_events::Tag;
|
use watchexec_events::Tag;
|
||||||
use watchexec_events::filekind::FileEventKind;
|
use watchexec_events::filekind::FileEventKind;
|
||||||
@@ -22,12 +17,6 @@ use crate::source_builder::SourceBuilder;
|
|||||||
use console::Term;
|
use console::Term;
|
||||||
use spinners::{Spinner, Spinners};
|
use spinners::{Spinner, Spinners};
|
||||||
|
|
||||||
fn is_css_module(file_name: Cow<str>) -> bool {
|
|
||||||
static RE: Lazy<Regex> =
|
|
||||||
Lazy::new(|| Regex::new(r"^.*\.module\.(css|scss|sass|less|styl|stylus)$").unwrap());
|
|
||||||
RE.is_match(&file_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ssr_reload_needed(path: &Path) -> bool {
|
fn ssr_reload_needed(path: &Path) -> bool {
|
||||||
let file_name_starts_with_env = path
|
let file_name_starts_with_env = path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -36,19 +25,14 @@ fn ssr_reload_needed(path: &Path) -> bool {
|
|||||||
|
|
||||||
let file_path = path.to_string_lossy();
|
let file_path = path.to_string_lossy();
|
||||||
|
|
||||||
file_name_starts_with_env
|
file_name_starts_with_env || file_path.ends_with("sx") || file_path.ends_with("mdx")
|
||||||
|| file_path.ends_with("sx")
|
|
||||||
|| file_path.ends_with("mdx")
|
|
||||||
// When a CSS module is modified
|
|
||||||
// also the class names get modified.
|
|
||||||
|| is_css_module(file_path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(
|
#[allow(
|
||||||
clippy::await_holding_lock,
|
clippy::await_holding_lock,
|
||||||
reason = "At this point there is no other thread waiting for the lock"
|
reason = "At this point there is no other thread waiting for the lock"
|
||||||
)]
|
)]
|
||||||
async unsafe fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
|
async fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
|
||||||
if let Ok(mut pm) = process_manager.lock() {
|
if let Ok(mut pm) = process_manager.lock() {
|
||||||
pm.start_dev_processes().await
|
pm.start_dev_processes().await
|
||||||
}
|
}
|
||||||
@@ -75,12 +59,7 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
|
|||||||
|
|
||||||
let env_files = detect_existing_env_files();
|
let env_files = detect_existing_env_files();
|
||||||
|
|
||||||
unsafe {
|
start_all_processes(process_manager.clone()).await;
|
||||||
// It is safe to call this function because here
|
|
||||||
// only one thread is running and the lock is not
|
|
||||||
// needed by any other thread.
|
|
||||||
start_all_processes(process_manager.clone()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the spinner
|
// Remove the spinner
|
||||||
sp.stop();
|
sp.stop();
|
||||||
@@ -115,59 +94,38 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
|
|||||||
let mut should_reload_rust_server = false;
|
let mut should_reload_rust_server = false;
|
||||||
let mut should_refresh_axum_source = false;
|
let mut should_refresh_axum_source = false;
|
||||||
|
|
||||||
// Using HashSet to avoid duplicates
|
|
||||||
let mut paths_to_refresh_types: HashSet<PathBuf> = HashSet::new();
|
|
||||||
let mut removed_files_from_types: HashSet<PathBuf> = HashSet::new();
|
|
||||||
|
|
||||||
for event in action.events.iter() {
|
for event in action.events.iter() {
|
||||||
for event_type in event.tags.iter() {
|
for event_type in event.tags.iter() {
|
||||||
if let Tag::FileEventKind(kind) = event_type {
|
if let Tag::FileEventKind(kind) = event_type {
|
||||||
match kind {
|
match kind {
|
||||||
FileEventKind::Remove(_) => event.paths().for_each(|(path, _)| {
|
FileEventKind::Remove(_) => {
|
||||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
if event.paths().any(|(path, _)| {
|
||||||
removed_files_from_types.insert(path.to_path_buf());
|
path.extension().is_some_and(|ext| ext == "rs") ||
|
||||||
|
// APIs might define new HTTP methods that requires
|
||||||
|
// a refresh of the axum source
|
||||||
|
path.to_str().unwrap_or("").contains("api")
|
||||||
|
}) {
|
||||||
should_refresh_axum_source = true;
|
should_refresh_axum_source = true;
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
FileEventKind::Modify(_) => event.paths().for_each(|(path, _)| {
|
FileEventKind::Modify(_) => {
|
||||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
if event
|
||||||
|
.paths()
|
||||||
|
.any(|(path, _)| path.extension().is_some_and(|ext| ext == "rs"))
|
||||||
|
{
|
||||||
should_reload_rust_server = true;
|
should_reload_rust_server = true;
|
||||||
paths_to_refresh_types.insert(path.to_path_buf());
|
|
||||||
}
|
}
|
||||||
if ssr_reload_needed(path) {
|
|
||||||
|
if event.paths().any(|(path, _)| ssr_reload_needed(path)) {
|
||||||
should_reload_ssr_bundle = true;
|
should_reload_ssr_bundle = true;
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !paths_to_refresh_types.is_empty() {
|
|
||||||
if let Ok(mut builder) = source_builder.write() {
|
|
||||||
for path in paths_to_refresh_types {
|
|
||||||
// There is no need to check here if the `Type` trait is
|
|
||||||
// derived since it will be checked later by the TypeJar struct.
|
|
||||||
builder.refresh_typescript_file(path)
|
|
||||||
}
|
|
||||||
if builder.generate_typescript_file().is_err() {
|
|
||||||
error!("Failed to generate typescript file");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !removed_files_from_types.is_empty() {
|
|
||||||
if let Ok(mut builder) = source_builder.write() {
|
|
||||||
for path in removed_files_from_types {
|
|
||||||
builder.remove_typescript_file(path);
|
|
||||||
}
|
|
||||||
if builder.generate_typescript_file().is_err() {
|
|
||||||
error!("Failed to generate typescript file");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if should_reload_rust_server || should_refresh_axum_source {
|
if should_reload_rust_server || should_refresh_axum_source {
|
||||||
tuono_println!("Reloading...");
|
tuono_println!("Reloading...");
|
||||||
if should_refresh_axum_source {
|
if should_refresh_axum_source {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ struct GithubTreeResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn exit_with_error(message: &str) -> ! {
|
fn exit_with_error(message: &str) -> ! {
|
||||||
eprintln!("{message}");
|
eprintln!("{}", message);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ pub fn create_new_project(
|
|||||||
let folder_path = current_dir.join(folder_name);
|
let folder_path = current_dir.join(folder_name);
|
||||||
|
|
||||||
create_directories(&new_project_files, &folder_path, &template)
|
create_directories(&new_project_files, &folder_path, &template)
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to create directories: {err}")));
|
.unwrap_or_else(|err| exit_with_error(&format!("Failed to create directories: {}", err)));
|
||||||
|
|
||||||
for GithubFile {
|
for GithubFile {
|
||||||
element_type, path, ..
|
element_type, path, ..
|
||||||
@@ -231,17 +231,19 @@ fn update_package_json_version(folder_path: &Path) -> io::Result<()> {
|
|||||||
let v = crate_version!();
|
let v = crate_version!();
|
||||||
let package_json_path = folder_path.join(PathBuf::from("package.json"));
|
let package_json_path = folder_path.join(PathBuf::from("package.json"));
|
||||||
let package_json = fs::read_to_string(&package_json_path)
|
let package_json = fs::read_to_string(&package_json_path)
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read package.json: {err}")));
|
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read package.json: {}", err)));
|
||||||
let package_json = package_json.replace("link:../../packages/tuono", v);
|
let package_json = package_json.replace("link:../../packages/tuono", v);
|
||||||
|
|
||||||
let mut file = OpenOptions::new()
|
let mut file = OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.open(package_json_path)
|
.open(package_json_path)
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open package.json: {err}")));
|
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open package.json: {}", err)));
|
||||||
|
|
||||||
file.write_all(package_json.as_bytes())
|
file.write_all(package_json.as_bytes())
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to package.json: {err}")));
|
.unwrap_or_else(|err| {
|
||||||
|
exit_with_error(&format!("Failed to write to package.json: {}", err))
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -250,7 +252,7 @@ fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
|
|||||||
let v = crate_version!();
|
let v = crate_version!();
|
||||||
let cargo_toml_path = folder_path.join(PathBuf::from("Cargo.toml"));
|
let cargo_toml_path = folder_path.join(PathBuf::from("Cargo.toml"));
|
||||||
let cargo_toml = fs::read_to_string(&cargo_toml_path)
|
let cargo_toml = fs::read_to_string(&cargo_toml_path)
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read Cargo.toml: {err}")));
|
.unwrap_or_else(|err| exit_with_error(&format!("Failed to read Cargo.toml: {}", err)));
|
||||||
let cargo_toml = cargo_toml.replace(
|
let cargo_toml = cargo_toml.replace(
|
||||||
"{ path = \"../../crates/tuono_lib/\" }",
|
"{ path = \"../../crates/tuono_lib/\" }",
|
||||||
&format!("\"{v}\""),
|
&format!("\"{v}\""),
|
||||||
@@ -260,10 +262,10 @@ fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
|
|||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.open(cargo_toml_path)
|
.open(cargo_toml_path)
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open Cargo.toml: {err}")));
|
.unwrap_or_else(|err| exit_with_error(&format!("Failed to open Cargo.toml: {}", err)));
|
||||||
|
|
||||||
file.write_all(cargo_toml.as_bytes())
|
file.write_all(cargo_toml.as_bytes())
|
||||||
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to Cargo.toml: {err}")));
|
.unwrap_or_else(|err| exit_with_error(&format!("Failed to write to Cargo.toml: {}", err)));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,3 @@ mod mode;
|
|||||||
mod process_manager;
|
mod process_manager;
|
||||||
mod route;
|
mod route;
|
||||||
mod source_builder;
|
mod source_builder;
|
||||||
mod symbols;
|
|
||||||
mod typescript;
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ impl ProcessManager {
|
|||||||
let mut processes = HashMap::new();
|
let mut processes = HashMap::new();
|
||||||
processes.insert(
|
processes.insert(
|
||||||
ProcessId::WatchReactSrc,
|
ProcessId::WatchReactSrc,
|
||||||
start_supervisor_job(DEV_WATCH_BIN_SRC, Vec::new()),
|
start_supervisor_job(DEV_WATCH_BIN_SRC, vec![]),
|
||||||
);
|
);
|
||||||
|
|
||||||
processes.insert(
|
processes.insert(
|
||||||
@@ -63,7 +63,7 @@ impl ProcessManager {
|
|||||||
|
|
||||||
processes.insert(
|
processes.insert(
|
||||||
ProcessId::BuildReactSSRSrc,
|
ProcessId::BuildReactSSRSrc,
|
||||||
start_supervisor_job(DEV_SSR_BIN_SRC, Vec::new()),
|
start_supervisor_job(DEV_SSR_BIN_SRC, vec![]),
|
||||||
);
|
);
|
||||||
|
|
||||||
Self { processes }
|
Self { processes }
|
||||||
@@ -94,7 +94,7 @@ impl ProcessManager {
|
|||||||
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
||||||
// Format the server address as a valid URL so that it becomes clickable in the CLI
|
// Format the server address as a valid URL so that it becomes clickable in the CLI
|
||||||
// @see https://github.com/tuono-labs/tuono/issues/460
|
// @see https://github.com/tuono-labs/tuono/issues/460
|
||||||
let server_base_url = format!("http://{server_address}");
|
let server_base_url = format!("http://{}", server_address);
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
tuono_println!("⚡ Tuono v{}", crate_version!());
|
tuono_println!("⚡ Tuono v{}", crate_version!());
|
||||||
|
|||||||
+19
-11
@@ -102,7 +102,7 @@ fn read_http_methods_from_file(path: &String) -> Vec<Method> {
|
|||||||
// Extract just the element surrounded by the phrantesist.
|
// Extract just the element surrounded by the phrantesist.
|
||||||
.replace("tuono_lib::api(", "")
|
.replace("tuono_lib::api(", "")
|
||||||
.replace(")]", "");
|
.replace(")]", "");
|
||||||
Method::from_str(http_method.to_uppercase().as_str()).unwrap_or(Method::GET)
|
Method::from_str(http_method.as_str()).unwrap_or(Method::GET)
|
||||||
})
|
})
|
||||||
.collect::<Vec<Method>>()
|
.collect::<Vec<Method>>()
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ impl Route {
|
|||||||
trace!("Requesting the page: {}", url);
|
trace!("Requesting the page: {}", url);
|
||||||
let mut response = match reqwest.get(format!("http://localhost:3000{path}")).send() {
|
let mut response = match reqwest.get(format!("http://localhost:3000{path}")).send() {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(_) => return Err(format!("Failed to get the response: {url}")),
|
Err(_) => return Err(format!("Failed to get the response: {}", url)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let file_path = self.output_file_path();
|
let file_path = self.output_file_path();
|
||||||
@@ -177,14 +177,18 @@ impl Route {
|
|||||||
let parent_dir = match file_path.parent() {
|
let parent_dir = match file_path.parent() {
|
||||||
Some(parent_dir) => parent_dir,
|
Some(parent_dir) => parent_dir,
|
||||||
None => {
|
None => {
|
||||||
return Err(format!("Failed to get the parent directory {file_path:?}"));
|
return Err(format!(
|
||||||
|
"Failed to get the parent directory {:?}",
|
||||||
|
file_path
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !parent_dir.is_dir() {
|
if !parent_dir.is_dir() {
|
||||||
if let Err(err) = create_all(parent_dir, false) {
|
if let Err(err) = create_all(parent_dir, false) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to create the parent directory {parent_dir:?}\nError: {err}"
|
"Failed to create the parent directory {:?}\nError: {err}",
|
||||||
|
parent_dir
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,11 +197,11 @@ impl Route {
|
|||||||
|
|
||||||
let mut file = match File::create(&file_path) {
|
let mut file = match File::create(&file_path) {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(_) => return Err(format!("Failed to create the file: {file_path:?}")),
|
Err(_) => return Err(format!("Failed to create the file: {:?}", file_path)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if io::copy(&mut response, &mut file).is_err() {
|
if io::copy(&mut response, &mut file).is_err() {
|
||||||
return Err(format!("Failed to write the file: {file_path:?}"));
|
return Err(format!("Failed to write the file: {:?}", file_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saving also the server response
|
// Saving also the server response
|
||||||
@@ -210,7 +214,8 @@ impl Route {
|
|||||||
Some(parent_dir) => parent_dir,
|
Some(parent_dir) => parent_dir,
|
||||||
None => {
|
None => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to get the parent directory {data_file_path:?}"
|
"Failed to get the parent directory {:?}",
|
||||||
|
data_file_path
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -218,7 +223,8 @@ impl Route {
|
|||||||
if !data_parent_dir.is_dir() {
|
if !data_parent_dir.is_dir() {
|
||||||
if let Err(err) = create_all(data_parent_dir, false) {
|
if let Err(err) = create_all(data_parent_dir, false) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to create the parent directory {data_parent_dir:?}\n Error: {err}"
|
"Failed to create the parent directory {:?}\n Error: {err}",
|
||||||
|
data_parent_dir
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +253,8 @@ impl Route {
|
|||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to create the JSON file: {data_file_path:?}"
|
"Failed to create the JSON file: {:?}",
|
||||||
|
data_file_path
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -267,15 +274,16 @@ impl Route {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|extension| self.path.ends_with(extension))
|
.any(|extension| self.path.ends_with(extension))
|
||||||
{
|
{
|
||||||
return PathBuf::from(format!("out/static{cleaned_path}"));
|
return PathBuf::from(format!("out/static{}", cleaned_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
PathBuf::from(format!("out/static{cleaned_path}/index.html"))
|
PathBuf::from(format!("out/static{}/index.html", cleaned_path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use crate::app::App;
|
|||||||
use crate::mode::Mode;
|
use crate::mode::Mode;
|
||||||
use crate::route::AxumInfo;
|
use crate::route::AxumInfo;
|
||||||
use crate::route::Route;
|
use crate::route::Route;
|
||||||
use crate::typescript::TypesJar;
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
const FALLBACK_HTML: &str = include_str!("../templates/fallback.html");
|
const FALLBACK_HTML: &str = include_str!("../templates/fallback.html");
|
||||||
@@ -60,7 +59,6 @@ pub struct SourceBuilder {
|
|||||||
pub app: App,
|
pub app: App,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
types_jar: TypesJar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceBuilder {
|
impl SourceBuilder {
|
||||||
@@ -81,23 +79,20 @@ impl SourceBuilder {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
app,
|
app,
|
||||||
mode,
|
mode,
|
||||||
types_jar: TypesJar::from(&base_path),
|
|
||||||
base_path,
|
base_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the source code needed for both build and dev
|
// Build the source code needed for both build and dev
|
||||||
pub fn base_build(&mut self) -> io::Result<()> {
|
pub fn base_build(&mut self) -> io::Result<()> {
|
||||||
let mode = self.mode.clone();
|
let Self { mode, .. } = &self;
|
||||||
|
|
||||||
self.refresh_axum_source()?;
|
self.refresh_axum_source()?;
|
||||||
let dev_folder = Path::new(DEV_FOLDER);
|
let dev_folder = Path::new(DEV_FOLDER);
|
||||||
self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?;
|
self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?;
|
||||||
self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?;
|
self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?;
|
||||||
|
|
||||||
self.types_jar.generate_typescript_file(&self.base_path)?;
|
if mode == &Mode::Dev {
|
||||||
|
|
||||||
if mode == Mode::Dev {
|
|
||||||
self.app.build_tuono_config()?;
|
self.app.build_tuono_config()?;
|
||||||
let fallback_html = self.build_html_fallback();
|
let fallback_html = self.build_html_fallback();
|
||||||
self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?;
|
self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?;
|
||||||
@@ -170,18 +165,6 @@ impl SourceBuilder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_typescript_file(&mut self, path: PathBuf) {
|
|
||||||
self.types_jar.refresh_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_typescript_file(&mut self, path: PathBuf) {
|
|
||||||
self.types_jar.remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_typescript_file(&mut self) -> io::Result<()> {
|
|
||||||
self.types_jar.generate_typescript_file(&self.base_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_routes_declaration(&self) -> String {
|
fn create_routes_declaration(&self) -> String {
|
||||||
let routes = &self.app.route_map;
|
let routes = &self.app.route_map;
|
||||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||||
@@ -261,7 +244,6 @@ mod tests {
|
|||||||
app: App::new(),
|
app: App::new(),
|
||||||
mode: Mode::Dev,
|
mode: Mode::Dev,
|
||||||
base_path: PathBuf::new(),
|
base_path: PathBuf::new(),
|
||||||
types_jar: TypesJar::default(),
|
|
||||||
}
|
}
|
||||||
.generate_axum_source();
|
.generate_axum_source();
|
||||||
|
|
||||||
@@ -269,7 +251,6 @@ mod tests {
|
|||||||
app: App::new(),
|
app: App::new(),
|
||||||
mode: Mode::Prod,
|
mode: Mode::Prod,
|
||||||
base_path: PathBuf::new(),
|
base_path: PathBuf::new(),
|
||||||
types_jar: TypesJar::default(),
|
|
||||||
}
|
}
|
||||||
.generate_axum_source();
|
.generate_axum_source();
|
||||||
|
|
||||||
@@ -283,7 +264,6 @@ mod tests {
|
|||||||
app: App::new(),
|
app: App::new(),
|
||||||
mode: Mode::Dev,
|
mode: Mode::Dev,
|
||||||
base_path: PathBuf::new(),
|
base_path: PathBuf::new(),
|
||||||
types_jar: TypesJar::default(),
|
|
||||||
}
|
}
|
||||||
.generate_axum_source();
|
.generate_axum_source();
|
||||||
|
|
||||||
@@ -296,7 +276,6 @@ mod tests {
|
|||||||
app: App::new(),
|
app: App::new(),
|
||||||
mode: Mode::Dev,
|
mode: Mode::Dev,
|
||||||
base_path: PathBuf::new(),
|
base_path: PathBuf::new(),
|
||||||
types_jar: TypesJar::default(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut route = Route::new(String::from("index.tsx"));
|
let mut route = Route::new(String::from("index.tsx"));
|
||||||
@@ -321,7 +300,6 @@ mod tests {
|
|||||||
app,
|
app,
|
||||||
mode: Mode::Dev,
|
mode: Mode::Dev,
|
||||||
base_path: PathBuf::new(),
|
base_path: PathBuf::new(),
|
||||||
types_jar: TypesJar::default(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let fallback_html = source_builder.build_html_fallback();
|
let fallback_html = source_builder.build_html_fallback();
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
use std::ops::Deref;
|
|
||||||
|
|
||||||
use syn::Ident;
|
|
||||||
|
|
||||||
pub struct Symbol(&'static str);
|
|
||||||
|
|
||||||
// The trait name that allow automatic struct/enum/type conversion
|
|
||||||
// to typescript
|
|
||||||
pub const TYPE_TRAIT: Symbol = Symbol("Type");
|
|
||||||
|
|
||||||
impl PartialEq<Symbol> for Ident {
|
|
||||||
fn eq(&self, word: &Symbol) -> bool {
|
|
||||||
self == word.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq<Symbol> for &Ident {
|
|
||||||
fn eq(&self, word: &Symbol) -> bool {
|
|
||||||
*self == word.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for Symbol {
|
|
||||||
type Target = &'static str;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use super::utils::has_derive_type;
|
|
||||||
use crate::typescript::parser::{parse_enum, parse_struct};
|
|
||||||
use std::error::Error;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use tracing::trace;
|
|
||||||
|
|
||||||
/// Represents all the valid typescript types found in a file.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct FileTypes {
|
|
||||||
/// Rust file when the type was found
|
|
||||||
pub file_path: PathBuf,
|
|
||||||
/// All the types found in the file
|
|
||||||
/// ready to be printed in the typescript file
|
|
||||||
pub types_as_string: String,
|
|
||||||
/// The types found in the file.
|
|
||||||
/// Used to check that the types are not duplicated across files.
|
|
||||||
pub types: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<(PathBuf, String)> for FileTypes {
|
|
||||||
type Error = Box<dyn Error>;
|
|
||||||
|
|
||||||
fn try_from((file_path, file_str): (PathBuf, String)) -> Result<Self, Self::Error> {
|
|
||||||
trace!("Parsing file: {:?}", &file_path);
|
|
||||||
let file = syn::parse_file(&file_str)?;
|
|
||||||
|
|
||||||
let mut types_as_string = String::new();
|
|
||||||
let mut types = Vec::new();
|
|
||||||
|
|
||||||
for item in file.items {
|
|
||||||
match item {
|
|
||||||
syn::Item::Struct(element) => {
|
|
||||||
if !has_derive_type(&element.attrs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
trace!("Found struct in file: {:?}", &file_path);
|
|
||||||
let (struct_name, typescript_definition) = parse_struct(&element);
|
|
||||||
types_as_string.push_str(&typescript_definition);
|
|
||||||
types.push(struct_name);
|
|
||||||
}
|
|
||||||
syn::Item::Enum(element) => {
|
|
||||||
if !has_derive_type(&element.attrs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
trace!("Found enum in file: {:?}", &file_path);
|
|
||||||
|
|
||||||
let (enum_name, typescript_definition) = parse_enum(&element);
|
|
||||||
types_as_string.push_str(&typescript_definition);
|
|
||||||
types.push(enum_name);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if types_as_string.is_empty() {
|
|
||||||
return Err("No types found in the file".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
file_path,
|
|
||||||
types_as_string,
|
|
||||||
types,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_creates_type_from_pathbuf_and_string() {
|
|
||||||
let file_path = PathBuf::from("src/types.rs");
|
|
||||||
let file_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct {
|
|
||||||
field1: String,
|
|
||||||
field2: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum {
|
|
||||||
Variant1,
|
|
||||||
Variant2,
|
|
||||||
}
|
|
||||||
"#
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let ttype = FileTypes::try_from((file_path.clone(), file_str)).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(ttype.file_path, file_path);
|
|
||||||
assert_eq!(
|
|
||||||
ttype.types_as_string,
|
|
||||||
"export interface MyStruct {\n field1: string;\n field2: number;\n}\nexport type MyEnum = \"Variant1\" | \"Variant2\";\n"
|
|
||||||
);
|
|
||||||
assert_eq!(ttype.types, vec!["MyStruct", "MyEnum"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
mod file_types;
|
|
||||||
pub mod parser;
|
|
||||||
|
|
||||||
mod types_jar;
|
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
pub use file_types::*;
|
|
||||||
pub use types_jar::*;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mod parse_enum;
|
|
||||||
mod parse_struct;
|
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
pub use parse_enum::*;
|
|
||||||
pub use parse_struct::*;
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
use crate::typescript::parser::utils::{
|
|
||||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
|
||||||
rust_to_typescript_type, should_skip_element,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Parse a rust enum and returns a tuple of the enum name and the
|
|
||||||
/// enum compiled to a typescript type
|
|
||||||
pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) {
|
|
||||||
let enum_name = element.ident.to_string();
|
|
||||||
|
|
||||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
|
||||||
let mut enum_variants: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
|
||||||
|
|
||||||
for variant in &element.variants {
|
|
||||||
if should_skip_element(&variant.attrs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parsed_variant = rename_option.transform(variant.ident.to_string());
|
|
||||||
|
|
||||||
match &variant.fields {
|
|
||||||
syn::Fields::Named(field) => {
|
|
||||||
parsed_variant = format!("{{\"{parsed_variant}\": {{ ");
|
|
||||||
let mut variant_fields: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for field in &field.named {
|
|
||||||
if should_skip_element(&field.attrs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let field_name = rename_option.transform(get_field_name(field));
|
|
||||||
|
|
||||||
let field_type = rust_to_typescript_type(&field.ty);
|
|
||||||
variant_fields.push(format!("{field_name}: {field_type}"));
|
|
||||||
}
|
|
||||||
enum_variants.push(format!(
|
|
||||||
"{parsed_variant}{} }}}}",
|
|
||||||
variant_fields.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
syn::Fields::Unnamed(field) => {
|
|
||||||
let mut variant_fields: Vec<String> = Vec::new();
|
|
||||||
for field in &field.unnamed {
|
|
||||||
let field_type = rust_to_typescript_type(&field.ty);
|
|
||||||
variant_fields.push(field_type);
|
|
||||||
}
|
|
||||||
enum_variants.push(format!(
|
|
||||||
"{{\"{parsed_variant}\": [{}]}}",
|
|
||||||
variant_fields.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
syn::Fields::Unit => {
|
|
||||||
enum_variants.push(format!("\"{parsed_variant}\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let enum_type = format!(
|
|
||||||
"export type {enum_name}{generics} = {};\n",
|
|
||||||
enum_variants.join(" | ")
|
|
||||||
);
|
|
||||||
(enum_name, enum_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parse_a_simple_enum() {
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum {
|
|
||||||
Variant1,
|
|
||||||
Variant2,
|
|
||||||
Variant3
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(enum_name, "MyEnum");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = \"Variant1\" | \"Variant2\" | \"Variant3\";\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_apply_rename_all_modifier() {
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
enum MyEnum {
|
|
||||||
Id,
|
|
||||||
Name,
|
|
||||||
UserAge
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(enum_name, "MyEnum");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_skips_a_variant() {
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
enum MyEnum {
|
|
||||||
Id,
|
|
||||||
Name,
|
|
||||||
UserAge,
|
|
||||||
#[serde(skip)]
|
|
||||||
SkipMe
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parse_struct_variant() {
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum {
|
|
||||||
Id,
|
|
||||||
User {
|
|
||||||
name: String,
|
|
||||||
age: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = \"Id\" | {\"User\": { name: string, age: number }};\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum {
|
|
||||||
Id,
|
|
||||||
User {
|
|
||||||
name: String,
|
|
||||||
#[serde(skip)]
|
|
||||||
age: u32,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = \"Id\" | {\"User\": { name: string }};\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum {
|
|
||||||
Request {
|
|
||||||
body: Bytes
|
|
||||||
},
|
|
||||||
Response{
|
|
||||||
payload: Bytes
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = {\"Request\": { body: Bytes }} | {\"Response\": { payload: Bytes }};\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parse_the_generic_type() {
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum<T> {
|
|
||||||
Id,
|
|
||||||
User {
|
|
||||||
name: T,
|
|
||||||
age: u32
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(enum_name, "MyEnum");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum<T> = \"Id\" | {\"User\": { name: T, age: number }};\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parse_the_tuple_variant() {
|
|
||||||
let enum_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnum {
|
|
||||||
Id,
|
|
||||||
User(String, u32),
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(enum_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export type MyEnum = \"Id\" | {\"User\": [string, number]};\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
use crate::typescript::parser::utils::{
|
|
||||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
|
||||||
rust_to_typescript_type,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::utils::should_skip_element;
|
|
||||||
|
|
||||||
/// Parse a rust struct and returns a tuple of the struct name and the
|
|
||||||
/// struct compiled to a typescript interface
|
|
||||||
pub fn parse_struct(element: &syn::ItemStruct) -> (String, String) {
|
|
||||||
let struct_name = element.ident.to_string();
|
|
||||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
|
||||||
|
|
||||||
let mut fields_as_string = format!("export interface {struct_name}{generics} {{\n");
|
|
||||||
|
|
||||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
|
||||||
|
|
||||||
for field in &element.fields {
|
|
||||||
if should_skip_element(&field.attrs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let field_name = get_field_name(field);
|
|
||||||
|
|
||||||
let field_type = rust_to_typescript_type(&field.ty);
|
|
||||||
|
|
||||||
let field_name = rename_option.transform(field_name);
|
|
||||||
|
|
||||||
fields_as_string.push_str(&format!(" {field_name}: {field_type};\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fields_as_string.push_str("}\n");
|
|
||||||
|
|
||||||
(struct_name, fields_as_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parses_struct() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct<'a, T, R>{
|
|
||||||
field_1: &str,
|
|
||||||
field2: i32,
|
|
||||||
field3: Option<String>,
|
|
||||||
field4: Vec<i32>,
|
|
||||||
record: HashMap<&'a str, i32>,
|
|
||||||
user: User,
|
|
||||||
generic: T,
|
|
||||||
generic2: HashMap<&mut str, R>,
|
|
||||||
btree: BTreeMap<String, i32>,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
|
|
||||||
assert_eq!(struct_name, "MyStruct");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct<T, R> {\n field_1: string;\n field2: number;\n field3: string | null;\n field4: number[];\n record: Record<string, number>;\n user: User;\n generic: T;\n generic2: Record<string, R>;\n btree: Record<string, number>;\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parse_tuple_fields() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct {
|
|
||||||
tuple: (i32, i32, String, User),
|
|
||||||
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct {\n tuple: [number, number, string, User];\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_parses_struct_with_no_generics() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct {
|
|
||||||
field_1: &str,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
|
|
||||||
assert_eq!(struct_name, "MyStruct");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct {\n field_1: string;\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_turn_the_keys_camel_case() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct MyStruct {
|
|
||||||
field_one: &str,
|
|
||||||
field_two: i32,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
|
|
||||||
assert_eq!(struct_name, "MyStruct");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct {\n fieldOne: string;\n fieldTwo: number;\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_turn_the_keys_pascal_case() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
#[serde(rename_all = "PascalCase")]
|
|
||||||
struct MyStruct {
|
|
||||||
field_one: &str,
|
|
||||||
field_two: i32,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
|
|
||||||
assert_eq!(struct_name, "MyStruct");
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct {\n FieldOne: string;\n FieldTwo: number;\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_retrieve_the_serde_rename_option() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
#[serde(rename_all = "PascalCase")]
|
|
||||||
struct MyStruct {
|
|
||||||
field_one: &str,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let rename_option: RenameSerdeOptions =
|
|
||||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
|
||||||
|
|
||||||
assert_eq!(rename_option, RenameSerdeOptions::PascalCase);
|
|
||||||
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct MyStruct {
|
|
||||||
field_one: &str,
|
|
||||||
}"#;
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let rename_option: RenameSerdeOptions =
|
|
||||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
|
||||||
assert_eq!(rename_option, RenameSerdeOptions::CamelCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_identifies_skip_fields() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct {
|
|
||||||
#[serde(skip)]
|
|
||||||
field_one: &str,
|
|
||||||
field_two: i32,
|
|
||||||
#[serde(skip_serializing)]
|
|
||||||
field_three: i32,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
|
|
||||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct {\n field_two: number;\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_override_a_field_name() {
|
|
||||||
let struct_str = r#"
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct {
|
|
||||||
#[serde(rename = "field_one")]
|
|
||||||
field_two: i32,
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
|
||||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
|
||||||
assert_eq!(
|
|
||||||
typescript_definition,
|
|
||||||
"export interface MyStruct {\n field_one: number;\n}\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
use convert_case::{Case, Casing};
|
|
||||||
use std::str::FromStr;
|
|
||||||
use syn::punctuated::Punctuated;
|
|
||||||
use syn::token::Comma;
|
|
||||||
use syn::{GenericArgument, GenericParam, PathArguments};
|
|
||||||
|
|
||||||
fn type_to_typescript(type_name: &str) -> &str {
|
|
||||||
match type_name {
|
|
||||||
"i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
|
|
||||||
| "isize" | "usize" => "number",
|
|
||||||
"str" | "String" | "char" => "string",
|
|
||||||
"bool" => "boolean",
|
|
||||||
_ => type_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_field_name(field: &syn::Field) -> String {
|
|
||||||
let field_name: String = parse_serde_attribute(&field.attrs, "rename");
|
|
||||||
|
|
||||||
if !field_name.is_empty() {
|
|
||||||
return field_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(field) = field.ident.as_ref() {
|
|
||||||
field.to_string()
|
|
||||||
} else {
|
|
||||||
String::from("unknown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the struct generics and return them collected into a "<...>" string.
|
|
||||||
/// If no generics are present, return an empty string.
|
|
||||||
pub fn parse_generics_to_typescript_string(generics: Punctuated<GenericParam, Comma>) -> String {
|
|
||||||
let generics = generics
|
|
||||||
.iter()
|
|
||||||
.map(|param| {
|
|
||||||
if let syn::GenericParam::Type(type_param) = param {
|
|
||||||
type_param.ident.to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(|name| !name.is_empty())
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
if !generics.is_empty() {
|
|
||||||
return format!("<{}>", generics.join(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse any "assign" `#[serde(... = "...")]` attribute and return the value of the specified
|
|
||||||
/// attribute.
|
|
||||||
pub fn parse_serde_attribute<T>(attrs: &[syn::Attribute], attribute_name: &str) -> T
|
|
||||||
where
|
|
||||||
T: Default + FromStr,
|
|
||||||
{
|
|
||||||
for attr in attrs {
|
|
||||||
if attr.path().is_ident("serde") {
|
|
||||||
if let Ok(meta) = attr.parse_args::<syn::Expr>() {
|
|
||||||
match meta {
|
|
||||||
syn::Expr::Assign(assign) => {
|
|
||||||
if let syn::Expr::Path(path) = *assign.left {
|
|
||||||
if !path.path.is_ident(attribute_name) {
|
|
||||||
return T::default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let syn::Expr::Lit(syn::ExprLit {
|
|
||||||
lit: syn::Lit::Str(lit_str),
|
|
||||||
..
|
|
||||||
}) = *assign.right
|
|
||||||
{
|
|
||||||
return T::from_str(&lit_str.value()).unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return T::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
T::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the element should be skipped based on the presence of
|
|
||||||
/// `#[serde(skip)]` or `#[serde(skip_serializing)]` attributes.
|
|
||||||
pub fn should_skip_element(attrs: &[syn::Attribute]) -> bool {
|
|
||||||
for attr in attrs {
|
|
||||||
if attr.path().is_ident("serde") {
|
|
||||||
if let Ok(meta) = attr.parse_args::<syn::Ident>() {
|
|
||||||
if meta == "skip" || meta == "skip_serializing" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rust_to_typescript_type(ty: &syn::Type) -> String {
|
|
||||||
match ty {
|
|
||||||
syn::Type::Tuple(tuple) => {
|
|
||||||
let inner_types: Vec<String> =
|
|
||||||
tuple.elems.iter().map(rust_to_typescript_type).collect();
|
|
||||||
format!("[{}]", inner_types.join(", "))
|
|
||||||
}
|
|
||||||
syn::Type::Path(type_path) => {
|
|
||||||
if let Some(last_segment) = type_path.path.segments.last() {
|
|
||||||
let outer_type = last_segment.ident.to_string();
|
|
||||||
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
|
|
||||||
let inner_types: Vec<String> = args
|
|
||||||
.args
|
|
||||||
.iter()
|
|
||||||
.filter_map(|arg| {
|
|
||||||
if let GenericArgument::Type(inner_type) = arg {
|
|
||||||
match inner_type {
|
|
||||||
syn::Type::Path(inner_type_path) => {
|
|
||||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
|
||||||
}
|
|
||||||
syn::Type::Reference(reference) => {
|
|
||||||
if let syn::Type::Path(inner_type_path) = &*reference.elem {
|
|
||||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
|
||||||
} else {
|
|
||||||
Some("unknown".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Some("unknown".to_string()),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
match outer_type.as_str() {
|
|
||||||
"Option" => {
|
|
||||||
format!("{} | null", type_to_typescript(&inner_types[0]))
|
|
||||||
}
|
|
||||||
"Vec" => {
|
|
||||||
format!("{}[]", type_to_typescript(&inner_types[0]))
|
|
||||||
}
|
|
||||||
"HashMap" | "BTreeMap" => {
|
|
||||||
format!(
|
|
||||||
"Record<{}, {}>",
|
|
||||||
type_to_typescript(&inner_types[0]),
|
|
||||||
type_to_typescript(&inner_types[1])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => "unknown".to_string(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
type_to_typescript(&outer_type).to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"unknown".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syn::Type::Reference(reference) => {
|
|
||||||
// Ignore lifetimes and treat references as their base type
|
|
||||||
if let syn::Type::Path(type_path) = &*reference.elem {
|
|
||||||
if let Some(base_type) = type_path.path.segments.last() {
|
|
||||||
type_to_typescript(&base_type.ident.to_string()).to_string()
|
|
||||||
} else {
|
|
||||||
"unknown".to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"unknown".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => "unknown".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This enum matches serde's RenameRule enum
|
|
||||||
#[derive(Debug, Eq, PartialEq, Default)]
|
|
||||||
pub enum RenameSerdeOptions {
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
LowerCase,
|
|
||||||
UpperCase,
|
|
||||||
PascalCase,
|
|
||||||
CamelCase,
|
|
||||||
SnakeCase,
|
|
||||||
ScreamingSnakeCase,
|
|
||||||
KebabCase,
|
|
||||||
ScreamingKebabCase,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for RenameSerdeOptions {
|
|
||||||
type Err = ();
|
|
||||||
|
|
||||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
|
||||||
match input {
|
|
||||||
"lowercase" => Ok(Self::LowerCase),
|
|
||||||
"UPPERCASE" => Ok(Self::UpperCase),
|
|
||||||
"PascalCase" => Ok(Self::PascalCase),
|
|
||||||
"camelCase" => Ok(Self::CamelCase),
|
|
||||||
"snake_case" => Ok(Self::SnakeCase),
|
|
||||||
"SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase),
|
|
||||||
"kebab-case" => Ok(Self::KebabCase),
|
|
||||||
"SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase),
|
|
||||||
_ => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenameSerdeOptions {
|
|
||||||
pub fn transform(&self, input: String) -> String {
|
|
||||||
match self {
|
|
||||||
Self::LowerCase => input.to_lowercase(),
|
|
||||||
Self::UpperCase => input.to_uppercase(),
|
|
||||||
Self::CamelCase => input.to_case(Case::Camel),
|
|
||||||
Self::PascalCase => input.to_case(Case::Pascal),
|
|
||||||
_ => input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
use crate::symbols::TYPE_TRAIT;
|
|
||||||
use crate::typescript::FileTypes;
|
|
||||||
use glob::glob;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::env;
|
|
||||||
use std::fs::read_to_string;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tracing::{error, trace};
|
|
||||||
use tuono_internal::tuono_println;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct TypesJar {
|
|
||||||
types: Vec<FileTypes>,
|
|
||||||
should_generate_typescript_file: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TypesJar {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
types: Vec::new(),
|
|
||||||
should_generate_typescript_file: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TypesJar {
|
|
||||||
/// Removes all types from the jar that are
|
|
||||||
/// present in the provided `file_path`.
|
|
||||||
/// This function is triggered when a file is deleted
|
|
||||||
pub fn remove_file(&mut self, file_path: PathBuf) {
|
|
||||||
self.should_generate_typescript_file = true;
|
|
||||||
self.types.retain(|ttype| ttype.file_path != file_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn refresh_file(&mut self, path: PathBuf) {
|
|
||||||
if let Ok(file_str) = read_to_string(&path) {
|
|
||||||
if file_str.contains(*TYPE_TRAIT) {
|
|
||||||
if let Ok(ttype) = FileTypes::try_from((path.clone(), file_str)) {
|
|
||||||
if Some(&ttype) == self.types.iter().find(|t| t.file_path == path) {
|
|
||||||
// The new file exactly matches the old one
|
|
||||||
trace!("File already exists in jar: {:?}", path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trace!("Refreshing: {:?} type", ttype.types);
|
|
||||||
|
|
||||||
self.should_generate_typescript_file = true;
|
|
||||||
self.remove_file(path);
|
|
||||||
self.types.push(ttype);
|
|
||||||
} else {
|
|
||||||
error!("Failed to parse file: {:?}", path);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if the file exist. In case it is it means that the user
|
|
||||||
// removed the "Type" derived trait. Hence we have to remove it from
|
|
||||||
// the jar
|
|
||||||
self.remove_file(path.clone());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Failed to read file: {:?}", path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_duplicate_types(&self) -> HashMap<String, (PathBuf, PathBuf)> {
|
|
||||||
trace!("Checking for duplicated types");
|
|
||||||
let mut duplicates: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
|
|
||||||
let mut paths: Vec<&PathBuf> = Vec::new();
|
|
||||||
let mut types: Vec<&Vec<String>> = Vec::new();
|
|
||||||
|
|
||||||
for file_types in self.types.iter() {
|
|
||||||
paths.push(&file_types.file_path);
|
|
||||||
types.push(&file_types.types);
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in 0..types.len() {
|
|
||||||
for j in (i + 1)..types.len() {
|
|
||||||
let types_i = types[i];
|
|
||||||
let types_j = types[j];
|
|
||||||
|
|
||||||
for type_i in types_i {
|
|
||||||
if types_j.contains(type_i) {
|
|
||||||
duplicates.insert(type_i.clone(), (paths[j].clone(), paths[i].clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
duplicates
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_duplicated_types(&self) {
|
|
||||||
let duplicates = self.check_duplicate_types();
|
|
||||||
let base_path = env::current_dir().unwrap_or_default();
|
|
||||||
let base_path_str = base_path.to_string_lossy();
|
|
||||||
|
|
||||||
for (type_name, file_paths) in duplicates.iter() {
|
|
||||||
let first_file_path = file_paths
|
|
||||||
.0
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace(&base_path_str.to_string(), "");
|
|
||||||
let second_file_path = file_paths
|
|
||||||
.1
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace(&base_path_str.to_string(), "");
|
|
||||||
|
|
||||||
tuono_println!("Duplicate \"{}\" type found in files:\n", type_name);
|
|
||||||
tuono_println!("- {}", first_file_path);
|
|
||||||
tuono_println!("- {}\n", second_file_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate the string containing all the typescript types
|
|
||||||
/// found in the jar.
|
|
||||||
fn generate_typescript(&self) -> String {
|
|
||||||
self.log_duplicated_types();
|
|
||||||
let mut typescript = String::from("declare module \"tuono/types\" {\n");
|
|
||||||
for ttype in &self.types {
|
|
||||||
typescript.push_str(&format!(
|
|
||||||
"// START [{}]\n",
|
|
||||||
ttype.file_path.to_string_lossy()
|
|
||||||
));
|
|
||||||
typescript.push_str(&ttype.types_as_string);
|
|
||||||
typescript.push_str(&format!("// END [{}]\n", ttype.file_path.to_string_lossy()));
|
|
||||||
}
|
|
||||||
typescript.push_str("}\n");
|
|
||||||
typescript
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_typescript_file(&mut self, base_path: &Path) -> std::io::Result<()> {
|
|
||||||
if !self.should_generate_typescript_file {
|
|
||||||
trace!("No need to create typescript module file");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.should_generate_typescript_file = false;
|
|
||||||
trace!("Creating typescript module file");
|
|
||||||
let typescript = self.generate_typescript();
|
|
||||||
let typescript_file_path = base_path.join(".tuono").join("types.ts");
|
|
||||||
std::fs::write(typescript_file_path, typescript)?;
|
|
||||||
|
|
||||||
trace!("Typescript module file created");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&PathBuf> for TypesJar {
|
|
||||||
/// Fill the TypesJar with all the Rust files found within
|
|
||||||
/// the provided `base_path`.
|
|
||||||
fn from(base_path: &PathBuf) -> Self {
|
|
||||||
let mut jar = Self::new();
|
|
||||||
|
|
||||||
if let Some(path) = base_path.join("src/**/*.rs").to_str() {
|
|
||||||
if let Ok(files) = glob(path) {
|
|
||||||
files.for_each(|path| {
|
|
||||||
let file_path = path.unwrap_or_default();
|
|
||||||
if let Ok(file_str) = read_to_string(&file_path) {
|
|
||||||
if !file_str.contains(*TYPE_TRAIT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(ttype) = FileTypes::try_from((file_path.clone(), file_str)) {
|
|
||||||
jar.types.push(ttype);
|
|
||||||
} else {
|
|
||||||
error!("Failed to parse file: {:?}", file_path);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Failed to read file: {:?}", file_path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
error!("Failed to read glob pattern: {:?}", path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_finds_duplicate_types() {
|
|
||||||
let file_type1 = FileTypes {
|
|
||||||
file_path: PathBuf::from("src/types1.rs"),
|
|
||||||
types_as_string: String::from("type1"),
|
|
||||||
types: vec![
|
|
||||||
String::from("Type1"),
|
|
||||||
String::from("Type2"),
|
|
||||||
String::from("Type3"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let file_type2 = FileTypes {
|
|
||||||
file_path: PathBuf::from("src/types2.rs"),
|
|
||||||
types_as_string: String::from("type1"),
|
|
||||||
types: vec![
|
|
||||||
String::from("Type1"),
|
|
||||||
String::from("Type2"),
|
|
||||||
String::from("Type4"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_type3 = FileTypes {
|
|
||||||
file_path: PathBuf::from("src/types2.rs"),
|
|
||||||
types_as_string: String::from("type1"),
|
|
||||||
types: vec![String::from("Type3")],
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut jar = TypesJar::new();
|
|
||||||
jar.types.push(file_type1);
|
|
||||||
jar.types.push(file_type2);
|
|
||||||
jar.types.push(file_type3);
|
|
||||||
|
|
||||||
let result = jar.check_duplicate_types();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result
|
|
||||||
.keys()
|
|
||||||
.collect::<Vec<&String>>()
|
|
||||||
.contains(&&"Type1".to_string())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result
|
|
||||||
.keys()
|
|
||||||
.collect::<Vec<&String>>()
|
|
||||||
.contains(&&"Type2".to_string())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result
|
|
||||||
.keys()
|
|
||||||
.collect::<Vec<&String>>()
|
|
||||||
.contains(&&"Type3".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!result
|
|
||||||
.keys()
|
|
||||||
.collect::<Vec<&String>>()
|
|
||||||
.contains(&&"Type4".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_removes_types() {
|
|
||||||
let mut jar = TypesJar::new();
|
|
||||||
let file_path = PathBuf::from("src/types.rs");
|
|
||||||
let file_type = FileTypes {
|
|
||||||
file_path: file_path.clone(),
|
|
||||||
types_as_string: String::from("type1"),
|
|
||||||
types: vec![String::from("Type1")],
|
|
||||||
};
|
|
||||||
assert!(jar.should_generate_typescript_file);
|
|
||||||
//Force file generation to false
|
|
||||||
jar.should_generate_typescript_file = false;
|
|
||||||
|
|
||||||
jar.types.push(file_type);
|
|
||||||
jar.remove_file(file_path.clone());
|
|
||||||
|
|
||||||
assert!(jar.should_generate_typescript_file);
|
|
||||||
assert_eq!(jar.types.len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
use crate::symbols::TYPE_TRAIT;
|
|
||||||
use syn::{Attribute, Meta};
|
|
||||||
|
|
||||||
pub fn has_derive_type(attrs: &[Attribute]) -> bool {
|
|
||||||
for attr in attrs {
|
|
||||||
if let Meta::List(meta_list) = &attr.meta {
|
|
||||||
if meta_list.path.is_ident("derive") {
|
|
||||||
for nested_meta in meta_list
|
|
||||||
.parse_args_with(
|
|
||||||
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
|
|
||||||
)
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
if let Meta::Path(path) = nested_meta {
|
|
||||||
if path.is_ident(&TYPE_TRAIT) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use syn::{ItemEnum, ItemStruct, ItemType, parse_quote};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_checks_if_derive_type_is_present() {
|
|
||||||
let test_struct: ItemStruct = parse_quote! {
|
|
||||||
#[derive(Type)]
|
|
||||||
struct MyStruct;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(has_derive_type(&test_struct.attrs));
|
|
||||||
|
|
||||||
let test_type: ItemType = parse_quote! {
|
|
||||||
#[derive(Type)]
|
|
||||||
type MyType = i32;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(has_derive_type(&test_type.attrs));
|
|
||||||
|
|
||||||
let test_enum: ItemEnum = parse_quote! {
|
|
||||||
#[derive(Type)]
|
|
||||||
enum MyEnunType {
|
|
||||||
Variant1,
|
|
||||||
Variant2,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(has_derive_type(&test_enum.attrs));
|
|
||||||
|
|
||||||
let test_struct_without_type: ItemStruct = parse_quote! {
|
|
||||||
struct MyStruct;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(!has_derive_type(&test_struct_without_type.attrs));
|
|
||||||
|
|
||||||
let multi_derived_trait: ItemStruct = parse_quote! {
|
|
||||||
#[derive(Type, Serialize, Debug)]
|
|
||||||
struct MyStruct;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(has_derive_type(&multi_derived_trait.attrs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -107,41 +107,6 @@ fn it_successfully_create_multiple_api_for_the_same_file() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial]
|
|
||||||
fn it_successfully_import_mixed_case_routes() {
|
|
||||||
let temp_tuono_project = TempTuonoProject::new();
|
|
||||||
|
|
||||||
for method in ["get", "post", "put", "delete", "patch"] {
|
|
||||||
temp_tuono_project.add_file_with_content(
|
|
||||||
&format!("./src/routes/api/{method}_lower.rs"),
|
|
||||||
&format!(r"#[tuono_lib::api({method})]"),
|
|
||||||
);
|
|
||||||
temp_tuono_project.add_file_with_content(
|
|
||||||
&format!("./src/routes/api/{method}_upper.rs"),
|
|
||||||
&format!(r"#[tuono_lib::api({})]", method.to_uppercase()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut test_tuono_build = Command::cargo_bin("tuono").unwrap();
|
|
||||||
test_tuono_build
|
|
||||||
.arg("build")
|
|
||||||
.arg("--no-js-emit")
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
let temp_main_rs_path = temp_tuono_project.path().join(".tuono/main.rs");
|
|
||||||
|
|
||||||
let temp_main_rs_content =
|
|
||||||
fs::read_to_string(&temp_main_rs_path).expect("Failed to read '.tuono/main.rs' content.");
|
|
||||||
|
|
||||||
for method in ["get", "post", "put", "delete", "patch"] {
|
|
||||||
let expected = format!(r#"use tuono_lib::axum::routing::{method};"#);
|
|
||||||
let imports = temp_main_rs_content.match_indices(&expected);
|
|
||||||
assert_eq!(imports.count(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn it_successfully_create_catch_all_routes() {
|
fn it_successfully_create_catch_all_routes() {
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ impl GitHubServerMock {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path(format!(
|
.and(path(&format!(
|
||||||
"repos/tuono-labs/tuono/git/ref/tags/v{tuono_version}"
|
"repos/tuono-labs/tuono/git/ref/tags/v{}",
|
||||||
|
tuono_version
|
||||||
)))
|
)))
|
||||||
.respond_with(sha_response_template)
|
.respond_with(sha_response_template)
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
@@ -56,7 +57,7 @@ impl GitHubServerMock {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path(format!("repos/tuono-labs/tuono/git/trees/{sha}")))
|
.and(path(&format!("repos/tuono-labs/tuono/git/trees/{}", sha)))
|
||||||
.respond_with(tree_response_template)
|
.respond_with(tree_response_template)
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_internal"
|
name = "tuono_internal"
|
||||||
version = "0.19.7"
|
version = "0.19.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "Superfast React fullstack framework"
|
description = "Superfast React fullstack framework"
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ pub struct TempTuonoProject {
|
|||||||
temp_dir: TempDir,
|
temp_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TempTuonoProject {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TempTuonoProject {
|
impl TempTuonoProject {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let original_dir = env::current_dir().expect("Failed to read current_dir");
|
let original_dir = env::current_dir().expect("Failed to read current_dir");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib"
|
name = "tuono_lib"
|
||||||
version = "0.19.7"
|
version = "0.19.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "Superfast React fullstack framework"
|
description = "Superfast React fullstack framework"
|
||||||
@@ -17,7 +17,7 @@ include = [
|
|||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ssr_rs = "0.8.3"
|
ssr_rs = "0.8.2"
|
||||||
axum = {version = "0.8.1", features = ["json", "ws"]}
|
axum = {version = "0.8.1", features = ["json", "ws"]}
|
||||||
axum-extra = {version = "0.10.0", features = ["cookie"]}
|
axum-extra = {version = "0.10.0", features = ["cookie"]}
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
@@ -32,12 +32,12 @@ either = "1.13.0"
|
|||||||
tower-http = {version = "0.6.0", features = ["fs"]}
|
tower-http = {version = "0.6.0", features = ["fs"]}
|
||||||
colored = "3.0.0"
|
colored = "3.0.0"
|
||||||
|
|
||||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.7"}
|
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.2"}
|
||||||
tuono_internal = {path = "../tuono_internal", version = "0.19.7"}
|
tuono_internal = {path = "../tuono_internal", version = "0.19.2"}
|
||||||
# Match the same version used by axum
|
# Match the same version used by axum
|
||||||
tokio-tungstenite = "0.27.0"
|
tokio-tungstenite = "0.26.0"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
tungstenite = "0.27.0"
|
tungstenite = "0.26.0"
|
||||||
http = "1.1.0"
|
http = "1.1.0"
|
||||||
pin-project = "1.1.7"
|
pin-project = "1.1.7"
|
||||||
tower = "0.5.1"
|
tower = "0.5.1"
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ pub unsafe fn load_env_vars(mode: Mode) {
|
|||||||
Mode::Prod => "production",
|
Mode::Prod => "production",
|
||||||
};
|
};
|
||||||
|
|
||||||
env_files.push(format!(".env.{mode_name}"));
|
env_files.push(format!(".env.{}", mode_name));
|
||||||
env_files.push(String::from(".env.local"));
|
env_files.push(String::from(".env.local"));
|
||||||
env_files.push(format!(".env.{mode_name}.local"));
|
env_files.push(format!(".env.{}.local", mode_name));
|
||||||
|
|
||||||
let system_env_names: HashSet<String> = env::vars().map(|(k, _)| k).collect();
|
let system_env_names: HashSet<String> = env::vars().map(|(k, _)| k).collect();
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ mod response;
|
|||||||
mod server;
|
mod server;
|
||||||
mod services;
|
mod services;
|
||||||
mod ssr;
|
mod ssr;
|
||||||
mod vite_reverse_proxy;
|
mod vite;
|
||||||
mod vite_websocket_proxy;
|
|
||||||
|
|
||||||
pub use mode::Mode;
|
pub use mode::Mode;
|
||||||
pub use payload::Payload;
|
pub use payload::Payload;
|
||||||
pub use request::Request;
|
pub use request::Request;
|
||||||
pub use response::{Props, Response};
|
pub use response::{Props, Response};
|
||||||
pub use server::{Server, tuono_internal_init_v8_platform};
|
pub use server::{Server, tuono_internal_init_v8_platform};
|
||||||
pub use tuono_lib_macros::{Type, api, handler};
|
pub use tuono_lib_macros::{api, handler};
|
||||||
|
|
||||||
// Re-exports
|
// Re-exports
|
||||||
pub use axum;
|
pub use axum;
|
||||||
|
|||||||
+130
-478
@@ -1,6 +1,4 @@
|
|||||||
use once_cell::sync::Lazy;
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use regex::Regex;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -9,513 +7,167 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||||
|
|
||||||
fn has_dynamic_path(pathname: &str) -> bool {
|
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
static RE: Lazy<Regex> =
|
pub struct BundleInfo {
|
||||||
Lazy::new(|| Regex::new(r"\[(.*?)\]").expect("Invalid regex for dynamic path detection"));
|
/// TODO: Add also the import field and load the dynamic
|
||||||
RE.is_match(pathname)
|
/// values in the payload bundles.
|
||||||
|
pub file: String,
|
||||||
|
#[serde(default = "default_css_vector")]
|
||||||
|
pub css: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ViteManifest is the mapping between the vite output bundled files
|
fn default_css_vector() -> Vec<String> {
|
||||||
/// and the originals.
|
|
||||||
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
|
||||||
pub type ViteManifest = HashMap<String, BundleInfo>;
|
|
||||||
|
|
||||||
fn empty_vector() -> Vec<String> {
|
|
||||||
Vec::with_capacity(0)
|
Vec::with_capacity(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interface representing the bundle information
|
/// Manifest is the mapping between the vite output bundled files
|
||||||
/// as they are in the vite manifest.json.
|
/// and the originals.
|
||||||
///
|
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
||||||
/// Used for deserialization
|
pub static MANIFEST: OnceCell<HashMap<String, BundleInfo>> = OnceCell::new();
|
||||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BundleInfo {
|
|
||||||
pub file: String,
|
|
||||||
#[serde(default = "empty_vector")]
|
|
||||||
pub css: Vec<String>,
|
|
||||||
#[serde(default = "empty_vector")]
|
|
||||||
pub imports: Vec<String>,
|
|
||||||
// TODO: Add also dynamic imports
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
pub fn load_manifest() {
|
||||||
pub struct RouteBundle {
|
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH)).unwrap();
|
||||||
pub css_files: Vec<String>,
|
|
||||||
pub js_files: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Manifest {
|
|
||||||
/// The mapping between the route and the bundle
|
|
||||||
bundles: HashMap<String, RouteBundle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clean_route_path(path: String) -> String {
|
|
||||||
let path = path
|
|
||||||
.replace("../src/routes", "")
|
|
||||||
.replace(".tsx", "")
|
|
||||||
.replace(".mdx", "")
|
|
||||||
.replace(".md", "")
|
|
||||||
.replace(".jsx", "");
|
|
||||||
|
|
||||||
if path == "/index" {
|
|
||||||
return "/".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
path.replace("/index", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ViteManifest> for Manifest {
|
|
||||||
fn from(manifest: ViteManifest) -> Self {
|
|
||||||
let mut bundles = HashMap::new();
|
|
||||||
let client_main = manifest
|
|
||||||
.get("client-main.tsx")
|
|
||||||
// client-main.tsx is the entry point and always exists
|
|
||||||
.expect("client-main.tsx not found in the manifest")
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
for (key, bundle) in &manifest {
|
|
||||||
if key.contains("__layout") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if key == "client-main.tsx" {
|
|
||||||
bundles.insert(
|
|
||||||
"client-main".to_string(),
|
|
||||||
RouteBundle {
|
|
||||||
css_files: bundle.css.clone(),
|
|
||||||
js_files: vec![bundle.file.clone()],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let route = clean_route_path(key.clone());
|
|
||||||
|
|
||||||
// Skip components/utils files
|
|
||||||
if !route.starts_with("/") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let css_files = [bundle.css.clone(), client_main.css.clone()].concat();
|
|
||||||
let js_files = vec![bundle.file.clone(), client_main.file.clone()];
|
|
||||||
|
|
||||||
let mut route_bundle = RouteBundle {
|
|
||||||
css_files,
|
|
||||||
js_files,
|
|
||||||
};
|
|
||||||
|
|
||||||
// the imports bundle always contains at least the client-main
|
|
||||||
if bundle.imports.len() > 1 {
|
|
||||||
for import in &bundle.imports {
|
|
||||||
if import == "client-main.tsx" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(import_bundle) = manifest.get(import) {
|
|
||||||
route_bundle.js_files.push(import_bundle.file.clone());
|
|
||||||
route_bundle.css_files.extend(import_bundle.css.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bundles.insert(route, route_bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add __layout imports
|
|
||||||
for (key, layout_bundle) in &manifest {
|
|
||||||
let route = clean_route_path(key.clone());
|
|
||||||
if route.contains("__layout") {
|
|
||||||
let path_included_in_layout = route.replace("__layout", "");
|
|
||||||
|
|
||||||
let mut layout_css_files: Vec<String> = Vec::new();
|
|
||||||
let mut layout_js_files: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for import in &layout_bundle.imports {
|
|
||||||
if import == "client-main.tsx" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(import_bundle) = manifest.get(import) {
|
|
||||||
layout_js_files.push(import_bundle.file.clone());
|
|
||||||
layout_css_files.extend(import_bundle.css.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (key, route_bundles) in &mut bundles {
|
|
||||||
if key.starts_with(path_included_in_layout.as_str()) {
|
|
||||||
route_bundles.js_files.push(layout_bundle.file.clone());
|
|
||||||
route_bundles.css_files.extend(layout_bundle.css.clone());
|
|
||||||
route_bundles.js_files.extend(layout_js_files.clone());
|
|
||||||
route_bundles.css_files.extend(layout_css_files.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Manifest { bundles }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Manifest {
|
|
||||||
/// This method adds the route specific bundles to the server
|
|
||||||
/// side rendered HTML.
|
|
||||||
///
|
|
||||||
/// The same matching algorithm is implemented on the client side in
|
|
||||||
/// this file (packages/tuono/src/router/components/Matches.ts).
|
|
||||||
///
|
|
||||||
/// Optimizations should occour on both.
|
|
||||||
pub fn get_bundle_from_pathname(&self, pathname: &str) -> RouteBundle {
|
|
||||||
// Exact match
|
|
||||||
if let Some(bundle) = self.bundles.get(pathname) {
|
|
||||||
return bundle.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let dynamic_routes = self
|
|
||||||
.bundles
|
|
||||||
.keys()
|
|
||||||
.filter(|path| has_dynamic_path(path))
|
|
||||||
.collect::<Vec<&String>>();
|
|
||||||
|
|
||||||
if !dynamic_routes.is_empty() {
|
|
||||||
let path_segments = pathname
|
|
||||||
.split('/')
|
|
||||||
.filter(|path| !path.is_empty())
|
|
||||||
.collect::<Vec<&str>>();
|
|
||||||
|
|
||||||
'_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() {
|
|
||||||
let dyn_route_segments = dyn_route
|
|
||||||
.split('/')
|
|
||||||
.filter(|path| !path.is_empty())
|
|
||||||
.collect::<Vec<&str>>();
|
|
||||||
|
|
||||||
let mut route_segments_collector: Vec<&str> = Vec::new();
|
|
||||||
|
|
||||||
for i in 0..dyn_route_segments.len() {
|
|
||||||
// Catch all dynamic route
|
|
||||||
if dyn_route_segments[i].starts_with("[...") {
|
|
||||||
route_segments_collector.push(dyn_route_segments[i]);
|
|
||||||
|
|
||||||
let manifest_key = route_segments_collector.join("/");
|
|
||||||
|
|
||||||
let route_data = self.bundles.get(&format!("/{manifest_key}"));
|
|
||||||
|
|
||||||
if let Some(data) = route_data {
|
|
||||||
return data.clone();
|
|
||||||
}
|
|
||||||
break '_dynamic_routes_loop;
|
|
||||||
}
|
|
||||||
if path_segments.len() == i {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if dyn_route_segments[i] == path_segments[i]
|
|
||||||
|| has_dynamic_path(dyn_route_segments[i])
|
|
||||||
{
|
|
||||||
route_segments_collector.push(dyn_route_segments[i])
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if route_segments_collector.len() == path_segments.len() {
|
|
||||||
let manifest_key = route_segments_collector.join("/");
|
|
||||||
|
|
||||||
let route_data = self.bundles.get(&format!("/{manifest_key}"));
|
|
||||||
if let Some(data) = route_data {
|
|
||||||
return data.clone();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No dynamic routes, return the client main bundle
|
|
||||||
if let Some(bundle) = self.bundles.get("client-main") {
|
|
||||||
return bundle.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should never happen because client-main always exists
|
|
||||||
RouteBundle::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static MANIFEST: OnceCell<Manifest> = OnceCell::new();
|
|
||||||
|
|
||||||
/// Load the vite manifest from the file system and set it in the MANIFEST
|
|
||||||
/// static variable.
|
|
||||||
pub fn load_manifest() -> std::io::Result<()> {
|
|
||||||
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH))?;
|
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
let manifest: ViteManifest = serde_json::from_reader(reader)?;
|
let manifest = serde_json::from_reader(reader).unwrap();
|
||||||
MANIFEST
|
let _ = MANIFEST.set(remap_manifest_keys(manifest));
|
||||||
.set(Manifest::from(manifest))
|
}
|
||||||
.map_err(|_| std::io::Error::other("Failed to set the manifest"))?;
|
|
||||||
Ok(())
|
fn remap_manifest_keys(manifest: HashMap<String, BundleInfo>) -> HashMap<String, BundleInfo> {
|
||||||
|
let mut new_hashmap = HashMap::new();
|
||||||
|
|
||||||
|
manifest.keys().for_each(|key| {
|
||||||
|
let new_key = key
|
||||||
|
.replace("../src/routes", "")
|
||||||
|
.replace(".tsx", "")
|
||||||
|
.replace(".jsx", "")
|
||||||
|
.replace("index", "");
|
||||||
|
|
||||||
|
new_hashmap.insert(new_key, manifest.get(key).unwrap().clone());
|
||||||
|
});
|
||||||
|
|
||||||
|
new_hashmap
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
// This manifest is an example of a complex vite manifest.json
|
|
||||||
// It includes dynamic routes, static routes, catch all routes, nested
|
|
||||||
// __layout and shared components.
|
|
||||||
const MANIFEST_EXAMPLE: &str = r#"{
|
|
||||||
"../src/routes/about.tsx": {
|
|
||||||
"file": "assets/about-C3UqHfGb.js",
|
|
||||||
"name": "about",
|
|
||||||
"src": "../src/routes/about.tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx",
|
|
||||||
"_FileWithCssOnly.js"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/about-DUhMJ_Ze.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"_FileWithCssOnly.js": {
|
|
||||||
"file": "assets/FileWithCssOnly.js",
|
|
||||||
"name": "FileWithCssOnly",
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/FileWithCssOnly.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/catch_all/[...slug].tsx": {
|
|
||||||
"file": "assets/_...slug_-CpJyPnPj.js",
|
|
||||||
"name": "_...slug_",
|
|
||||||
"src": "../src/routes/catch_all/[...slug].tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/_..-CipbPoTl.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/index.tsx": {
|
|
||||||
"file": "assets/index-B3tnHOzi.js",
|
|
||||||
"name": "index",
|
|
||||||
"src": "../src/routes/index.tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/index-CynfArjF.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/pokemons/[pokemon]/[type].tsx": {
|
|
||||||
"file": "assets/_type_-B-sJOcVJ.js",
|
|
||||||
"name": "_type_",
|
|
||||||
"src": "../src/routes/pokemons/[pokemon]/[type].tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx",
|
|
||||||
"_PokemonView-jNGFFO0j.js"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/_type_-B8vgxybx.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/pokemons/[pokemon]/index.tsx": {
|
|
||||||
"file": "assets/index-ByRBj7WK.js",
|
|
||||||
"name": "index",
|
|
||||||
"src": "../src/routes/pokemons/[pokemon]/index.tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx",
|
|
||||||
"_PokemonView-jNGFFO0j.js"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/index-CM86zKWq.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/pokemons/__layout.tsx": {
|
|
||||||
"file": "assets/__layout-2v3JiSeL.js",
|
|
||||||
"name": "__layout",
|
|
||||||
"src": "../src/routes/pokemons/__layout.tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/__layout-CXGGqNw5.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"_PokemonView-BcJZaQaO.css": {
|
|
||||||
"file": "assets/PokemonView-BcJZaQaO.css",
|
|
||||||
"src": "_PokemonView-BcJZaQaO.css"
|
|
||||||
},
|
|
||||||
"_PokemonView-jNGFFO0j.js": {
|
|
||||||
"file": "assets/PokemonView-jNGFFO0j.js",
|
|
||||||
"name": "PokemonView",
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/PokemonView-BcJZaQaO.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"client-main.tsx": {
|
|
||||||
"file": "assets/client-main-DOdr9gvl.js",
|
|
||||||
"name": "client-main",
|
|
||||||
"src": "client-main.tsx",
|
|
||||||
"isEntry": true,
|
|
||||||
"dynamicImports": [
|
|
||||||
"../src/routes/pokemons/__layout.tsx",
|
|
||||||
"../src/routes/about.tsx",
|
|
||||||
"../src/routes/index.tsx",
|
|
||||||
"../src/routes/catch_all/[...slug].tsx",
|
|
||||||
"../src/routes/pokemons/[pokemon]/[type].tsx",
|
|
||||||
"../src/routes/pokemons/[pokemon]/index.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/client-main-BS7N-NIa.css"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_correctly_cleans_the_route_path() {
|
|
||||||
let cleaned_path = clean_route_path("../src/routes/index.tsx".to_string());
|
|
||||||
assert_eq!(cleaned_path, "/");
|
|
||||||
|
|
||||||
let cleaned_path =
|
|
||||||
clean_route_path("../src/routes/pokemons/[pokemon]/index.tsx".to_string());
|
|
||||||
assert_eq!(cleaned_path, "/pokemons/[pokemon]");
|
|
||||||
|
|
||||||
let cleaned_path = clean_route_path("../src/routes/pokemons/__layout.tsx".to_string());
|
|
||||||
assert_eq!(cleaned_path, "/pokemons/__layout");
|
|
||||||
|
|
||||||
let cleaned_path =
|
|
||||||
clean_route_path("../src/routes/pokemons/[pokemon]/[type].mdx".to_string());
|
|
||||||
assert_eq!(cleaned_path, "/pokemons/[pokemon]/[type]");
|
|
||||||
|
|
||||||
let cleaned_path = clean_route_path("../src/routes/about.md".to_string());
|
|
||||||
assert_eq!(cleaned_path, "/about");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn correctly_parse_the_manifest_json() {
|
fn correctly_parse_the_manifest_json() {
|
||||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
let manifest_example = r#"{
|
||||||
|
"../src/routes/index.tsx": {
|
||||||
|
"file": "assets/index.js",
|
||||||
|
"name": "index",
|
||||||
|
"src": "../src/routes/index.tsx",
|
||||||
|
"isDynamicEntry": true,
|
||||||
|
"imports": [
|
||||||
|
"client-main.tsx"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"assets/index.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"meta-tags-lib": {
|
||||||
|
"file": "assets/meta-lib.js",
|
||||||
|
"name": "meta-tags-lib",
|
||||||
|
"imports": [
|
||||||
|
"client-main.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"client-main.tsx": {
|
||||||
|
"file": "assets/client-main.js",
|
||||||
|
"name": "client-main",
|
||||||
|
"src": "client-main.tsx",
|
||||||
|
"isEntry": true,
|
||||||
|
"dynamicImports": [
|
||||||
|
"../src/routes/index.tsx",
|
||||||
|
"../src/routes/pokemons/[pokemon].tsx"
|
||||||
|
],
|
||||||
|
"css": [
|
||||||
|
"assets/client-main.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
let manifest = Manifest::from(parsed_manifest);
|
let parsed_manifest =
|
||||||
assert_eq!(manifest.bundles.len(), 6);
|
serde_json::from_str::<HashMap<String, BundleInfo>>(manifest_example).unwrap();
|
||||||
let index_route = manifest.get_bundle_from_pathname("/");
|
|
||||||
|
|
||||||
assert_eq!(
|
let mut result = HashMap::new();
|
||||||
index_route.css_files,
|
result.insert(
|
||||||
vec![
|
"../src/routes/index.tsx".to_string(),
|
||||||
"assets/index-CynfArjF.css",
|
BundleInfo {
|
||||||
"assets/client-main-BS7N-NIa.css"
|
file: "assets/index.js".to_string(),
|
||||||
]
|
css: vec!["assets/index.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
result.insert(
|
||||||
|
"client-main.tsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/client-main.js".to_string(),
|
||||||
|
css: vec!["assets/client-main.css".to_string()],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
result.insert(
|
||||||
index_route.js_files,
|
"meta-tags-lib".to_string(),
|
||||||
vec!["assets/index-B3tnHOzi.js", "assets/client-main-DOdr9gvl.js"]
|
BundleInfo {
|
||||||
|
file: "assets/meta-lib.js".to_string(),
|
||||||
|
css: Vec::new(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert_eq!(parsed_manifest, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_load_the_correct_single_dyn_path() {
|
fn should_correctly_remap_the_manifest() {
|
||||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
||||||
|
parsed_manifest.insert(
|
||||||
let manifest = Manifest::from(parsed_manifest);
|
"../src/routes/index.tsx".to_string(),
|
||||||
let nested_route = manifest.get_bundle_from_pathname("/pokemons/ditto");
|
BundleInfo {
|
||||||
|
file: "assets/index.js".to_string(),
|
||||||
assert_eq!(
|
css: vec!["assets/index.css".to_string()],
|
||||||
nested_route.css_files,
|
},
|
||||||
vec![
|
|
||||||
"assets/index-CM86zKWq.css",
|
|
||||||
"assets/client-main-BS7N-NIa.css",
|
|
||||||
"assets/PokemonView-BcJZaQaO.css",
|
|
||||||
"assets/__layout-CXGGqNw5.css"
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
parsed_manifest.insert(
|
||||||
nested_route.js_files,
|
"../src/routes/about.jsx".to_string(),
|
||||||
vec![
|
BundleInfo {
|
||||||
"assets/index-ByRBj7WK.js",
|
file: "assets/about.js".to_string(),
|
||||||
"assets/client-main-DOdr9gvl.js",
|
css: vec!["assets/about.css".to_string()],
|
||||||
"assets/PokemonView-jNGFFO0j.js",
|
},
|
||||||
"assets/__layout-2v3JiSeL.js"
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
parsed_manifest.insert(
|
||||||
|
"../src/routes/posts/[post].tsx".to_string(),
|
||||||
#[test]
|
BundleInfo {
|
||||||
fn should_load_the_correct_nested_dyn_path_bundles() {
|
file: "assets/posts/[post].js".to_string(),
|
||||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
css: vec!["assets/posts/[post].css".to_string()],
|
||||||
|
},
|
||||||
let manifest = Manifest::from(parsed_manifest);
|
);
|
||||||
let route = manifest.get_bundle_from_pathname("/pokemons/charizard/fire");
|
parsed_manifest.insert(
|
||||||
|
"client-main.tsx".to_string(),
|
||||||
assert_eq!(
|
BundleInfo {
|
||||||
route.css_files,
|
file: "assets/main.js".to_string(),
|
||||||
vec![
|
css: vec!["assets/main.css".to_string()],
|
||||||
"assets/_type_-B8vgxybx.css",
|
},
|
||||||
"assets/client-main-BS7N-NIa.css",
|
|
||||||
"assets/PokemonView-BcJZaQaO.css",
|
|
||||||
"assets/__layout-CXGGqNw5.css"
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
let remapped = remap_manifest_keys(parsed_manifest);
|
||||||
route.js_files,
|
|
||||||
vec![
|
|
||||||
"assets/_type_-B-sJOcVJ.js",
|
|
||||||
"assets/client-main-DOdr9gvl.js",
|
|
||||||
"assets/PokemonView-jNGFFO0j.js",
|
|
||||||
"assets/__layout-2v3JiSeL.js"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn should_load_the_correct_catch_all_bundles() {
|
|
||||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
|
||||||
|
|
||||||
let manifest = Manifest::from(parsed_manifest);
|
|
||||||
let route = manifest.get_bundle_from_pathname("/catch_all/some/random/path");
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
route.css_files,
|
remapped.get("/").unwrap().file,
|
||||||
vec!["assets/_..-CipbPoTl.css", "assets/client-main-BS7N-NIa.css"]
|
"assets/index.js".to_string()
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
route.js_files,
|
|
||||||
vec![
|
|
||||||
"assets/_...slug_-CpJyPnPj.js",
|
|
||||||
"assets/client-main-DOdr9gvl.js"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn should_load_the_defined_path_bundles() {
|
|
||||||
let parsed_manifest = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE).unwrap();
|
|
||||||
|
|
||||||
let manifest = Manifest::from(parsed_manifest);
|
|
||||||
let route = manifest.get_bundle_from_pathname("/about");
|
|
||||||
assert_eq!(
|
|
||||||
route.css_files,
|
|
||||||
vec![
|
|
||||||
"assets/about-DUhMJ_Ze.css",
|
|
||||||
"assets/client-main-BS7N-NIa.css",
|
|
||||||
"assets/FileWithCssOnly.css"
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
route.js_files,
|
remapped.get("/about").unwrap().file,
|
||||||
vec![
|
"assets/about.js".to_string()
|
||||||
"assets/about-C3UqHfGb.js",
|
);
|
||||||
"assets/client-main-DOdr9gvl.js",
|
assert_eq!(
|
||||||
"assets/FileWithCssOnly.js"
|
remapped.get("/posts/[post]").unwrap().file,
|
||||||
]
|
"assets/posts/[post].js".to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remapped.get("client-main").unwrap().file,
|
||||||
|
"assets/main.js".to_string()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+252
-67
@@ -2,11 +2,17 @@ use crate::config::GLOBAL_CONFIG;
|
|||||||
use crate::manifest::MANIFEST;
|
use crate::manifest::MANIFEST;
|
||||||
use crate::mode::{GLOBAL_MODE, Mode};
|
use crate::mode::{GLOBAL_MODE, Mode};
|
||||||
use erased_serde::Serialize;
|
use erased_serde::Serialize;
|
||||||
|
use regex::Regex;
|
||||||
use serde::Serialize as SerdeSerialize;
|
use serde::Serialize as SerdeSerialize;
|
||||||
use tuono_internal::config::ServerConfig;
|
use tuono_internal::config::ServerConfig;
|
||||||
|
|
||||||
use crate::request::{Location, Request};
|
use crate::request::{Location, Request};
|
||||||
|
|
||||||
|
fn has_dynamic_path(route: &str) -> bool {
|
||||||
|
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||||
|
regex.is_match(route)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(SerdeSerialize)]
|
#[derive(SerdeSerialize)]
|
||||||
/// This is the payload sent to the client for hydration
|
/// This is the payload sent to the client for hydration
|
||||||
pub struct Payload<'a> {
|
pub struct Payload<'a> {
|
||||||
@@ -14,9 +20,9 @@ pub struct Payload<'a> {
|
|||||||
data: &'a dyn Serialize,
|
data: &'a dyn Serialize,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
#[serde(rename(serialize = "jsBundles"))]
|
#[serde(rename(serialize = "jsBundles"))]
|
||||||
js_bundles: Option<Vec<String>>,
|
js_bundles: Option<Vec<&'a String>>,
|
||||||
#[serde(rename(serialize = "cssBundles"))]
|
#[serde(rename(serialize = "cssBundles"))]
|
||||||
css_bundles: Option<Vec<String>>,
|
css_bundles: Option<Vec<&'a String>>,
|
||||||
#[serde(rename(serialize = "devServerConfig"))]
|
#[serde(rename(serialize = "devServerConfig"))]
|
||||||
dev_server_config: Option<&'a ServerConfig>,
|
dev_server_config: Option<&'a ServerConfig>,
|
||||||
}
|
}
|
||||||
@@ -52,11 +58,102 @@ impl<'a> Payload<'a> {
|
|||||||
serde_json::to_string(&self)
|
serde_json::to_string(&self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This method adds the route specific bundles to the server
|
||||||
|
/// side rendered HTML.
|
||||||
|
///
|
||||||
|
/// The same matching algorithm is implemented on the client side in
|
||||||
|
/// this file (packages/tuono/src/router/components/Matches.ts).
|
||||||
|
///
|
||||||
|
/// Optimizations should occour on both.
|
||||||
fn add_bundle_sources(&mut self) {
|
fn add_bundle_sources(&mut self) {
|
||||||
let manifest = MANIFEST.get().expect("Manifest not loaded");
|
// Manifest should always be loaded. The load happen before starting
|
||||||
let bundles = manifest.get_bundle_from_pathname(self.location.pathname());
|
// the server.
|
||||||
self.js_bundles = Some(bundles.js_files);
|
let manifest = MANIFEST.get().expect("Failed to load manifest");
|
||||||
self.css_bundles = Some(bundles.css_files);
|
|
||||||
|
// The main bundle should always exist.
|
||||||
|
// The extension should be tsx even with JS only projects.
|
||||||
|
let main_bundle = manifest
|
||||||
|
.get("client-main")
|
||||||
|
.expect("Failed to get client-main bundle");
|
||||||
|
|
||||||
|
let mut js_bundles_sources = vec![&main_bundle.file];
|
||||||
|
let mut css_bundles_sources = main_bundle.css.iter().collect::<Vec<&String>>();
|
||||||
|
|
||||||
|
let pathname = &self.location.pathname();
|
||||||
|
|
||||||
|
let bundle_data = manifest.get(*pathname);
|
||||||
|
|
||||||
|
if let Some(data) = bundle_data {
|
||||||
|
js_bundles_sources.push(&data.file);
|
||||||
|
|
||||||
|
data.css
|
||||||
|
.iter()
|
||||||
|
.for_each(|source| css_bundles_sources.push(source))
|
||||||
|
} else {
|
||||||
|
let dynamic_routes = manifest
|
||||||
|
.keys()
|
||||||
|
.filter(|path| has_dynamic_path(path))
|
||||||
|
.collect::<Vec<&String>>();
|
||||||
|
|
||||||
|
if !dynamic_routes.is_empty() {
|
||||||
|
let path_segments = pathname
|
||||||
|
.split('/')
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
'_dynamic_routes_loop: for dyn_route in dynamic_routes.iter() {
|
||||||
|
let dyn_route_segments = dyn_route
|
||||||
|
.split('/')
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
let mut route_segments_collector: Vec<&str> = vec![];
|
||||||
|
|
||||||
|
for i in 0..dyn_route_segments.len() {
|
||||||
|
if dyn_route_segments[i].starts_with("[...") {
|
||||||
|
route_segments_collector.push(dyn_route_segments[i]);
|
||||||
|
|
||||||
|
let manifest_key = route_segments_collector.join("/");
|
||||||
|
|
||||||
|
let route_data = manifest.get(&format!("/{manifest_key}"));
|
||||||
|
if let Some(data) = route_data {
|
||||||
|
js_bundles_sources.push(&data.file);
|
||||||
|
data.css
|
||||||
|
.iter()
|
||||||
|
.for_each(|source| css_bundles_sources.push(source))
|
||||||
|
}
|
||||||
|
break '_dynamic_routes_loop;
|
||||||
|
}
|
||||||
|
if path_segments.len() == i {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if dyn_route_segments[i] == path_segments[i]
|
||||||
|
|| has_dynamic_path(dyn_route_segments[i])
|
||||||
|
{
|
||||||
|
route_segments_collector.push(dyn_route_segments[i])
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if route_segments_collector.len() == path_segments.len() {
|
||||||
|
let manifest_key = route_segments_collector.join("/");
|
||||||
|
|
||||||
|
let route_data = manifest.get(&format!("/{manifest_key}"));
|
||||||
|
if let Some(data) = route_data {
|
||||||
|
js_bundles_sources.push(&data.file);
|
||||||
|
data.css
|
||||||
|
.iter()
|
||||||
|
.for_each(|source| css_bundles_sources.push(source))
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.js_bundles = Some(js_bundles_sources);
|
||||||
|
self.css_bundles = Some(css_bundles_sources);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,66 +161,69 @@ impl<'a> Payload<'a> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::manifest::ViteManifest;
|
|
||||||
use axum::http::Uri;
|
use axum::http::Uri;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
const MANIFEST_EXAMPLE: &str = r#"{
|
use crate::manifest::BundleInfo;
|
||||||
"../src/routes/index.tsx": {
|
|
||||||
"file": "assets/index-D-yFyCZo.js",
|
|
||||||
"name": "index",
|
|
||||||
"src": "../src/routes/index.tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/index-CynfArjF.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/pokemons/[pokemon].tsx": {
|
|
||||||
"file": "assets/_pokemon_-DlFInatQ.js",
|
|
||||||
"name": "_pokemon_",
|
|
||||||
"src": "../src/routes/pokemons/[pokemon].tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/_pokemon_-BcJZaQaO.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"../src/routes/pokemons/__layout.tsx": {
|
|
||||||
"file": "assets/__layout-BFnT3M7X.js",
|
|
||||||
"name": "__layout",
|
|
||||||
"src": "../src/routes/pokemons/__layout.tsx",
|
|
||||||
"isDynamicEntry": true,
|
|
||||||
"imports": [
|
|
||||||
"client-main.tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/__layout-CXGGqNw5.css"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"client-main.tsx": {
|
|
||||||
"file": "assets/client-main-B9g1NVV7.js",
|
|
||||||
"name": "client-main",
|
|
||||||
"src": "client-main.tsx",
|
|
||||||
"isEntry": true,
|
|
||||||
"dynamicImports": [
|
|
||||||
"../src/routes/pokemons/__layout.tsx",
|
|
||||||
"../src/routes/index.tsx",
|
|
||||||
"../src/routes/pokemons/[pokemon].tsx"
|
|
||||||
],
|
|
||||||
"css": [
|
|
||||||
"assets/client-main-BS7N-NIa.css"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'_> {
|
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload {
|
||||||
let manifest_mock = serde_json::from_str::<ViteManifest>(MANIFEST_EXAMPLE)
|
let mut manifest_mock = HashMap::new();
|
||||||
.expect("Failed to parse the manifest example");
|
manifest_mock.insert(
|
||||||
MANIFEST.get_or_init(|| manifest_mock.into());
|
"client-main".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/bundled-file.js".to_string(),
|
||||||
|
css: vec!["assets/bundled-file.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/index.js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/index.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/posts/[post]".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/posts/[post].js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/posts/[post].css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/posts/[post]/[comment]".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/posts/[post]/[comment].js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/posts/[post]/[comment].css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/pokemons/[...catch_all]".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/catch_all.js".to_string(),
|
||||||
|
css: vec!["assets/catch_all.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/posts/custom-post".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/custom-post.js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/custom-post.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/about".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/about.js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/about.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
MANIFEST.get_or_init(|| manifest_mock);
|
||||||
|
|
||||||
let uri = uri
|
let uri = uri
|
||||||
.unwrap_or("http://localhost:3000/")
|
.unwrap_or("http://localhost:3000/")
|
||||||
@@ -150,15 +250,15 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload.js_bundles,
|
payload.js_bundles,
|
||||||
Some(vec![
|
Some(vec![
|
||||||
"assets/index-D-yFyCZo.js".to_string(),
|
&"assets/bundled-file.js".to_string(),
|
||||||
"assets/client-main-B9g1NVV7.js".to_string()
|
&"assets/index.js".to_string()
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload.css_bundles,
|
payload.css_bundles,
|
||||||
Some(vec![
|
Some(vec![
|
||||||
"assets/index-CynfArjF.css".to_string(),
|
&"assets/bundled-file.css".to_string(),
|
||||||
"assets/client-main-BS7N-NIa.css".to_string()
|
&"assets/index.css".to_string()
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,4 +270,89 @@ mod tests {
|
|||||||
assert!(payload.js_bundles.is_none());
|
assert!(payload.js_bundles.is_none());
|
||||||
assert!(payload.css_bundles.is_none());
|
assert!(payload.css_bundles.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_correct_single_dyn_path_bundles() {
|
||||||
|
let mut payload = prepare_payload(Some("http://localhost:3000/posts/a-post"), Mode::Prod);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/posts/[post].js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/posts/[post].css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_correct_nested_dyn_path_bundles() {
|
||||||
|
let mut payload = prepare_payload(
|
||||||
|
Some("http://localhost:3000/posts/a-post/a-comment"),
|
||||||
|
Mode::Prod,
|
||||||
|
);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/posts/[post]/[comment].js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/posts/[post]/[comment].css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_correct_catch_all_bundles() {
|
||||||
|
let mut payload = prepare_payload(
|
||||||
|
Some("http://localhost:3000/pokemons/a-poke/a-poke"),
|
||||||
|
Mode::Prod,
|
||||||
|
);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/catch_all.js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/catch_all.css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_defined_path_bundles() {
|
||||||
|
let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/about.js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/about.css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ use tuono_internal::config::Config;
|
|||||||
use tuono_internal::tuono_println;
|
use tuono_internal::tuono_println;
|
||||||
|
|
||||||
use crate::env::load_env_vars;
|
use crate::env::load_env_vars;
|
||||||
use crate::{
|
use crate::vite::{vite_reverse_proxy, vite_websocket_proxy};
|
||||||
catch_all::catch_all, services::logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy,
|
use crate::{catch_all::catch_all, services::logger::LoggerLayer};
|
||||||
vite_websocket_proxy::vite_websocket_proxy,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEV_PUBLIC_DIR: &str = "public";
|
const DEV_PUBLIC_DIR: &str = "public";
|
||||||
const PROD_PUBLIC_DIR: &str = "out/client";
|
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||||
@@ -55,9 +53,7 @@ impl Server {
|
|||||||
let _ = GLOBAL_CONFIG.set(config.clone());
|
let _ = GLOBAL_CONFIG.set(config.clone());
|
||||||
|
|
||||||
if mode == Mode::Prod {
|
if mode == Mode::Prod {
|
||||||
if let Err(err) = load_manifest() {
|
load_manifest()
|
||||||
tuono_println!("Failed to load vite manifest: {}", err.to_string().red());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod reverse_proxy;
|
||||||
|
mod websocket_proxy;
|
||||||
|
|
||||||
|
pub use reverse_proxy::vite_reverse_proxy;
|
||||||
|
pub use websocket_proxy::vite_websocket_proxy;
|
||||||
+3
-24
@@ -1,16 +1,12 @@
|
|||||||
use crate::config::GLOBAL_CONFIG;
|
use crate::config::GLOBAL_CONFIG;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, Query};
|
use axum::extract::Path;
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use axum::http::{HeaderName, HeaderValue};
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
pub async fn vite_reverse_proxy(
|
pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
|
||||||
Path(path): Path<String>,
|
|
||||||
query: Query<HashMap<String, String>>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
let config = GLOBAL_CONFIG
|
let config = GLOBAL_CONFIG
|
||||||
@@ -23,24 +19,7 @@ pub async fn vite_reverse_proxy(
|
|||||||
config.server.port + 1
|
config.server.port + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
let query_string = query
|
match client.get(format!("{vite_url}/{path}")).send().await {
|
||||||
.0
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k}={v}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
let query_string = if query_string.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("?{query_string}")
|
|
||||||
};
|
|
||||||
|
|
||||||
match client
|
|
||||||
.get(format!("{vite_url}/{path}{query_string}"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let mut response_builder = Response::builder().status(res.status().as_u16());
|
let mut response_builder = Response::builder().status(res.status().as_u16());
|
||||||
|
|
||||||
+13
-2
@@ -105,8 +105,19 @@ async fn handle_socket(mut tuono_socket: WebSocket) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = tuono_sender.send(msg_to_browser).await {
|
if let Err(err) = tuono_sender.send(msg_to_browser).await {
|
||||||
if err.to_string() != Error::AlreadyClosed.to_string() {
|
let err_str = &err.to_string();
|
||||||
eprintln!("Failed to send back message from vite to browser: {err}")
|
let error = err
|
||||||
|
.into_inner()
|
||||||
|
.downcast::<Error>()
|
||||||
|
.unwrap_or(Box::new(Error::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
"Failed to parse error into tungstenite::Error",
|
||||||
|
))));
|
||||||
|
|
||||||
|
match *error {
|
||||||
|
Error::AlreadyClosed => {}
|
||||||
|
Error::Io(err) => eprintln!("IO error received from vite: {}", err.to_string()),
|
||||||
|
_ => eprintln!("Failed to send back message from vite to browser: {err_str}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib_macros"
|
name = "tuono_lib_macros"
|
||||||
version = "0.19.7"
|
version = "0.19.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Superfast React fullstack framework"
|
description = "Superfast React fullstack framework"
|
||||||
homepage = "https://tuono.dev"
|
homepage = "https://tuono.dev"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
let api_fn_name = Ident::new(
|
let api_fn_name = Ident::new(
|
||||||
&format!("{http_method}_tuono_internal_api"),
|
&format!("{}_tuono_internal_api", http_method),
|
||||||
Span::call_site().into(),
|
Span::call_site().into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,13 +19,3 @@ pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
pub fn api(args: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn api(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
api::api_core(args, item)
|
api::api_core(args, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Automatically generate typescript's types
|
|
||||||
/// from Rust's structs, types and enums.
|
|
||||||
///
|
|
||||||
/// The types will be exported on the client side
|
|
||||||
/// and it will be available from the `"tuono/types"` module.
|
|
||||||
#[proc_macro_derive(Type)]
|
|
||||||
pub fn derive_typescript_type(_: TokenStream) -> TokenStream {
|
|
||||||
TokenStream::new()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"types": "tsc --noEmit"
|
"types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"oxc-transform": "0.77.3",
|
"oxc-transform": "0.62.0",
|
||||||
"rollup-plugin-preserve-directives": "0.4.0",
|
"rollup-plugin-preserve-directives": "0.4.0",
|
||||||
"unplugin-isolated-decl": "0.13.6",
|
"unplugin-isolated-decl": "0.13.6",
|
||||||
"vite": "6.1.3",
|
"vite": "6.1.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tuono_lib::{Props, Request, Response, Type};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
#[derive(Serialize)]
|
||||||
struct MyResponse<'a> {
|
struct MyResponse<'a> {
|
||||||
subtitle: &'a str,
|
subtitle: &'a str,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { JSX } from 'react'
|
import type { JSX } from 'react'
|
||||||
import type { TuonoRouteProps } from 'tuono'
|
import type { TuonoRouteProps } from 'tuono'
|
||||||
import { Link } from 'tuono'
|
import { Link } from 'tuono'
|
||||||
import type { MyResponse } from 'tuono/types'
|
|
||||||
|
interface IndexProps {
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function IndexPage({
|
export default function IndexPage({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: TuonoRouteProps<MyResponse>): JSX.Element {
|
}: TuonoRouteProps<IndexProps>): JSX.Element {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <h1>Loading...</h1>
|
return <h1>Loading...</h1>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
"include": ["src", "tuono.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,7 @@ const tuonoEslintConfig = tseslint.config(
|
|||||||
// #endregion shared
|
// #endregion shared
|
||||||
|
|
||||||
// #region package-specific
|
// #region package-specific
|
||||||
'packages/tuono-react-vite-plugin/tests/generator/**',
|
'packages/tuono-fs-router-vite-plugin/tests/generator/**',
|
||||||
|
|
||||||
'packages/tuono-lazy-fn-vite-plugin/tests/sources/**',
|
'packages/tuono-lazy-fn-vite-plugin/tests/sources/**',
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ const tuonoEslintConfig = tseslint.config(
|
|||||||
{
|
{
|
||||||
files: [REACT_FILES_MATCH],
|
files: [REACT_FILES_MATCH],
|
||||||
plugins: {
|
plugins: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
'react-hooks': eslintPluginReactHooks,
|
'react-hooks': eslintPluginReactHooks,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -28,5 +28,5 @@
|
|||||||
// Completeness
|
// Completeness
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
"include": ["src", "tuono.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,5 @@
|
|||||||
// Completeness
|
// Completeness
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
"include": ["src", "tuono.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,5 @@
|
|||||||
// Completeness
|
// Completeness
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
"include": ["src", "tuono.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
@import 'tailwindcss' source('../..');
|
@import 'tailwindcss';
|
||||||
|
|||||||
@@ -28,5 +28,5 @@
|
|||||||
// Completeness
|
// Completeness
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
"include": ["src", "tuono.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -2,7 +2,7 @@
|
|||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad",
|
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev --filter=./devtools/* --filter=./packages/*",
|
"dev": "turbo dev --filter=./devtools/* --filter=./packages/*",
|
||||||
"build": "turbo build --filter=./devtools/* --filter=./packages/*",
|
"build": "turbo build --filter=./devtools/* --filter=./packages/*",
|
||||||
@@ -26,17 +26,17 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.24.0",
|
"@eslint/js": "9.24.0",
|
||||||
"@playwright/test": "1.54.1",
|
"@playwright/test": "1.51.1",
|
||||||
"@types/node": "22.16.5",
|
"@types/node": "22.14.0",
|
||||||
"@vitest/eslint-plugin": "1.2.1",
|
"@vitest/eslint-plugin": "1.1.39",
|
||||||
"eslint": "9.24.0",
|
"eslint": "9.24.0",
|
||||||
"eslint-import-resolver-typescript": "4.3.2",
|
"eslint-import-resolver-typescript": "4.3.2",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"turbo": "2.5.5",
|
"turbo": "2.5.0",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.7.3",
|
||||||
"typescript-eslint": "8.29.1"
|
"typescript-eslint": "8.29.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono-react-vite-plugin",
|
"name": "tuono-fs-router-vite-plugin",
|
||||||
"version": "0.19.7",
|
"version": "0.19.2",
|
||||||
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
||||||
"homepage": "https://tuono.dev",
|
"homepage": "https://tuono.dev",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/tuono-labs/tuono.git",
|
"url": "git+https://github.com/tuono-labs/tuono.git",
|
||||||
"directory": "packages/tuono-react-vite-plugin"
|
"directory": "packages/tuono-fs-router-vite-plugin"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Valerio Ageno",
|
"author": "Valerio Ageno",
|
||||||
@@ -46,6 +46,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
"vite-config": "workspace:*",
|
"vite-config": "workspace:*",
|
||||||
"vitest": "3.2.4"
|
"vitest": "3.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
import type { RouteNode } from '../types'
|
|
||||||
|
|
||||||
import { spaces } from './utils'
|
import { spaces } from './utils'
|
||||||
|
import type { RouteNode } from './types'
|
||||||
|
|
||||||
export function buildRouteConfig(nodes: Array<RouteNode>, depth = 1): string {
|
export function buildRouteConfig(nodes: Array<RouteNode>, depth = 1): string {
|
||||||
const children = nodes.map((node) => {
|
const children = nodes.map((node) => {
|
||||||
+2
-4
@@ -3,8 +3,6 @@ import path from 'path'
|
|||||||
|
|
||||||
import { format } from 'prettier'
|
import { format } from 'prettier'
|
||||||
|
|
||||||
import type { Config, RouteNode } from '../types'
|
|
||||||
|
|
||||||
import { buildRouteConfig } from './build-route-config'
|
import { buildRouteConfig } from './build-route-config'
|
||||||
import { hasParentRoute } from './has-parent-route'
|
import { hasParentRoute } from './has-parent-route'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
removeUnderscores,
|
removeUnderscores,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
|
import type { Config, RouteNode } from './types'
|
||||||
import {
|
import {
|
||||||
ROUTES_FOLDER,
|
ROUTES_FOLDER,
|
||||||
LAYOUT_PATH_ID,
|
LAYOUT_PATH_ID,
|
||||||
@@ -27,7 +26,7 @@ import {
|
|||||||
} from './constants'
|
} from './constants'
|
||||||
|
|
||||||
import { sortRouteNodes } from './sort-route-nodes'
|
import { sortRouteNodes } from './sort-route-nodes'
|
||||||
import isDefaultExported from './is-default-exported'
|
import isDefaultExported from './utils/is-default-exported'
|
||||||
|
|
||||||
let latestTask = 0
|
let latestTask = 0
|
||||||
|
|
||||||
@@ -207,7 +206,6 @@ export async function routeGenerator(
|
|||||||
rustHandlersNodes.includes(node.path || '')
|
rustHandlersNodes.includes(node.path || '')
|
||||||
? 'hasHandler: true'
|
? 'hasHandler: true'
|
||||||
: '',
|
: '',
|
||||||
`filePath: '${node.path || '/'}'`,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(',')}
|
.join(',')}
|
||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
import type { RouteNode } from '../types'
|
|
||||||
|
|
||||||
import { LAYOUT_PATH_ID } from './constants'
|
import { LAYOUT_PATH_ID } from './constants'
|
||||||
import { multiSortBy } from './utils'
|
import { multiSortBy } from './utils'
|
||||||
|
import type { RouteNode } from './types'
|
||||||
|
|
||||||
export function hasParentRoute(
|
export function hasParentRoute(
|
||||||
routes: Array<RouteNode>,
|
routes: Array<RouteNode>,
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { normalize } from 'node:path'
|
||||||
|
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
import { routeGenerator } from './generator'
|
||||||
|
|
||||||
|
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||||
|
|
||||||
|
let lock = false
|
||||||
|
|
||||||
|
export function TuonoFsRouterPlugin(): Plugin {
|
||||||
|
const generate = async (): Promise<void> => {
|
||||||
|
if (lock) return
|
||||||
|
lock = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await routeGenerator()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
lock = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFile = async (file: string): Promise<void> => {
|
||||||
|
const filePath = normalize(file)
|
||||||
|
|
||||||
|
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||||
|
await generate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-tuono-fs-router',
|
||||||
|
configResolved: async (): Promise<void> => {
|
||||||
|
await generate()
|
||||||
|
},
|
||||||
|
watchChange: async (
|
||||||
|
file: string,
|
||||||
|
context: { event: string },
|
||||||
|
): Promise<void> => {
|
||||||
|
if (['create', 'update', 'delete'].includes(context.event)) {
|
||||||
|
await handleFile(file)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import type { RouteNode } from '../types'
|
import type { RouteNode } from './types'
|
||||||
|
|
||||||
import { multiSortBy } from './utils'
|
import { multiSortBy } from './utils'
|
||||||
import { LAYOUT_PATH_ID } from './constants'
|
import { LAYOUT_PATH_ID } from './constants'
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { RouteNode } from '../types'
|
import type { RouteNode } from './types'
|
||||||
|
|
||||||
export function removeExt(d: string, keepExtension: boolean = false): string {
|
export function removeExt(d: string, keepExtension: boolean = false): string {
|
||||||
return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d
|
return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import fs from 'fs/promises'
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
import { routeGenerator } from '../src/fs-routing/generator'
|
import { routeGenerator } from '../src/generator'
|
||||||
|
|
||||||
describe('generator works', async () => {
|
describe('generator works', async () => {
|
||||||
const folderNames = await fs.readdir(`${process.cwd()}/tests/generator`)
|
const folderNames = await fs.readdir(`${process.cwd()}/tests/generator`)
|
||||||
-2
@@ -21,14 +21,12 @@ const Postscatchall = createRoute({ component: PostscatchallImport })
|
|||||||
const IndexRoute = Index.update({
|
const IndexRoute = Index.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostscatchallRoute = Postscatchall.update({
|
const PostscatchallRoute = Postscatchall.update({
|
||||||
path: '/posts/[...catch_all]',
|
path: '/posts/[...catch_all]',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
hasHandler: true,
|
hasHandler: true,
|
||||||
filePath: '/posts/[...catch_all]',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and export the route tree
|
// Create and export the route tree
|
||||||
-2
@@ -21,13 +21,11 @@ const Index = createRoute({ component: IndexImport })
|
|||||||
const AboutRoute = About.update({
|
const AboutRoute = About.update({
|
||||||
path: '/about',
|
path: '/about',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/about',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const IndexRoute = Index.update({
|
const IndexRoute = Index.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and export the route tree
|
// Create and export the route tree
|
||||||
-6
@@ -36,37 +36,31 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
|
|||||||
|
|
||||||
const PostslayoutRoute = Postslayout.update({
|
const PostslayoutRoute = Postslayout.update({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/posts/__layout',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const AboutRoute = About.update({
|
const AboutRoute = About.update({
|
||||||
path: '/about',
|
path: '/about',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/about',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const IndexRoute = Index.update({
|
const IndexRoute = Index.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostspostRoute = Postspost.update({
|
const PostspostRoute = Postspost.update({
|
||||||
path: '/posts/[post]',
|
path: '/posts/[post]',
|
||||||
getParentRoute: () => PostslayoutRoute,
|
getParentRoute: () => PostslayoutRoute,
|
||||||
filePath: '/posts/[post]',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostsIndexRoute = PostsIndex.update({
|
const PostsIndexRoute = PostsIndex.update({
|
||||||
path: '/posts',
|
path: '/posts',
|
||||||
getParentRoute: () => PostslayoutRoute,
|
getParentRoute: () => PostslayoutRoute,
|
||||||
filePath: '/posts/',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostsMyPostRoute = PostsMyPost.update({
|
const PostsMyPostRoute = PostsMyPost.update({
|
||||||
path: '/posts/my-post',
|
path: '/posts/my-post',
|
||||||
getParentRoute: () => PostslayoutRoute,
|
getParentRoute: () => PostslayoutRoute,
|
||||||
filePath: '/posts/my-post',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and export the route tree
|
// Create and export the route tree
|
||||||
-3
@@ -25,19 +25,16 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
|
|||||||
const AboutRoute = About.update({
|
const AboutRoute = About.update({
|
||||||
path: '/about',
|
path: '/about',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/about',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const IndexRoute = Index.update({
|
const IndexRoute = Index.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const PostsMyPostRoute = PostsMyPost.update({
|
const PostsMyPostRoute = PostsMyPost.update({
|
||||||
path: '/posts/my-post',
|
path: '/posts/my-post',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/posts/my-post',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and export the route tree
|
// Create and export the route tree
|
||||||
-2
@@ -21,13 +21,11 @@ const Index = createRoute({ component: IndexImport })
|
|||||||
const AboutRoute = About.update({
|
const AboutRoute = About.update({
|
||||||
path: '/about',
|
path: '/about',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/about',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const IndexRoute = Index.update({
|
const IndexRoute = Index.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
filePath: '/',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and export the route tree
|
// Create and export the route tree
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { TuonoReactPlugin } from './plugin'
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { normalize } from 'node:path'
|
|
||||||
|
|
||||||
import type { Plugin, ViteDevServer } from 'vite'
|
|
||||||
|
|
||||||
import { routeGenerator } from './fs-routing/generator'
|
|
||||||
import { getStylesForComponentId, isCssModulesFile } from './styles'
|
|
||||||
|
|
||||||
const CRITICAL_CSS_PATH = '/vite-server/tuono_internal__critical_css'
|
|
||||||
|
|
||||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
|
||||||
|
|
||||||
let lock = false
|
|
||||||
|
|
||||||
export function TuonoReactPlugin(): Plugin {
|
|
||||||
const generate = async (): Promise<void> => {
|
|
||||||
if (lock) return
|
|
||||||
lock = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await routeGenerator()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
lock = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFile = async (file: string): Promise<void> => {
|
|
||||||
const filePath = normalize(file)
|
|
||||||
|
|
||||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
|
||||||
await generate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This manifest is used to store the CSS modules contents in dev mode
|
|
||||||
// { [filePath]: cssContent }
|
|
||||||
const cssModulesManifest: Record<string, string> = {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'vite-plugin-tuono-react',
|
|
||||||
configResolved: async (): Promise<void> => {
|
|
||||||
await generate()
|
|
||||||
},
|
|
||||||
watchChange: async (
|
|
||||||
file: string,
|
|
||||||
context: { event: string },
|
|
||||||
): Promise<void> => {
|
|
||||||
if (['create', 'update', 'delete'].includes(context.event)) {
|
|
||||||
await handleFile(file)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transform: (code, id): void => {
|
|
||||||
if (isCssModulesFile(id)) {
|
|
||||||
cssModulesManifest[id] = code
|
|
||||||
}
|
|
||||||
},
|
|
||||||
configureServer: (server: ViteDevServer): void => {
|
|
||||||
// Using middlewares in order to take advantage of async requests out of
|
|
||||||
// the box
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
||||||
server.middlewares.use(async (req, res, next): Promise<void> => {
|
|
||||||
const url = new URL(req.url || '', `http://${req.headers.host || ''}`)
|
|
||||||
|
|
||||||
// Give the request handler access to the critical CSS in dev to avoid a
|
|
||||||
// flash of unstyled content since Vite injects CSS file contents via JS
|
|
||||||
if (url.pathname === CRITICAL_CSS_PATH) {
|
|
||||||
const componentId = url.searchParams.get('componentId')
|
|
||||||
const css = await getStylesForComponentId(
|
|
||||||
server,
|
|
||||||
componentId,
|
|
||||||
cssModulesManifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/css' })
|
|
||||||
res.end(css)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* This module is strongly inspired by the remix project.
|
|
||||||
*
|
|
||||||
* source: https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/styles.ts
|
|
||||||
*/
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
import type { ModuleNode, ViteDevServer } from 'vite'
|
|
||||||
|
|
||||||
const isCssFile = (file: string): boolean => cssFileRegExp.test(file)
|
|
||||||
|
|
||||||
const cssFileRegExp =
|
|
||||||
/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/
|
|
||||||
|
|
||||||
const cssModulesRegExp = new RegExp(`\\.module${cssFileRegExp.source}`)
|
|
||||||
|
|
||||||
const routesFolder = path.relative(process.cwd(), 'src/routes')
|
|
||||||
|
|
||||||
const injectQuery = (url: string, query: string): string =>
|
|
||||||
url.includes('?') ? url.replace('?', `?${query}&`) : `${url}?${query}`
|
|
||||||
|
|
||||||
export const isCssModulesFile = (file: string): boolean =>
|
|
||||||
cssModulesRegExp.test(file)
|
|
||||||
|
|
||||||
const cssUrlParamsWithoutSideEffects = ['url', 'inline', 'raw', 'inline-css']
|
|
||||||
|
|
||||||
const isCssUrlWithoutSideEffects = (url: string): boolean => {
|
|
||||||
const queryString = url.split('?')[1]
|
|
||||||
|
|
||||||
if (!queryString) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(queryString)
|
|
||||||
for (const paramWithoutSideEffects of cssUrlParamsWithoutSideEffects) {
|
|
||||||
if (
|
|
||||||
// Parameter is blank and not explicitly set, i.e. "?url", not "?url="
|
|
||||||
params.get(paramWithoutSideEffects) === '' &&
|
|
||||||
!url.includes(`?${paramWithoutSideEffects}=`) &&
|
|
||||||
!url.includes(`&${paramWithoutSideEffects}=`)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizePath = (modulePath: string): string => {
|
|
||||||
return modulePath.startsWith('node_modules')
|
|
||||||
? path.join(process.cwd(), modulePath)
|
|
||||||
: modulePath
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStylesForModule = async (
|
|
||||||
viteDevServer: ViteDevServer,
|
|
||||||
moduleUrl: string,
|
|
||||||
/**
|
|
||||||
* All the CSS modules are preloaded and saved in this manifest
|
|
||||||
*/
|
|
||||||
cssModulesManifest: Record<string, string>,
|
|
||||||
): Promise<string | undefined> => {
|
|
||||||
const styles: Record<string, string> = {}
|
|
||||||
const deps: Set<ModuleNode> = new Set()
|
|
||||||
|
|
||||||
const moduleFilePath = normalizePath(moduleUrl)
|
|
||||||
try {
|
|
||||||
let node: ModuleNode | undefined =
|
|
||||||
await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
|
|
||||||
|
|
||||||
// If the module is only present in the client module graph, the module
|
|
||||||
// won't have been found on the first request to the server. If so, we
|
|
||||||
// request the module so it's in the module graph, then try again.
|
|
||||||
if (!node) {
|
|
||||||
try {
|
|
||||||
await viteDevServer.transformRequest(moduleFilePath)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
node = await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
console.error(`Could not resolve module for file: ${moduleFilePath}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await findNodeDependencies(viteDevServer, node, deps)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dep of deps) {
|
|
||||||
if (
|
|
||||||
dep.file &&
|
|
||||||
isCssFile(dep.file) &&
|
|
||||||
!isCssUrlWithoutSideEffects(dep.url) // Ignore styles that resolved as URLs, inline or raw. These shouldn't get injected.
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const css = isCssModulesFile(dep.file)
|
|
||||||
? cssModulesManifest[dep.file]
|
|
||||||
: ((
|
|
||||||
await viteDevServer.ssrLoadModule(
|
|
||||||
// We need the ?inline query in Vite v6 when loading CSS in SSR
|
|
||||||
// since it does not expose the default export for CSS in a
|
|
||||||
// server environment.
|
|
||||||
injectQuery(normalizePath(dep.file), 'inline'),
|
|
||||||
)
|
|
||||||
).default as string)
|
|
||||||
|
|
||||||
if (css === undefined) {
|
|
||||||
throw new Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
styles[dep.url] = css
|
|
||||||
} catch {
|
|
||||||
// this can happen with dynamically imported modules
|
|
||||||
console.warn(`Could not load ${dep.file}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
Object.entries(styles)
|
|
||||||
.map(([fileName, css]) => [
|
|
||||||
`\n/* ${fileName
|
|
||||||
// Escape comment syntax in file paths
|
|
||||||
.replace(/\/\*/g, '/\\*')
|
|
||||||
.replace(/\*\//g, '*\\/')} */`,
|
|
||||||
css,
|
|
||||||
])
|
|
||||||
.flat()
|
|
||||||
.join('\n') || undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function transform the componentId into a file path.
|
|
||||||
* File extension is not required for the vite.moduleGraph URL search.
|
|
||||||
*/
|
|
||||||
function findFileFromComponentId(id: string): string {
|
|
||||||
if (id.endsWith('/')) {
|
|
||||||
return id + 'index'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('__root__')) {
|
|
||||||
return id.replaceAll('__root__', '__layout')
|
|
||||||
}
|
|
||||||
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStylesForComponentId = async (
|
|
||||||
viteDevServer: ViteDevServer,
|
|
||||||
/**
|
|
||||||
* The route name (should match tuono-router specs)
|
|
||||||
*/
|
|
||||||
componentId: string | null,
|
|
||||||
/**
|
|
||||||
* All the CSS modules are preloaded and saved in this manifest
|
|
||||||
*/
|
|
||||||
cssModulesManifest: Record<string, string>,
|
|
||||||
): Promise<string | undefined> => {
|
|
||||||
const relativeFilePath = path.join(
|
|
||||||
routesFolder,
|
|
||||||
findFileFromComponentId(componentId || ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
const fileUrl = path.join(process.cwd(), relativeFilePath)
|
|
||||||
|
|
||||||
return await getStylesForModule(viteDevServer, fileUrl, cssModulesManifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is used to find all the dependencies of a module node.
|
|
||||||
* The starting node is always a route.
|
|
||||||
*/
|
|
||||||
const findNodeDependencies = async (
|
|
||||||
vite: ViteDevServer,
|
|
||||||
node: ModuleNode,
|
|
||||||
deps: Set<ModuleNode>,
|
|
||||||
): Promise<void> => {
|
|
||||||
// since `ssrTransformResult.deps` contains URLs instead of `ModuleNode`s, this process is asynchronous.
|
|
||||||
// instead of using `await`, we resolve all branches in parallel.
|
|
||||||
const branches: Array<Promise<void>> = []
|
|
||||||
|
|
||||||
async function addFromNode(innerNode: ModuleNode): Promise<void> {
|
|
||||||
if (!deps.has(innerNode)) {
|
|
||||||
deps.add(innerNode)
|
|
||||||
await findNodeDependencies(vite, innerNode, deps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFromUrl(url: string): Promise<void> {
|
|
||||||
const innerNode = await vite.moduleGraph.getModuleByUrl(url)
|
|
||||||
|
|
||||||
if (innerNode) {
|
|
||||||
await addFromNode(innerNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.ssrTransformResult) {
|
|
||||||
if (node.ssrTransformResult.deps) {
|
|
||||||
node.ssrTransformResult.deps.forEach((url) =>
|
|
||||||
branches.push(addFromUrl(url)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
node.importedModules.forEach((innerNode: ModuleNode) =>
|
|
||||||
branches.push(addFromNode(innerNode)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(branches)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono-router",
|
"name": "tuono-router",
|
||||||
"version": "0.19.7",
|
"version": "0.19.2",
|
||||||
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
|
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
|
||||||
"homepage": "https://tuono.dev",
|
"homepage": "https://tuono.dev",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -45,12 +45,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/react": "16.3.0",
|
"@testing-library/react": "16.3.0",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.0",
|
||||||
"@vitejs/plugin-react-swc": "3.11.0",
|
"@vitejs/plugin-react-swc": "3.8.1",
|
||||||
"happy-dom": "20.8.9",
|
"happy-dom": "17.4.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"vite": "6.1.3",
|
"vite": "6.1.3",
|
||||||
"vite-config": "workspace:*",
|
"vite-config": "workspace:*",
|
||||||
"vitest": "3.2.4"
|
"vitest": "3.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { JSX } from 'react'
|
|
||||||
|
|
||||||
import type { Mode } from '../types'
|
|
||||||
|
|
||||||
const VITE_PROXY_PATH = '/vite-server'
|
|
||||||
const CRITICAL_CSS_PATH = VITE_PROXY_PATH + '/tuono_internal__critical_css'
|
|
||||||
|
|
||||||
interface CriticalCssProps {
|
|
||||||
routeFilePath?: string
|
|
||||||
mode?: Mode
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the critical CSS for the given route
|
|
||||||
* This is required in order to avoid FOUC during development
|
|
||||||
* since vite does not support CSS injection without JS waterfall
|
|
||||||
*/
|
|
||||||
export function CriticalCss({
|
|
||||||
routeFilePath,
|
|
||||||
mode,
|
|
||||||
}: CriticalCssProps): JSX.Element | null {
|
|
||||||
if (!routeFilePath || mode !== 'Dev') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<link
|
|
||||||
href={`${CRITICAL_CSS_PATH}?componentId=${routeFilePath}`}
|
|
||||||
precedence="high"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,6 @@ import type { JSX } from 'react'
|
|||||||
|
|
||||||
import { useRoute } from '../hooks/useRoute'
|
import { useRoute } from '../hooks/useRoute'
|
||||||
|
|
||||||
import type { Mode } from '../types'
|
|
||||||
|
|
||||||
import { RouteMatch } from './RouteMatch'
|
import { RouteMatch } from './RouteMatch'
|
||||||
import { NotFound } from './NotFound'
|
import { NotFound } from './NotFound'
|
||||||
import { useRouterContext } from './RouterContext'
|
import { useRouterContext } from './RouterContext'
|
||||||
@@ -11,26 +9,16 @@ import { useRouterContext } from './RouterContext'
|
|||||||
interface MatchesProps<TServerPayloadData = unknown> {
|
interface MatchesProps<TServerPayloadData = unknown> {
|
||||||
// user defined props
|
// user defined props
|
||||||
serverInitialData: TServerPayloadData
|
serverInitialData: TServerPayloadData
|
||||||
mode?: Mode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Matches({
|
export function Matches({ serverInitialData }: MatchesProps): JSX.Element {
|
||||||
serverInitialData,
|
|
||||||
mode,
|
|
||||||
}: MatchesProps): JSX.Element {
|
|
||||||
const { location } = useRouterContext()
|
const { location } = useRouterContext()
|
||||||
|
|
||||||
const route = useRoute(location.pathname)
|
const route = useRoute(location.pathname)
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return <NotFound mode={mode} />
|
return <NotFound />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RouteMatch route={route} serverInitialData={serverInitialData} />
|
||||||
<RouteMatch
|
|
||||||
route={route}
|
|
||||||
mode={mode}
|
|
||||||
serverInitialData={serverInitialData}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,17 @@ import type { JSX } from 'react'
|
|||||||
import { useRouterContext } from '../components/RouterContext'
|
import { useRouterContext } from '../components/RouterContext'
|
||||||
import { ROOT_ROUTE_ID } from '../route'
|
import { ROOT_ROUTE_ID } from '../route'
|
||||||
|
|
||||||
import type { Mode } from '../types'
|
|
||||||
|
|
||||||
import { RouteMatch } from './RouteMatch'
|
import { RouteMatch } from './RouteMatch'
|
||||||
import { NotFoundDefaultContent } from './NotFoundDefaultContent'
|
import { NotFoundDefaultContent } from './NotFoundDefaultContent'
|
||||||
import { CriticalCss } from './CriticalCss'
|
|
||||||
|
|
||||||
export function NotFound({ mode }: { mode?: Mode }): JSX.Element | null {
|
export function NotFound(): JSX.Element | null {
|
||||||
const { router } = useRouterContext()
|
const { router } = useRouterContext()
|
||||||
|
|
||||||
const custom404Route = router.routesById['/404']
|
const custom404Route = router.routesById['/404']
|
||||||
|
|
||||||
// Check if exists a custom 404 error page
|
// Check if exists a custom 404 error page
|
||||||
if (custom404Route) {
|
if (custom404Route) {
|
||||||
return (
|
return <RouteMatch route={custom404Route} serverInitialData={{}} />
|
||||||
<>
|
|
||||||
<CriticalCss routeFilePath={custom404Route.filePath} mode={mode} />
|
|
||||||
<RouteMatch route={custom404Route} mode={mode} serverInitialData={{}} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RootLayout = router.routesById[ROOT_ROUTE_ID]?.component
|
const RootLayout = router.routesById[ROOT_ROUTE_ID]?.component
|
||||||
@@ -30,7 +22,6 @@ export function NotFound({ mode }: { mode?: Mode }): JSX.Element | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RootLayout data={null} isLoading={false}>
|
<RootLayout data={null} isLoading={false}>
|
||||||
<CriticalCss routeFilePath="__root__" mode={mode} />
|
|
||||||
<NotFoundDefaultContent />
|
<NotFoundDefaultContent />
|
||||||
</RootLayout>
|
</RootLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { cleanup, render, screen } from '@testing-library/react'
|
|||||||
import { Route } from '../route'
|
import { Route } from '../route'
|
||||||
import type { RouteComponent, RouteProps } from '../types'
|
import type { RouteComponent, RouteProps } from '../types'
|
||||||
import { useServerPayloadData } from '../hooks/useServerPayloadData'
|
import { useServerPayloadData } from '../hooks/useServerPayloadData'
|
||||||
import { useRouterContext } from '../components/RouterContext'
|
|
||||||
|
|
||||||
import { RouteMatch } from './RouteMatch'
|
import { RouteMatch } from './RouteMatch'
|
||||||
|
|
||||||
@@ -48,21 +47,16 @@ vi.mock('../hooks/useServerPayloadData', () => ({
|
|||||||
useServerPayloadData: vi.fn(),
|
useServerPayloadData: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../components/RouterContext', () => ({
|
|
||||||
useRouterContext: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const useServerPayloadDataMock = vi.mocked(useServerPayloadData)
|
const useServerPayloadDataMock = vi.mocked(useServerPayloadData)
|
||||||
const useRouterContextMock = vi.mocked(useRouterContext)
|
|
||||||
|
|
||||||
describe('<RouteMatch />', () => {
|
describe('<RouteMatch />', () => {
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
it('should correctly render nested routes', () => {
|
it('should correctly render nested routes', () => {
|
||||||
useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } })
|
useServerPayloadDataMock.mockReturnValue({
|
||||||
|
data: { some: 'data' },
|
||||||
// @ts-expect-error only isTransitioning is used by RouteMatch
|
isLoading: false,
|
||||||
useRouterContextMock.mockReturnValue({ isTransitioning: false })
|
})
|
||||||
|
|
||||||
render(<RouteMatch route={route} serverInitialData={{}} />)
|
render(<RouteMatch route={route} serverInitialData={{}} />)
|
||||||
|
|
||||||
@@ -87,15 +81,13 @@ describe('<RouteMatch />', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should correctly handle loading transition', () => {
|
it('should return null data when while loading', () => {
|
||||||
useServerPayloadDataMock.mockReturnValue({ data: { some: 'data' } })
|
useServerPayloadDataMock.mockReturnValue({
|
||||||
|
data: { some: 'data' },
|
||||||
|
isLoading: true,
|
||||||
|
})
|
||||||
|
|
||||||
// @ts-expect-error only isTransitioning is used by RouteMatch
|
render(<RouteMatch route={route} serverInitialData={{}} />)
|
||||||
useRouterContextMock.mockReturnValue({ isTransitioning: true })
|
|
||||||
|
|
||||||
const { rerender } = render(
|
|
||||||
<RouteMatch route={route} serverInitialData={{}} />,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByTestId('root')).toMatchInlineSnapshot(
|
expect(screen.getByTestId('root')).toMatchInlineSnapshot(
|
||||||
`
|
`
|
||||||
@@ -114,30 +106,5 @@ describe('<RouteMatch />', () => {
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// @ts-expect-error only isTransitioning is used by RouteMatch
|
|
||||||
useRouterContextMock.mockReturnValue({ isTransitioning: false })
|
|
||||||
|
|
||||||
rerender(<RouteMatch route={route} serverInitialData={{}} />)
|
|
||||||
|
|
||||||
expect(screen.getByTestId('root')).toMatchInlineSnapshot(
|
|
||||||
`
|
|
||||||
<div
|
|
||||||
data-testid="root"
|
|
||||||
>
|
|
||||||
root route
|
|
||||||
<div
|
|
||||||
data-testid="parent"
|
|
||||||
>
|
|
||||||
parent route
|
|
||||||
<div
|
|
||||||
data-testid="current"
|
|
||||||
>
|
|
||||||
{"some":"data"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import type { JSX } from 'react'
|
import type { JSX } from 'react'
|
||||||
import { memo, Suspense, useMemo } from 'react'
|
import { memo, Suspense, useMemo } from 'react'
|
||||||
|
|
||||||
import type { Mode } from '../types'
|
|
||||||
import type { Route } from '../route'
|
import type { Route } from '../route'
|
||||||
|
|
||||||
import { useServerPayloadData } from '../hooks/useServerPayloadData'
|
import { useServerPayloadData } from '../hooks/useServerPayloadData'
|
||||||
|
|
||||||
import { useRouterContext } from './RouterContext'
|
|
||||||
import { CriticalCss } from './CriticalCss'
|
|
||||||
|
|
||||||
interface RouteMatchProps<TServerPayloadData = unknown> {
|
interface RouteMatchProps<TServerPayloadData = unknown> {
|
||||||
route: Route
|
route: Route
|
||||||
// User defined server side props
|
// User defined server side props
|
||||||
serverInitialData: TServerPayloadData
|
serverInitialData: TServerPayloadData
|
||||||
mode?: Mode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,26 +18,22 @@ interface RouteMatchProps<TServerPayloadData = unknown> {
|
|||||||
export const RouteMatch = ({
|
export const RouteMatch = ({
|
||||||
route,
|
route,
|
||||||
serverInitialData,
|
serverInitialData,
|
||||||
mode,
|
|
||||||
}: RouteMatchProps): JSX.Element => {
|
}: RouteMatchProps): JSX.Element => {
|
||||||
const { data } = useServerPayloadData(route, serverInitialData)
|
const { data, isLoading } = useServerPayloadData(route, serverInitialData)
|
||||||
const { isTransitioning } = useRouterContext()
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const routes = useMemo(() => loadParentComponents(route), [route.id])
|
const routes = useMemo(() => loadParentComponents(route), [route.id])
|
||||||
|
|
||||||
const routeData = isTransitioning ? null : data
|
const routeData = isLoading ? null : data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TraverseRootComponents
|
<TraverseRootComponents
|
||||||
routes={routes}
|
routes={routes}
|
||||||
data={routeData}
|
data={routeData}
|
||||||
isLoading={isTransitioning}
|
isLoading={isLoading}
|
||||||
mode={mode}
|
|
||||||
>
|
>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<CriticalCss routeFilePath={route.filePath} mode={mode} />
|
<route.component data={routeData} isLoading={isLoading} />
|
||||||
<route.component data={routeData} isLoading={isTransitioning} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</TraverseRootComponents>
|
</TraverseRootComponents>
|
||||||
)
|
)
|
||||||
@@ -55,7 +45,6 @@ interface TraverseRootComponentsProps<TData = unknown> {
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
index?: number
|
index?: number
|
||||||
mode?: Mode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,26 +59,18 @@ const TraverseRootComponents = memo(
|
|||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
index = 0,
|
index = 0,
|
||||||
mode,
|
|
||||||
children,
|
children,
|
||||||
}: TraverseRootComponentsProps): React.JSX.Element => {
|
}: TraverseRootComponentsProps): React.JSX.Element => {
|
||||||
if (routes.length > index) {
|
if (routes.length > index) {
|
||||||
const route = routes[index] as Route
|
const Parent = (routes[index] as Route).component
|
||||||
const Parent = route.component
|
|
||||||
|
|
||||||
// Fallback to the route id if the filePath is not defined
|
|
||||||
// as is the case for the root route
|
|
||||||
const routeFilePath = route.filePath || route.id
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Parent data={data} isLoading={isLoading}>
|
<Parent data={data} isLoading={isLoading}>
|
||||||
<CriticalCss routeFilePath={routeFilePath} mode={mode} />
|
|
||||||
<TraverseRootComponents
|
<TraverseRootComponents
|
||||||
routes={routes}
|
routes={routes}
|
||||||
data={data}
|
data={data}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
index={index + 1}
|
index={index + 1}
|
||||||
mode={mode}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TraverseRootComponents>
|
</TraverseRootComponents>
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { createContext, useState, useEffect, useContext, useMemo } from 'react'
|
||||||
createContext,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import type { Router } from '../router'
|
import type { Router } from '../router'
|
||||||
@@ -24,9 +17,7 @@ export interface ParsedLocation {
|
|||||||
interface RouterContextValue {
|
interface RouterContextValue {
|
||||||
router: Router
|
router: Router
|
||||||
location: ParsedLocation
|
location: ParsedLocation
|
||||||
isTransitioning: boolean
|
|
||||||
updateLocation: (loc: ParsedLocation) => void
|
updateLocation: (loc: ParsedLocation) => void
|
||||||
stopTransitioning: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RouterContext = createContext({} as RouterContextValue)
|
const RouterContext = createContext({} as RouterContextValue)
|
||||||
@@ -73,9 +64,6 @@ export function RouterContextProvider({
|
|||||||
const [location, setLocation] = useState<ParsedLocation>(() =>
|
const [location, setLocation] = useState<ParsedLocation>(() =>
|
||||||
getInitialLocation(serverInitialLocation),
|
getInitialLocation(serverInitialLocation),
|
||||||
)
|
)
|
||||||
// Global state to track whether a page transition is in progress.
|
|
||||||
// Set to `false` once the page is fully loaded, including server-side data.
|
|
||||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen browser navigation events
|
* Listen browser navigation events
|
||||||
@@ -103,24 +91,13 @@ export function RouterContextProvider({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const updateLocation = useCallback((newLocation: ParsedLocation): void => {
|
|
||||||
setIsTransitioning(true)
|
|
||||||
setLocation(newLocation)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const stopTransitioning = useCallback((): void => {
|
|
||||||
setIsTransitioning(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const contextValue: RouterContextValue = useMemo(
|
const contextValue: RouterContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
router,
|
router,
|
||||||
location,
|
location,
|
||||||
isTransitioning,
|
updateLocation: setLocation,
|
||||||
updateLocation,
|
|
||||||
stopTransitioning,
|
|
||||||
}),
|
}),
|
||||||
[location, router, isTransitioning, updateLocation, stopTransitioning],
|
[location, router],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { JSX } from 'react'
|
import type { JSX } from 'react'
|
||||||
|
|
||||||
import type { ServerInitialLocation, Mode } from '../types'
|
import type { ServerInitialLocation } from '../types'
|
||||||
import type { Router } from '../router'
|
import type { Router } from '../router'
|
||||||
|
|
||||||
import { RouterContextProvider } from './RouterContext'
|
import { RouterContextProvider } from './RouterContext'
|
||||||
@@ -10,21 +10,19 @@ interface RouterProviderProps {
|
|||||||
router: Router
|
router: Router
|
||||||
serverInitialLocation: ServerInitialLocation
|
serverInitialLocation: ServerInitialLocation
|
||||||
serverInitialData: unknown
|
serverInitialData: unknown
|
||||||
mode?: Mode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouterProvider({
|
export function RouterProvider({
|
||||||
router,
|
router,
|
||||||
serverInitialLocation,
|
serverInitialLocation,
|
||||||
serverInitialData,
|
serverInitialData,
|
||||||
mode,
|
|
||||||
}: RouterProviderProps): JSX.Element {
|
}: RouterProviderProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<RouterContextProvider
|
<RouterContextProvider
|
||||||
router={router}
|
router={router}
|
||||||
serverInitialLocation={serverInitialLocation}
|
serverInitialLocation={serverInitialLocation}
|
||||||
>
|
>
|
||||||
<Matches serverInitialData={serverInitialData} mode={mode} />
|
<Matches serverInitialData={serverInitialData} />
|
||||||
</RouterContextProvider>
|
</RouterContextProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ export function sanitizePathname(pathname: string): string {
|
|||||||
return pathname
|
return pathname
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Returns the route that matches the given pathname
|
|
||||||
*
|
|
||||||
* This hook is also implemented on server side to match the bundle
|
* This hook is also implemented on server side to match the bundle
|
||||||
* file to load at the first rendering.
|
* file to load at the first rendering.
|
||||||
*
|
*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user