Compare commits

...

65 Commits

Author SHA1 Message Date
Valerio Ageno 8d2be4178b feat: ugrade turbo 2024-06-04 20:57:53 +02:00
Valerio Ageno e89f2f63f8 feat: update version to v0.0.10 2024-06-04 20:49:13 +02:00
Valerio Ageno f6e7ebd33e fix: correctly import modulepreload-polyfill 2024-06-04 20:48:19 +02:00
Valerio Ageno 5ea209de0b feat: prevent default code on route 2024-06-04 20:29:36 +02:00
Valerio Ageno a4b2dd222d feat: update crate version to v0.0.9 2024-06-04 19:18:30 +02:00
Valerio Ageno c84f5d479b fix: static consts locations 2024-06-04 19:17:31 +02:00
Valerio Ageno 9251d445d3 feat: update version to v0.0.8 2024-06-04 19:06:53 +02:00
Valerio Ageno c1ff7e36a8 feat: update static_files mod import 2024-06-04 19:05:58 +02:00
Valerio Ageno 8dd8123fb1 feat: update version to v0.0.7 2024-06-04 18:59:33 +02:00
Valerio Ageno 7040fbfb3d feat: copy readme on pipeline 2024-06-04 18:58:24 +02:00
Valerio Ageno ee2abbd4e1 feat: tuono new command 2024-06-04 18:55:15 +02:00
Valerio Ageno 252c463300 feat: resize README logo 2024-06-03 20:59:39 +02:00
Valerio Ageno a10f205ccd feat: create tuono main page 2024-06-02 21:24:43 +02:00
Valerio Ageno 7cfaf94146 feat: enable dynamic routes on frontend 2024-06-02 20:32:53 +02:00
Valerio Ageno 7557df10f3 feat: enable dynamic routes on frontend 2024-06-02 12:14:56 +02:00
Valerio Ageno 64c37f42c5 feat: handle dynamic routes creation 2024-05-31 20:51:24 +02:00
Valerio Ageno 00594b7c3a feat: pass reqwest client to handler 2024-05-30 21:03:25 +02:00
Valerio Ageno 930e7bc851 feat: filter reload trigger files 2024-05-30 21:03:09 +02:00
Valerio Ageno d7d0001299 feat: add TuonoProps type 2024-05-29 21:54:21 +02:00
Valerio Ageno 1c0b4bba04 feat: update source builder 2024-05-29 21:31:28 +02:00
Valerio Ageno c12739d526 feat: create base tuono example 2024-05-26 19:05:19 +02:00
Valerio Ageno 0780a6ec79 feat: prevent empty .tuono folder 2024-05-26 18:44:15 +02:00
Valerio Ageno 73e142a5d7 feat: allow passing zero arguments to tuono CLI 2024-05-26 18:26:36 +02:00
Valerio Ageno d3cee9f1b2 feat: update ssr_rs crate to v0.5.2 2024-05-25 18:04:43 +02:00
Valerio Ageno e6370cb7d4 feat: add tuono/README.md as root README symlink 2024-05-25 18:02:21 +02:00
Valerio Ageno e2e6e313a5 feat: update version to v0.0.6 2024-05-25 18:01:09 +02:00
Valerio Ageno 2348576fa4 feat: generate main file from tuono dev cmd 2024-05-25 17:59:27 +02:00
Valerio Ageno 9550f8243c chore: remove outdated READMEs 2024-05-25 15:03:31 +02:00
Valerio Ageno 1d87a78393 chore: merge packages into tuono 2024-05-25 14:56:55 +02:00
Valerio Ageno ee092194d1 fix: change version to v0.0.5 2024-05-25 12:15:49 +02:00
Valerio Ageno 47a02cc572 fix: add tuono_lib_macro crate 2024-05-25 12:07:19 +02:00
Valerio Ageno 1849c49682 fix: crates mandatory fields 2024-05-25 11:51:26 +02:00
Valerio Ageno 9f071d9e13 fix: add path to CD pipeline 2024-05-25 11:42:49 +02:00
Valerio Ageno b96fdc5c4e feat: update crates/package versions 2024-05-25 11:31:38 +02:00
Valerio Ageno 89ffcf0923 CD: prepare for tuono crate release 2024-05-25 11:29:11 +02:00
Valerio Ageno b91210084f chore: MIT license 2024-05-25 10:23:19 +02:00
Valerio Ageno cfdd59f5fb fix: SSR bug on pages with no SSR handler 2024-05-23 22:44:57 +02:00
Valerio Ageno 5a30f36eb0 feat: scaffold build script 2024-05-23 22:32:43 +02:00
Valerio Ageno 322908145e feat: create dev scripts 2024-05-23 22:10:46 +02:00
Valerio Ageno ec2fb17f7f feat: handle server side props on the client 2024-05-23 20:41:05 +02:00
Valerio Ageno 9a0bb45a03 feat: create server side api handler 2024-05-20 21:07:37 +02:00
Valerio Ageno 04216d93bf feat: re-build ssr js on rs change 2024-05-19 20:44:45 +02:00
Valerio Ageno 4ed6c9902f feat: allow any serializable object as response 2024-05-19 20:32:19 +02:00
Valerio Ageno 208c18c94a feat: create base payload, request and response data structures 2024-05-19 12:22:56 +02:00
Valerio Ageno d4c9b25b90 feat: create route handler macro 2024-05-18 20:45:35 +02:00
Valerio Ageno 74968b27cc feat: update README.md 2024-05-18 11:51:27 +02:00
Valerio Ageno db98e0e2fd feat: create dev environment 2024-05-18 11:47:17 +02:00
Valerio Ageno fc1d9013f8 feat: set zustand store server side 2024-05-15 20:42:16 +02:00
Valerio Ageno da75dc4a07 feat: enable server side route matching 2024-05-14 21:23:32 +02:00
Valerio Ageno 1ef1a852fc feat: add support for static files 2024-05-14 20:42:32 +02:00
Valerio Ageno aa5d4b689d feat: create axum_bundler 2024-05-14 20:07:24 +02:00
Valerio Ageno 65f43b3caf feat: spawn vite from tuono crate 2024-05-13 21:11:22 +02:00
Valerio Ageno 6ef526be73 feat: update README 2024-05-12 21:52:04 +02:00
Valerio Ageno c55fe7dde4 feat: create cargo workspace 2024-05-12 21:40:46 +02:00
Valerio Ageno 6d994a1960 feat: check project bundle 2024-05-12 21:08:07 +02:00
Valerio Ageno b557820b6e feat: enable layout element 2024-05-12 17:13:41 +02:00
Valerio Ageno 4e1d0b6252 feat: initialize router on landing 2024-05-11 16:22:15 +02:00
Valerio Ageno 90de047a07 feat: handle custom not found error 2024-05-11 16:08:51 +02:00
Valerio Ageno ef98cfed90 feat: check rust handler in client component 2024-05-11 15:55:26 +02:00
Valerio Ageno b21d2eddc0 chore: add comment to routes-generator 2024-05-11 15:49:37 +02:00
Valerio Ageno 4a8a24c708 feat: detect rust handler file 2024-05-11 15:27:51 +02:00
Valerio Ageno 73c94e5fbb feat: create basic router 2024-05-11 15:08:49 +02:00
Valerio Ageno df80756c44 doc: add React Server Component onto the README 2024-05-05 09:46:37 +02:00
Valerio Ageno 3830f18e2d doc: update readme 2024-05-02 20:55:01 +02:00
Valerio Ageno 289986b4af doc: add tuono pronunciation to README 2024-05-02 20:52:05 +02:00
111 changed files with 2449 additions and 599 deletions
+1 -1
View File
@@ -97,7 +97,7 @@
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-shadow": "error",
"no-undef": "error",
"no-undef": "off",
"sort-imports": "off",
},
}
-36
View File
@@ -1,36 +0,0 @@
name: Publish package to npm
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install dependencies
run: pnpm install
- name: Install turbo
run: pnpm install turbo --global
- name: Build
run: pnpm build
- name: Publish
run: pnpm publish --no-git-checks --filter ./packages/tuono
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+103
View File
@@ -0,0 +1,103 @@
name: Publish on npm and crates
on:
release:
types: [published]
jobs:
publish-on-npm-dry-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Install dependencies
run: pnpm install
- name: Install turbo
run: pnpm install turbo --global
- name: Build
run: pnpm build
- name: Publish
run: pnpm publish --no-git-checks --dry-run --filter ./packages/tuono
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-on-crates-dry-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- uses: katyo/publish-crates@v2
with:
path: './crates/tuono'
dry-run: true
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
check-repo: true
publish-on-npm:
needs: [publish-on-crates-dry-run, publish-on-npm-dry-run]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Clone root README.md on tuono package
run: cp README.md packages/tuono/README.md
- name: Install dependencies
run: pnpm install
- name: Install turbo
run: pnpm install turbo --global
- name: Build
run: pnpm build
- name: Publish
run: pnpm publish --no-git-checks --filter ./packages/tuono
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-on-crates:
needs: [publish-on-crates-dry-run, publish-on-npm-dry-run]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- uses: katyo/publish-crates@v2
with:
path: './crates/tuono'
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
check-repo: true
+16
View File
@@ -8,3 +8,19 @@ pnpm-lock.yaml
examples/playground/
## Rust related ignores
# Generated by Cargo
# will have compiled files and executables
**/debug/
**/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
**/Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
+11
View File
@@ -0,0 +1,11 @@
[workspace]
resolver = "2"
members = [
"crates/tuono",
"crates/tuono_lib",
"crates/tuono_lib_macros",
]
exclude = [
"examples/playground",
"examples/tuono"
]
+7
View File
@@ -0,0 +1,7 @@
Copyright 2024 Valerio Ageno
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+39 -3
View File
@@ -1,13 +1,39 @@
# Tuono.js
<p align="center">
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px">
</p>
<h1 align="center">Tuono<br>The react/rust fullstack framework</h1>
<p align="center">
⚠️ This project is under heavy development. API might drastically change ⚠️
</p>
The fullstack react/rust framework.
> This project is under heavy development. API might drastically change!
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
Why Tuono? Just a badass name.
## Installation
```
cargo install tuono
```
## Create a new project
```
tuono new
```
## Development
```
tuono dev
```
## Features
- Rust based server side rendering
- FS routing
- CSS modules
- Hot Module Reload
- Multi thread backend
## Folder structure
@@ -18,3 +44,13 @@ The fullstack react/rust framework.
| - routes/
| | - api/
```
```tsx
// Data is passed by the loading function
const IndexPage = ({data, isLoading, isError}) => <h1>Index Page</h1>
export const Route = createFileRoute({
loader: (params) => fetchApi(params),
component: IndexPage
})
```
-24
View File
@@ -1,24 +0,0 @@
# Repository structure doc
## Monorepo tool
- [Turborepo](https://turbo.build/repo)
## Packages
> Highly inspired by [@tanstack/router](https://tanstack.com/router/latest)
- vite-fs-router
- router-generator
- react-router
- react-router-server
- tuono-runtime (entry point)
### JS Public interface
- tuono-runtime (all in one js framework package)
## Crates
- tuono (cli interface)
- tuono-axum-builder (axum project builder)
- tuono-watch (folder watch reload)
### Rust Public interface
- tuono (cli, dev and prod environment)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+32
View File
@@ -0,0 +1,32 @@
[package]
name = "tuono"
version = "0.0.10"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
homepage = "https://github.com/Valerioageno/tuono"
readme = "../../README.md"
license-file = "../../LICENSE.md"
categories = ["web-programming"]
include = [
"src/*.rs",
"Cargo.toml"
]
[lib]
name = "tuono"
path = "src/lib.rs"
[dependencies]
clap = { version = "4.5.4", features = ["derive", "cargo"] }
watchexec = "4.0.0"
miette = "7.2.0"
watchexec-signals = "3.0.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0.202", features = ["derive"] }
watchexec-supervisor = "2.0.0"
glob = "0.3.1"
regex = "1.10.4"
reqwest = {version = "0.12.4", features =["blocking", "json"]}
serde_json = "1.0"
+55
View File
@@ -0,0 +1,55 @@
use clap::{Parser, Subcommand};
use std::process::Command;
mod source_builder;
use source_builder::{bundle_axum_source, create_client_entry_files};
use crate::source_builder::check_tuono_folder;
mod scaffold_project;
mod watch;
#[derive(Subcommand, Debug)]
enum Actions {
/// Start the development environment
Dev,
/// Build the production assets
Build,
/// Scaffold a new project
New { folder_name: Option<String> },
}
#[derive(Parser, Debug)]
#[command(version, about = "The react/rust fullstack framework")]
struct Args {
#[command(subcommand)]
action: Actions,
}
fn init_tuono_folder() -> std::io::Result<()> {
check_tuono_folder()?;
bundle_axum_source()?;
create_client_entry_files()?;
Ok(())
}
pub fn cli() -> std::io::Result<()> {
let args = Args::parse();
match args.action {
Actions::Dev => {
init_tuono_folder()?;
watch::watch().unwrap();
}
Actions::Build => {
init_tuono_folder()?;
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
let _ = &vite_build.output()?;
}
Actions::New { folder_name } => {
scaffold_project::create_new_project(folder_name);
}
}
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
use tuono::cli;
fn main() {
cli().unwrap();
}
+165
View File
@@ -0,0 +1,165 @@
use clap::crate_version;
use reqwest::blocking;
use serde::Deserialize;
use std::env;
use std::fs::{self, create_dir, File, OpenOptions};
use std::io::{self, prelude::*};
use std::path::{Path, PathBuf};
const GITHUB_TUONO_REPO_URL: &str =
"https://api.github.com/repos/Valerioageno/tuono/git/trees/main?recursive=1";
const GITHUB_RAW_CONTENT_URL: &str = "https://raw.githubusercontent.com/Valerioageno/tuono/main/";
#[derive(Deserialize, Debug)]
enum GithubFileType {
#[serde(rename = "blob")]
Blob,
#[serde(rename = "tree")]
Tree,
}
#[derive(Deserialize, Debug)]
struct GithubResponse<T> {
tree: Vec<T>,
}
#[derive(Deserialize, Debug)]
struct GithubFile {
path: String,
#[serde(rename(deserialize = "type"))]
element_type: GithubFileType,
}
fn create_file(path: PathBuf, content: String) -> std::io::Result<()> {
let mut file = File::create(path)?;
let _ = file.write_all(content.as_bytes());
Ok(())
}
pub fn create_new_project(folder_name: Option<String>) {
let folder = folder_name.unwrap_or(".".to_string());
if folder != "." {
create_dir(&folder).unwrap();
}
let client = blocking::Client::builder()
.user_agent("")
.build()
.expect("Failed to build reqwest client");
let res = client
.get(GITHUB_TUONO_REPO_URL)
.send()
.expect("Failed to call the folder github API")
.json::<GithubResponse<GithubFile>>()
.expect("Failed to parse the repo structure");
let new_project_files = res
.tree
.iter()
.filter(|GithubFile { path, .. }| {
// TODO: Handle custom example download by CLI argument --template
if path.starts_with("examples/tuono/") {
return true;
}
false
})
.collect::<Vec<&GithubFile>>();
let folder_name = PathBuf::from(&folder);
let current_dir = env::current_dir().expect("Failed to get current working directory");
let folder_path = current_dir.join(folder_name);
create_directories(&new_project_files, &folder_path).expect("Failed to create directories");
for GithubFile {
element_type, path, ..
} in new_project_files.iter()
{
if let GithubFileType::Blob = element_type {
let file_content = client
.get(format!("{GITHUB_RAW_CONTENT_URL}{path}"))
.send()
.expect("Failed to call the folder github API")
.text()
.expect("Failed to parse the repo structure");
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
let file_path = folder_path.join(&path);
create_file(file_path, file_content).expect("failed to create file");
}
}
update_package_json_version(&folder_path).expect("Failed to update package.json version");
update_cargo_toml_version(&folder_path).expect("Failed to update Cargo.toml version");
outro(folder);
}
fn create_directories(new_project_files: &Vec<&GithubFile>, folder_path: &Path) -> io::Result<()> {
for GithubFile {
element_type, path, ..
} in new_project_files.iter()
{
if let GithubFileType::Tree = element_type {
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
let dir_path = folder_path.join(&path);
create_dir(&dir_path).unwrap();
}
}
Ok(())
}
fn update_package_json_version(folder_path: &Path) -> io::Result<()> {
let v = crate_version!();
let package_json_path = folder_path.join(PathBuf::from("package.json"));
let package_json = fs::read_to_string(&package_json_path)?;
let package_json = package_json.replace("workspace:*", v);
let mut file = OpenOptions::new()
.write(true)
.truncate(true)
.open(package_json_path)?;
file.write_all(package_json.as_bytes())?;
Ok(())
}
fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
let v = crate_version!();
let cargo_toml_path = folder_path.join(PathBuf::from("Cargo.toml"));
let cargo_toml = fs::read_to_string(&cargo_toml_path)?;
let cargo_toml =
cargo_toml.replace("{ path = \"../../crates/tuono_lib/\"}", &format!("\"{v}\""));
let mut file = OpenOptions::new()
.write(true)
.truncate(true)
.open(cargo_toml_path)?;
file.write_all(cargo_toml.as_bytes())?;
Ok(())
}
fn outro(folder_name: String) {
println!("Success! 🎉");
if folder_name != "." {
println!("\nGo to the project directory:");
println!("cd {folder_name}/");
}
println!("\nInstall the dependencies:");
println!("pnpm install");
println!("\nRun the local environment:");
println!("tuono dev");
}
+360
View File
@@ -0,0 +1,360 @@
use glob::glob;
use glob::GlobError;
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::io;
use std::io::prelude::*;
use std::path::Path;
use std::path::PathBuf;
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
// Do not manually update this file
import { routeTree } from './routeTree.gen'
import { serverSideRendering } from 'tuono/ssr'
export const renderFn = serverSideRendering(routeTree)
";
pub const CLIENT_ENTRY_DATA: &str = "// File automatically generated by tuono
// Do not manually update this file
import 'vite/modulepreload-polyfill'
import { hydrate } from 'tuono/hydration'
import '../src/styles/global.css'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
hydrate(routeTree)
";
pub const AXUM_ENTRY_POINT: &str = r##"
// File automatically generated
// Do not manually change it
use axum::extract::{Path, Request};
use axum::response::Html;
use axum::{routing::get, Router};
use tower_http::services::ServeDir;
use std::collections::HashMap;
use tuono_lib::{ssr, Ssr};
use reqwest::Client;
// MODULE_IMPORTS
#[tokio::main]
async fn main() {
Ssr::create_platform();
let fetch = Client::new();
let app = Router::new()
// ROUTE_BUILDER
.fallback_service(ServeDir::new("public").fallback(get(catch_all)))
.with_state(fetch);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn catch_all(Path(params): Path<HashMap<String, String>>, request: Request) -> Html<String> {
let pathname = &request.uri();
let headers = &request.headers();
let req = tuono_lib::Request::new(pathname, headers, params);
// TODO: remove unwrap
let payload = tuono_lib::Payload::new(&req, Box::new(""))
.client_payload()
.unwrap();
let result = ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)));
match result {
Ok(html) => Html(html),
_ => Html("500 internal server error".to_string()),
}
}
"##;
const ROOT_FOLDER: &str = "src/routes";
const DEV_FOLDER: &str = ".tuono";
pub enum Mode {
Prod,
Dev,
}
#[derive(Debug, PartialEq, Eq)]
struct Route {
/// Every module import is the path with a _ instead of /
pub module_import: String,
pub axum_route: String,
}
fn has_dynamic_path(route: &str) -> bool {
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
regex.is_match(route)
}
impl Route {
pub fn new(path: &str) -> Self {
let route_name = path.replace(".rs", "");
// Remove first slash
let mut module = route_name.as_str().chars();
module.next();
let axum_route = path.replace("/index.rs", "").replace(".rs", "");
if axum_route.is_empty() {
return Route {
module_import: module.as_str().to_string().replace('/', "_"),
axum_route: "/".to_string(),
};
}
if has_dynamic_path(&route_name) {
return Route {
module_import: module
.as_str()
.to_string()
.replace('/', "_")
.replace('[', "dyn_")
.replace(']', ""),
axum_route: axum_route.replace('[', ":").replace(']', ""),
};
}
Route {
module_import: module.as_str().to_string().replace('/', "_"),
axum_route,
}
}
}
struct SourceBuilder {
route_map: HashMap<PathBuf, Route>,
mode: Mode,
base_path: PathBuf,
}
impl SourceBuilder {
pub fn new(mode: Mode) -> Self {
let base_path = std::env::current_dir().unwrap();
SourceBuilder {
route_map: HashMap::new(),
mode,
base_path,
}
}
fn collect_routes(&mut self) {
glob(self.base_path.join("src/routes/**/*.rs").to_str().unwrap())
.unwrap()
.for_each(|entry| self.collect_route(entry))
}
fn collect_route(&mut self, path_buf: Result<PathBuf, GlobError>) {
let entry = path_buf.unwrap();
let base_path_str = self.base_path.to_str().unwrap();
let path = entry
.to_str()
.unwrap()
.replace(&format!("{base_path_str}/src/routes"), "");
let route = Route::new(&path);
self.route_map.insert(PathBuf::from(path), route);
}
}
fn create_main_file(base_path: &Path, bundled_file: &String) {
let mut data_file =
fs::File::create(base_path.join(".tuono/main.rs")).expect("creation failed");
data_file
.write_all(bundled_file.as_bytes())
.expect("write failed");
}
fn create_routes_declaration(routes: &HashMap<PathBuf, Route>) -> String {
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
for (_, route) in routes.iter() {
let Route {
axum_route,
module_import,
} = &route;
route_declarations.push_str(&format!(
r#".route("{axum_route}", get({module_import}::route))"#
));
route_declarations.push_str(&format!(
r#".route("/__tuono/data{axum_route}", get({module_import}::api))"#
));
}
route_declarations
}
fn create_modules_declaration(routes: &HashMap<PathBuf, Route>) -> String {
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
for (path, route) in routes.iter() {
let module_name = &route.module_import;
let path_str = path.to_str().unwrap();
route_declarations.push_str(&format!(
r#"#[path="../{ROOT_FOLDER}{path_str}"]
mod {module_name};
"#
))
}
route_declarations
}
pub fn bundle_axum_source() -> io::Result<()> {
println!("Axum project bundling");
let base_path = std::env::current_dir().unwrap();
let mut source_builder = SourceBuilder::new(Mode::Dev);
source_builder.collect_routes();
let bundled_file = AXUM_ENTRY_POINT
.replace(
"// ROUTE_BUILDER\n",
&create_routes_declaration(&source_builder.route_map),
)
.replace(
"// MODULE_IMPORTS\n",
&create_modules_declaration(&source_builder.route_map),
);
create_main_file(&base_path, &bundled_file);
Ok(())
}
pub fn check_tuono_folder() -> io::Result<()> {
let dev_folder = Path::new(DEV_FOLDER);
if !&dev_folder.is_dir() {
println!("exists");
fs::create_dir(dev_folder)?;
}
Ok(())
}
pub fn create_client_entry_files() -> io::Result<()> {
let dev_folder = Path::new(DEV_FOLDER);
let mut server_entry = fs::File::create(dev_folder.join("server-main.tsx"))?;
let mut client_entry = fs::File::create(dev_folder.join("client-main.tsx"))?;
server_entry.write_all(SERVER_ENTRY_DATA.as_bytes())?;
client_entry.write_all(CLIENT_ENTRY_DATA.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_dynamic_paths() {
let routes = [
("/home/user/Documents/tuono/src/routes/about.rs", false),
("/home/user/Documents/tuono/src/routes/index.rs", false),
(
"/home/user/Documents/tuono/src/routes/posts/index.rs",
false,
),
(
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
true,
),
];
routes
.into_iter()
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
}
#[test]
fn collect_routes() {
let mut source_builder = SourceBuilder::new(Mode::Dev);
source_builder.base_path = "/home/user/Documents/tuono".into();
let routes = [
"/home/user/Documents/tuono/src/routes/about.rs",
"/home/user/Documents/tuono/src/routes/index.rs",
"/home/user/Documents/tuono/src/routes/posts/index.rs",
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
];
routes
.into_iter()
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
let results = [
("/index.rs", "index"),
("/about.rs", "about"),
("/posts/index.rs", "posts_index"),
("/posts/[post].rs", "posts_dyn_post"),
];
results.into_iter().for_each(|(path, module_import)| {
assert_eq!(
source_builder
.route_map
.get(&PathBuf::from(path))
.unwrap()
.module_import,
String::from(module_import)
)
})
}
#[test]
fn create_multi_level_axum_paths() {
let mut source_builder = SourceBuilder::new(Mode::Dev);
source_builder.base_path = "/home/user/Documents/tuono".into();
let routes = [
"/home/user/Documents/tuono/src/routes/about.rs",
"/home/user/Documents/tuono/src/routes/index.rs",
"/home/user/Documents/tuono/src/routes/posts/index.rs",
"/home/user/Documents/tuono/src/routes/posts/any-post.rs",
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
];
routes
.into_iter()
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
let results = [
("/index.rs", "/"),
("/about.rs", "/about"),
("/posts/index.rs", "/posts"),
("/posts/any-post.rs", "/posts/any-post"),
("/posts/[post].rs", "/posts/:post"),
];
results.into_iter().for_each(|(path, expected_path)| {
assert_eq!(
source_builder
.route_map
.get(&PathBuf::from(path))
.unwrap()
.axum_route,
String::from(expected_path)
)
})
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::sync::Arc;
use watchexec_supervisor::command::{Command, Program};
use miette::{IntoDiagnostic, Result};
use watchexec::Watchexec;
use watchexec_signals::Signal;
use watchexec_supervisor::job::start_job;
use crate::source_builder::bundle_axum_source;
// What is the development pipeline?
//
// 1. Any file gets updates (rs/js/ts)
// 2. Client side vite works separately
// 3. Stop the dev server
// 4. Build the ssr client bundle
// 5. Restart the server
//
//
// The current solution is not optimized
// - We should avoid to bundle static lib (i.e. react) in the main bundle
#[tokio::main]
pub async fn watch() -> Result<()> {
let (watch_client, _) = start_job(Arc::new(Command {
program: Program::Exec {
prog: "node_modules/.bin/tuono-dev-watch".into(),
args: vec![],
},
options: Default::default(),
}));
watch_client.start().await;
let (run_server, _) = start_job(Arc::new(Command {
program: Program::Exec {
prog: "cargo".into(),
args: vec!["run".to_string()],
},
options: Default::default(),
}));
let (build_ssr_bundle, _) = start_job(Arc::new(Command {
program: Program::Exec {
prog: "node_modules/.bin/tuono-dev-ssr".into(),
args: vec![],
},
options: Default::default(),
}));
build_ssr_bundle.start().await;
run_server.start().await;
let wx = Watchexec::new(move |mut action| {
for event in action.events.iter() {
for path in event.paths() {
if path.0.to_string_lossy().ends_with(".rs")
|| path.0.to_string_lossy().ends_with("sx")
{
run_server.stop();
println!("Reloading server...");
build_ssr_bundle.stop();
build_ssr_bundle.start();
bundle_axum_source();
run_server.start();
}
}
}
// if Ctrl-C is received, quit
if action.signals().any(|sig| sig == Signal::Interrupt) {
action.quit();
}
action
})?;
// watch the current directory
wx.config.pathset(["./src"]);
let _ = wx.main().await.into_diagnostic()?;
Ok(())
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "tuono_lib"
version = "0.0.10"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
homepage = "https://github.com/Valerioageno/tuono"
readme = "../../README.md"
license-file = "../../LICENSE.md"
categories = ["web-programming"]
include = [
"src/*.rs",
"Cargo.toml"
]
[lib]
name = "tuono_lib"
path = "src/lib.rs"
[dependencies]
ssr_rs = "0.5.2"
axum = "0.7.5"
serde = { version = "1.0.202", features = ["derive"] }
erased-serde = "0.4.5"
serde_json = "1.0"
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.0.10"}
+3
View File
@@ -0,0 +1,3 @@
# tuono_lib
This project exposes the interfaces to easily handle the backend build.
+11
View File
@@ -0,0 +1,11 @@
mod payload;
mod request;
mod response;
pub mod ssr;
pub use payload::Payload;
pub use request::Request;
pub use response::Response;
pub use ssr_rs::Ssr;
pub use tuono_lib_macros::handler;
+25
View File
@@ -0,0 +1,25 @@
use erased_serde::Serialize;
use serde::Serialize as SerdeSerialize;
use crate::request::Location;
use crate::Request;
#[derive(SerdeSerialize)]
/// This is the data shared to the client
pub struct Payload<'a> {
router: Location<'a>,
props: Box<dyn Serialize>,
}
impl<'a> Payload<'a> {
pub fn new(req: &Request<'a>, props: Box<dyn Serialize>) -> Payload<'a> {
Payload {
router: req.location(),
props,
}
}
pub fn client_payload(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self)
}
}
+47
View File
@@ -0,0 +1,47 @@
use serde::Serialize;
use std::collections::HashMap;
use axum::http::{HeaderMap, Uri};
#[derive(Debug, Clone)]
pub struct Request<'a> {
uri: &'a Uri,
headers: &'a HeaderMap,
pub params: HashMap<String, String>,
}
/// Location must match client side interface
#[derive(Serialize, Debug)]
pub struct Location<'a> {
href: String,
pathname: &'a str,
search: HashMap<String, String>,
search_str: &'a str,
/// Server does not need it. Will be hanlder client side
hash: &'a str,
}
impl<'a> Request<'a> {
pub fn new(
uri: &'a Uri,
headers: &'a HeaderMap,
params: HashMap<String, String>,
) -> Request<'a> {
Request {
uri,
headers,
params,
}
}
pub fn location(&self) -> Location<'a> {
Location {
href: self.uri.to_string(),
pathname: &self.uri.path(),
// TODO: hanler search map
search: HashMap::new(),
search_str: &self.uri.query().unwrap_or(""),
hash: "",
}
}
}
+6
View File
@@ -0,0 +1,6 @@
use erased_serde::Serialize;
pub enum Response {
Redirect(String),
Props(Box<dyn Serialize>),
}
+17
View File
@@ -0,0 +1,17 @@
use ssr_rs::Ssr;
use std::cell::RefCell;
use std::fs::read_to_string;
pub struct Js;
impl Js {
thread_local! {
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
// TODO: handle here dev/prod source
Ssr::from(
read_to_string("./.tuono/server/dev-server.js").unwrap(),
""
).unwrap()
)
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "tuono_lib_macros"
version = "0.0.10"
edition = "2021"
description = "The react/rust fullstack framework"
homepage = "https://github.com/Valerioageno/tuono"
readme = "../../README.md"
license-file = "../../LICENSE.md"
categories = ["web-programming"]
include = [
"src/*.rs",
"Cargo.toml"
]
[lib]
crate_type = ["proc-macro"]
proc-marco = true
[dependencies]
syn = { version = "1.0.59", features = ["full"] }
quote = "1.0"
+3
View File
@@ -0,0 +1,3 @@
# tuono_lib_macros
Macros for tuono_lib
+67
View File
@@ -0,0 +1,67 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
let item = parse_macro_input!(item as ItemFn);
let fn_name = item.clone().sig.ident;
quote! {
use axum::response::{Html, IntoResponse};
use std::collections::HashMap;
use axum::extract::{State, Path};
use reqwest::Client;
#item
pub async fn route(
Path(params): Path<HashMap<String, String>>,
State(client): State<Client>,
request: axum::extract::Request
) -> Html<String> {
let pathname = &request.uri();
let headers = &request.headers();
let req = tuono_lib::Request::new(pathname, headers, params);
let local_response = #fn_name(req.clone(), client).await;
let res = match local_response {
tuono_lib::Response::Props(val) => {
// TODO: remove unwrap
let payload = tuono_lib::Payload::new(&req, val).client_payload().unwrap();
tuono_lib::ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)))
},
/// TODO: handle here redirection and rewrite
_ => Ok("500 Internal server error".to_string())
};
match res {
Ok(html) => Html(html),
_ => Html("500 internal server error".to_string())
}
}
pub async fn api(
Path(params): Path<HashMap<String, String>>,
State(client): State<Client>,
request: axum::extract::Request
) -> axum::response::Response {
let pathname = &request.uri();
let headers = &request.headers();
let req = tuono_lib::Request::new(pathname, headers, params);
let local_response = #fn_name(req.clone(), client).await;
let res = match local_response{
tuono_lib::Response::Props(val) => return axum::Json(val).into_response(),
_ => return axum::Json("").into_response()
};
}
}
.into()
}
+11
View File
@@ -0,0 +1,11 @@
extern crate proc_macro;
use proc_macro::TokenStream;
mod handler;
#[proc_macro_attribute]
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
handler::handler_core(args, item)
}
+13
View File
@@ -0,0 +1,13 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.tuono
out
target
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tuono"
version = "0.0.1"
edition = "2021"
[[bin]]
name = "tuono"
path = ".tuono/main.rs"
[dependencies]
axum = {version = "0.7.5", features = ["json"]}
tokio = { version = "1.37.0", features = ["full"] }
tower-http = {version = "0.5.2", features = ["fs"]}
serde = { version = "1.0.202", features = ["derive"] }
tuono_lib = { path = "../../crates/tuono_lib/"}
serde_json = "1.0"
reqwest = {version = "0.12.4", features = ["json"]}
+2
View File
@@ -1 +1,3 @@
# TODO: Basic Tuono app
+14
View File
@@ -0,0 +1,14 @@
{
"name": "tuono",
"version": "0.0.1",
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"tuono": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.4.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="82" height="73" viewBox="0 0 82 73" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M81.337 36.6169C81.337 31.245 74.5945 26.1541 64.2571 22.9971C66.6426 12.4847 65.5824 4.1211 60.9107 1.44343C59.8339 0.815331 58.5748 0.517811 57.1998 0.517811V4.20375C57.9619 4.20375 58.5748 4.35251 59.0884 4.6335C61.3414 5.92275 62.3188 10.8318 61.5568 17.1458C61.3745 18.6996 61.0763 20.3359 60.7119 22.0053C57.4649 21.212 53.9197 20.6004 50.1923 20.2037C47.9558 17.1459 45.6365 14.369 43.3007 11.9393C48.7013 6.93101 53.7706 4.18722 57.2164 4.18722V0.501282C52.6607 0.501282 46.6967 3.74094 40.6666 9.36076C34.6364 3.774 28.6726 0.567398 24.1168 0.567398V4.25333C27.5461 4.25333 32.6319 6.9806 38.0326 11.9558C35.7133 14.3855 33.394 17.1459 31.1907 20.2037C27.4467 20.6004 23.9015 21.212 20.6545 22.0219C20.2735 20.369 19.9918 18.7657 19.793 17.2285C19.0144 10.9145 19.9752 6.0054 22.2117 4.69962C22.7087 4.4021 23.3548 4.26987 24.1168 4.26987V0.583927C22.7252 0.583927 21.4662 0.881447 20.3728 1.50954C15.7177 4.18722 14.674 12.5343 17.0761 23.0136C6.77188 26.1871 0.0625 31.2615 0.0625 36.6169C0.0625 41.9887 6.805 47.0796 17.1424 50.2366C14.7568 60.749 15.8171 69.1126 20.4888 71.7903C21.5656 72.4184 22.8246 72.7159 24.2162 72.7159C28.772 72.7159 34.7359 69.4762 40.766 63.8564C46.7962 69.4432 52.76 72.6498 57.3158 72.6498C58.7073 72.6498 59.9664 72.3523 61.0598 71.7242C65.7149 69.0465 66.7586 60.6994 64.3565 50.2201C74.6276 47.0631 81.337 41.9722 81.337 36.6169ZM59.7676 25.5921C59.1547 27.7243 58.3926 29.9227 57.5311 32.121C56.8519 30.7987 56.1396 29.4764 55.361 28.1541C54.5989 26.8318 53.7872 25.5425 52.9754 24.2863C55.3278 24.6334 57.5974 25.0632 59.7676 25.5921ZM52.1802 43.1953C50.888 45.4267 49.5628 47.5424 48.1877 49.5094C45.7194 49.7243 43.2178 49.84 40.6997 49.84C38.1982 49.84 35.6967 49.7242 33.2449 49.5259C31.8699 47.559 30.528 45.4598 29.2358 43.2449C27.9768 41.0797 26.8337 38.8813 25.79 36.6664C26.8172 34.4516 27.9768 32.2367 29.2193 30.0714C30.5114 27.84 31.8368 25.7243 33.2118 23.7574C35.6801 23.5425 38.1817 23.4268 40.6997 23.4268C43.2013 23.4268 45.7028 23.5425 48.1546 23.7409C49.5296 25.7078 50.8715 27.807 52.1637 30.0218C53.4227 32.1871 54.5658 34.3855 55.6094 36.6003C54.5658 38.8152 53.4227 41.0301 52.1802 43.1953ZM57.5311 41.0466C58.4257 43.2615 59.1878 45.4763 59.8173 47.6251C57.6471 48.154 55.361 48.6003 52.992 48.9474C53.8037 47.6747 54.6155 46.3689 55.3775 45.0301C56.1396 43.7077 56.8519 42.3689 57.5311 41.0466ZM40.7329 58.6829C39.1922 57.0961 37.6515 55.3275 36.1274 53.3937C37.6184 53.4598 39.1425 53.5094 40.6832 53.5094C42.2404 53.5094 43.7811 53.4763 45.2886 53.3937C43.7976 55.3275 42.257 57.0961 40.7329 58.6829ZM28.4075 48.9474C26.0551 48.6003 23.7855 48.1705 21.6153 47.6416C22.2283 45.5094 22.9903 43.311 23.8518 41.1127C24.531 42.435 25.2434 43.7573 26.022 45.0796C26.8006 46.402 27.5958 47.6912 28.4075 48.9474ZM40.6501 14.5508C42.1907 16.1376 43.7314 17.9062 45.2555 19.8401C43.7645 19.7739 42.2404 19.7244 40.6997 19.7244C39.1425 19.7244 37.6018 19.7574 36.0943 19.8401C37.5853 17.9062 39.126 16.1376 40.6501 14.5508ZM28.391 24.2863C27.5792 25.559 26.7675 26.8648 26.0054 28.2037C25.2433 29.526 24.531 30.8483 23.8518 32.1706C22.9572 29.9557 22.1951 27.7409 21.5656 25.5921C23.7358 25.0797 26.022 24.6334 28.391 24.2863ZM13.3984 44.9805C7.53392 42.4846 3.74023 39.2119 3.74023 36.6169C3.74023 34.0218 7.53392 30.7326 13.3984 28.2533C14.8231 27.6417 16.3803 27.0962 17.9873 26.5838C18.9316 29.8235 20.1741 33.1954 21.7147 36.6499C20.1906 40.0879 18.9647 43.4433 18.037 46.6664C16.3969 46.154 14.8397 45.592 13.3984 44.9805ZM22.3111 68.6002C20.0581 67.311 19.0807 62.4019 19.8427 56.0879C20.0249 54.5341 20.3231 52.8978 20.6876 51.2284C23.9346 52.0218 27.4798 52.6333 31.2072 53.03C33.4437 56.0879 35.763 58.8647 38.0988 61.2945C32.6982 66.3027 27.6289 69.0465 24.1831 69.0465C23.4376 69.03 22.8081 68.8812 22.3111 68.6002ZM61.6065 56.0052C62.3851 62.3192 61.4242 67.2283 59.1878 68.5341C58.6908 68.8316 58.0447 68.9639 57.2827 68.9639C53.8534 68.9639 48.7675 66.2366 43.3669 61.2614C45.6862 58.8317 48.0055 56.0713 50.2088 53.0135C53.9528 52.6168 57.498 52.0052 60.745 51.1953C61.1261 52.8647 61.4242 54.468 61.6065 56.0052ZM67.9845 44.9805C66.5598 45.592 65.0026 46.1375 63.3956 46.6499C62.4513 43.4102 61.2089 40.0383 59.6682 36.5838C61.1923 33.1458 62.4182 29.7904 63.3459 26.5673C64.986 27.0797 66.5432 27.6417 68.0011 28.2533C73.8656 30.7491 77.6592 34.0218 77.6592 36.6169C77.6427 39.2119 73.849 42.5011 67.9845 44.9805Z" fill="#61DAFB"/>
<path d="M40.6832 44.1706C44.8644 44.1706 48.254 40.7886 48.254 36.6169C48.254 32.4451 44.8644 29.0632 40.6832 29.0632C36.502 29.0632 33.1124 32.4451 33.1124 36.6169C33.1124 40.7886 36.502 44.1706 40.6832 44.1706Z" fill="#61DAFB"/>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

+3
View File
@@ -0,0 +1,3 @@
export default function Button(): JSX.Element {
return <button>Button</button>
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
interface RootRouteProps {
children: ReactNode
}
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
return (
<>
<main className="main">{children}</main>
</>
)
}
+14
View File
@@ -0,0 +1,14 @@
use serde::Serialize;
use tuono_lib::{Request, Response};
#[derive(Serialize)]
struct MyResponse<'a> {
subtitle: &'a str,
}
#[tuono_lib::handler]
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
Response::Props(Box::new(MyResponse {
subtitle: "The react / rust fullstack framework",
}))
}
+41
View File
@@ -0,0 +1,41 @@
import type { TuonoProps } from 'tuono'
type IndexProps = {
subtitle: string
}
export default function IndexPage({
data,
isLoading,
}: TuonoProps<IndexProps>): JSX.Element {
if (isLoading) {
return <h1>Loading...</h1>
}
return (
<>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<div className="subtitle-wrap">
<p className="subtitle">{data?.subtitle}</p>
<a href="https://github.com/Valerioageno/tuono" target="_blank">
Github
</a>
</div>
</>
)
}
+89
View File
@@ -0,0 +1,89 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Poppins', sans-serif;
font-weight: 400;
font-style: normal;
}
body {
background: #fbfbfb;
}
main {
width: 673px;
margin: 20px auto;
}
.header {
display: flex;
gap: 20px;
}
.header a {
color: black;
font-size: 18px;
font-weight: 600;
text-decoration: none;
z-index: 2;
}
.title-wrap {
height: 200px;
}
.title {
position: absolute;
font-size: 200px;
line-height: 200px;
z-index: 0;
letter-spacing: -2px;
margin-left: -8px;
}
.title span {
opacity: 0;
}
.logo {
margin-left: 240px;
position: relative;
top: 25px;
}
.logo img {
position: absolute;
}
.rust {
animation: rotate 6s ease-in-out infinite;
}
.react {
top: 33px;
left: 28px;
animation: rotate 6s linear infinite reverse;
}
.subtitle {
font-size: 30px;
line-height: 30px;
letter-spacing: -1px;
}
.subtitle-wrap {
display: flex;
justify-content: space-between;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+5 -3
View File
@@ -2,9 +2,10 @@
"name": "tuono",
"version": "0.1.0",
"description": "",
"main": "index.js",
"packageManager": "pnpm@8.12.1",
"main": "src/index.js",
"packageManager": "pnpm@8.12.0",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"format": "turbo format",
@@ -17,6 +18,7 @@
"devDependencies": {
"@tanstack/config": "^0.7.0",
"@types/node": "^20.12.7",
"@types/react": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"@vitejs/plugin-react": "^4.2.1",
@@ -28,7 +30,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.2.4",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vite": "^5.2.11",
"vitest": "^1.5.2"
}
}
-6
View File
@@ -1,6 +0,0 @@
# Tuono packages
- `tuono-vite`: Handles the bundling (with chunk splitting)
- `tuono-routes-generator`: Handles the creation of the `routeGen.tree.ts` file
- `tuono-router`: Expose all the Functions and Components related to the routing
- `tuono`: Collect and expose all the public APIs. Is the only published package
-12
View File
@@ -1,12 +0,0 @@
{
"name": "tuono-router",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "MIT"
}
View File
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react",
},
"include": ["src", "tests", "vite.config.ts"],
}
-21
View File
@@ -1,21 +0,0 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
import react from '@vitejs/plugin-react'
const config = defineConfig({
plugins: [react()],
test: {
name: 'react-router',
watch: false,
environment: 'jsdom',
},
})
export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.tsx',
srcDir: './src',
}),
)
@@ -1 +0,0 @@
dist/
-48
View File
@@ -1,48 +0,0 @@
# Router generator
This package handle the creation of the routes.
Basically collects and manages them in a single entry point file.
## Generated route file
Currently the generator file is very similar to `@tanstack/router` one but since it does not need the same
configurability is up to strong updates.
```tsx
// This file is auto-generated by Tuono
import { Route as rootRoute } from './routes/__root'
import { Route as AboutImport } from './routes/about'
import { Route as IndexImport } from './routes/index'
// Create/Update Routes
const AboutRoute = AboutImport.update({
path: '/about',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/about': {
preLoaderRoute: typeof AboutImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
```
@@ -1,38 +0,0 @@
{
"name": "tuono-routes-generator",
"version": "0.1.0",
"description": "",
"scripts": {
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
"test": "vitest --watch=false",
"types": "tsc --noEmit"
},
"keywords": [],
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist",
"src"
],
"dependencies": {},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"author": "Valerio Ageno",
"license": "MIT"
}
@@ -1,41 +0,0 @@
import fs from 'fs/promises'
import { describe, it, expect } from 'vitest'
import { routeGenerator } from '../src'
function makeFolderDir(folder: string) {
return process.cwd() + `/tests/generator/${folder}`
}
async function getRouteTreeFileText(folder: string) {
const dir = makeFolderDir(folder)
return await fs.readFile(dir + '/routeTree.gen.ts', 'utf-8')
}
async function getExpectedRouteTreeFileText(folder: string) {
const dir = makeFolderDir(folder)
const location = dir + '/routeTree.expected.ts'
return await fs.readFile(location, 'utf-8')
}
describe('generator works', async () => {
const folderNames = await fs.readdir(process.cwd() + '/tests/generator')
it.each(folderNames.map((folder) => [folder]))(
'should wire-up the routes for a "%s" tree',
async (folderName) => {
const currentFolder = `${process.cwd()}/tests/generator/${folderName}`
await routeGenerator({
folderName: `${currentFolder}/routes`,
generatedRouteTree: `${currentFolder}/routeTree.gen.ts`,
})
const [expectedRouteTree, generatedRouteTree] = await Promise.all([
getExpectedRouteTreeFileText(folderName),
getRouteTreeFileText(folderName),
])
expect(generatedRouteTree).equal(expectedRouteTree)
},
)
})
@@ -1,61 +0,0 @@
// This file is auto-generated by Tuono
import { Route as rootRoute } from './routes/__root'
import { Route as AboutImport } from './routes/about'
import { Route as IndexImport } from './routes/index'
import { Route as PostsIndexImport } from './routes/posts/index'
import { Route as PostsPostSlugImport } from './routes/posts/post-slug'
// Create/Update Routes
const AboutRoute = AboutImport.update({
path: '/about',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
const PostsIndexRoute = PostsIndexImport.update({
path: '/posts/',
getParentRoute: () => rootRoute,
} as any)
const PostsPostSlugRoute = PostsPostSlugImport.update({
path: '/posts/post-slug',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/about': {
preLoaderRoute: typeof AboutImport
parentRoute: typeof rootRoute
}
'/posts/post-slug': {
preLoaderRoute: typeof PostsPostSlugImport
parentRoute: typeof rootRoute
}
'/posts/': {
preLoaderRoute: typeof PostsIndexImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([
IndexRoute,
AboutRoute,
PostsPostSlugRoute,
PostsIndexRoute,
])
@@ -1,5 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: () => <div>Hello /about!</div>,
})
@@ -1,36 +0,0 @@
// This file is auto-generated by Tuono
import { Route as rootRoute } from './routes/__root'
import { Route as AboutImport } from './routes/about'
import { Route as IndexImport } from './routes/index'
// Create/Update Routes
const AboutRoute = AboutImport.update({
path: '/about',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/about': {
preLoaderRoute: typeof AboutImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "vite.config.ts"]
}
@@ -1,12 +0,0 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
const config = defineConfig({})
export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
)
-2
View File
@@ -1,2 +0,0 @@
dist/
tests/snapshots
-5
View File
@@ -1,5 +0,0 @@
# [Tuono] Vite FS Router
> Strongly inspired by [@tanstack/router/router-vite-plugin](https://github.com/TanStack/router/tree/main/packages/router-vite-plugin)
This is the [vite](https://vitejs.dev/) plugin that automatically handles the file system routing.
-52
View File
@@ -1,52 +0,0 @@
{
"name": "tuono-vite",
"version": "0.1.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
"test": "vitest --typecheck --watch=false",
"types": "tsc --noEmit"
},
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"files": [
"dist",
"src/**"
],
"sideEffects": false,
"keywords": [],
"type": "module",
"author": "Valerio Ageno",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/generator": "^7.23.6",
"@babel/plugin-syntax-jsx": "^7.24.1",
"@babel/plugin-syntax-typescript": "^7.24.1",
"@babel/plugin-transform-react-jsx": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.24.1",
"@babel/template": "^7.24.0",
"@babel/traverse": "^7.24.1",
"@babel/types": "^7.24.0",
"@types/babel__core": "^7.20.5",
"@types/node": "^20.12.7",
"tuono-routes-generator": "workspace:*"
},
"devDependencies": {
"@types/babel-traverse": "^6.25.10"
}
}
-33
View File
@@ -1,33 +0,0 @@
import { readFile, readdir } from 'fs/promises'
import path from 'path'
import { expect, test } from 'vitest'
import { makeCompile, splitFile } from '../src/compiler'
import { SPLIT_PREFIX } from '../src/constants'
async function splitTestFile(opts: { file: string }): Promise<void> {
const code = (
await readFile(path.resolve(__dirname, `./test-files/${opts.file}`))
).toString()
const filename = opts.file.replace(__dirname, '')
const result = await splitFile({
code,
compile: makeCompile({
root: './test-files',
}),
filename: `${filename}?${SPLIT_PREFIX}`,
})
await expect(result.code).toMatchFileSnapshot(
`./snapshots/${filename.replace('.tsx', '')}?split.tsx`,
)
}
test('it compiles and splits', async (): Promise<void> => {
// get the list of files from the /test-files directory
const files = await readdir(path.resolve(__dirname, './test-files'))
for (const file of files) {
await splitTestFile({ file })
}
})
@@ -1,3 +0,0 @@
export const ImportedRoute = (): JSX.Element => {
return <h1>Imported route</h1>
}
@@ -1,3 +0,0 @@
export default function Route(): JSX.Element {
return <h1>Test route</h1>;
}
@@ -1,4 +0,0 @@
function PostsRoute() {
return <h1>Post route</h1>;
}
export default PostsRoute;
@@ -1,2 +0,0 @@
import { ImportedRoute } from '../shared/imported';
export default ImportedRoute;
@@ -1,5 +0,0 @@
import * as React from 'react'
export default function Route(): JSX.Element {
return <h1>Test route</h1>
}
@@ -1,7 +0,0 @@
import * as React from 'react'
function PostsRoute() {
return <h1>Post route</h1>
}
export default PostsRoute
@@ -1,4 +0,0 @@
import * as React from 'react'
import { ImportedRoute } from '../shared/imported'
export default ImportedRoute
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "vite.config.ts", "tests"],
"exclude": ["tests/test-files/**", "tests/snapshots/**"],
"compilerOptions": {
"jsx": "react"
}
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
const config = defineConfig({})
export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
exclude: ['./src/tests/'],
}),
)
-1
View File
@@ -1 +0,0 @@
# Tuono
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { buildProd } from '../dist/esm/build/index.js'
buildProd()
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { developmentSSRBundle } from '../dist/esm/build/index.js'
developmentSSRBundle()
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { developmentCSRWatch } from '../dist/esm/build/index.js'
developmentCSRWatch()
+70 -4
View File
@@ -1,19 +1,50 @@
{
"name": "tuono",
"version": "0.0.1",
"name": "tuono-tuono",
"version": "0.0.10",
"description": "",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
"types": "tsc --noEmit",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest"
},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"exports": {
"./build": {
"import": {
"types": "./dist/esm/build/index.d.ts",
"default": "./dist/esm/build/index.js"
},
"require": {
"types": "./dist/cjs/build/index.d.ts",
"default": "./dist/cjs/build/index.js"
}
},
"./ssr": {
"import": {
"types": "./dist/esm/ssr/index.d.ts",
"default": "./dist/esm/ssr/index.js"
},
"require": {
"types": "./dist/cjs/ssr/index.d.ts",
"default": "./dist/cjs/ssr/index.js"
}
},
"./hydration": {
"import": {
"types": "./dist/esm/hydration/index.d.ts",
"default": "./dist/esm/hydration/index.js"
},
"require": {
"types": "./dist/cjs/ssr/index.d.ts",
"default": "./dist/cjs/ssr/index.js"
}
},
".": {
"import": {
"types": "./dist/esm/index.d.ts",
@@ -26,11 +57,46 @@
},
"./package.json": "./package.json"
},
"bin": {
"tuono-dev-ssr": "./bin/dev-ssr.js",
"tuono-dev-watch": "./bin/watch.js",
"tuono-build-prod": "./bin/build-prod.js"
},
"files": [
"dist",
"src",
"README.md"
"README.md",
"bin/**"
],
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/generator": "^7.23.6",
"@babel/plugin-syntax-jsx": "^7.24.1",
"@babel/plugin-syntax-typescript": "^7.24.1",
"@babel/plugin-transform-react-jsx": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.24.1",
"@babel/template": "^7.24.0",
"@babel/traverse": "^7.24.1",
"@babel/types": "^7.24.0",
"@types/babel__core": "^7.20.5",
"@types/node": "^20.12.7",
"@vitejs/plugin-react-swc": "^3.7.0",
"fast-text-encoding": "^1.0.6",
"prettier": "^3.2.4",
"vite": "^5.2.11",
"zustand": "4.4.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@types/babel-traverse": "^6.25.10",
"jsdom": "^24.0.0",
"vitest": "^1.5.2"
},
"sideEffects": false,
"keywords": [],
"author": "Valerio Ageno",
+96
View File
@@ -0,0 +1,96 @@
import { build, createServer } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { ViteFsRouter } from './tuono-vite-plugin'
const BASE_CONFIG = {
silent: true,
root: '.tuono',
publicDir: '../public',
cacheDir: 'cache',
envDir: '../',
plugins: [react(), ViteFsRouter()],
}
export function developmentSSRBundle() {
;(async () => {
console.log('Build SSR')
await build({
...BASE_CONFIG,
build: {
ssr: true,
minify: false,
outDir: 'server',
emptyOutDir: true,
rollupOptions: {
input: './.tuono/server-main.tsx',
output: {
entryFileNames: 'dev-server.js',
format: 'iife',
},
},
},
ssr: {
target: 'webworker',
noExternal: true,
},
})
})()
}
export function developmentCSRWatch() {
;(async () => {
console.log('Watch files')
const server = await createServer({
...BASE_CONFIG,
server: {
port: 3001,
strictPort: true,
},
build: {
manifest: true,
emptyOutDir: true,
rollupOptions: {
input: './.tuono/client-main.tsx',
},
},
})
await server.listen()
})()
}
export function buildProd() {
;(async () => {
await build({
...BASE_CONFIG,
manifest: true,
build: {
outDir: '../out/client',
},
emptyOutDir: true,
rollupOptions: {
input: './.tuono/client-main.tsx',
},
})
await build({
...BASE_CONFIG,
build: {
ssr: true,
minify: false,
outDir: '../out/server',
emptyOutDir: true,
rollupOptions: {
input: './.tuono/server-main.tsx',
output: {
entryFileNames: 'prod-server.js',
format: 'iife',
},
},
},
ssr: {
target: 'webworker',
noExternal: true,
},
})
})()
}
@@ -34,8 +34,11 @@ const defaultConfig: Config = {
let isFirst = false
let skipMessage = false
async function getRouteNodes(config = defaultConfig): Promise<RouteNode[]> {
async function getRouteNodes(
config = defaultConfig,
): Promise<{ routeNodes: RouteNode[]; rustHandlersNodes: string[] }> {
const routeNodes: RouteNode[] = []
const rustHandlersNodes: string[] = []
async function recurse(dir: string): Promise<void> {
const fullDir = path.resolve(config.folderName, dir)
@@ -74,6 +77,19 @@ async function getRouteNodes(config = defaultConfig): Promise<RouteNode[]> {
isLoader,
variableName,
})
} else if (fullPath.match(/\.(rs)$/)) {
const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath)
let routePath =
cleanPath(`/${filePathNoExt.split('.').join('/')}`) || ''
if (routePath === 'index') {
routePath = '/'
}
routePath = routePath.replace(/\/index$/, '/') || '/'
rustHandlersNodes.push(routePath)
}
}),
)
@@ -81,7 +97,7 @@ async function getRouteNodes(config = defaultConfig): Promise<RouteNode[]> {
await recurse('./')
return routeNodes
return { routeNodes, rustHandlersNodes }
}
export function hasParentRoute(
@@ -139,7 +155,8 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
latestTask = taskId
const start = Date.now()
const beforeRouteNodes = await getRouteNodes(config)
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
await getRouteNodes(config)
const preRouteNodes = multiSortBy(beforeRouteNodes, [
(d): number => (d.routePath === '/' ? -1 : 1),
@@ -173,28 +190,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
)
const routeCode = fs.readFileSync(node.fullPath, 'utf-8')
const escapedRoutePath = removeTrailingUnderscores(
node.routePath.replaceAll('$', '$$'),
)
let replaced = routeCode
if (!routeCode) {
replaced = [
`import { createFileRoute } from '@tanstack/react-router'`,
`export const Route = createFileRoute('${escapedRoutePath}')({
component: () => <div>Hello ${escapedRoutePath}!</div>
})`,
].join('\n\n')
if (replaced !== routeCode) {
console.log(`[emoticon] Updating ${node.fullPath}`)
await fsp.writeFile(node.fullPath, replaced)
}
}
if (node.parent) {
node.parent.children = node.parent.children ?? []
node.parent.children.push(node)
@@ -239,44 +234,45 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
(d): any => d,
])
const routeImports = [
'// This file is auto-generated by Tuono',
[
`import { Route as rootRoute } from './${replaceBackslash(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, ROOT_PATH_ID),
),
)}'`,
...sortedRouteNodes.map((node) => {
return `import { Route as ${
node.variableName
}Import } from './${replaceBackslash(
removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, node.filePath),
),
false,
const imports = [
...sortedRouteNodes.map((node) => {
return `import ${node.variableName}Import from './${replaceBackslash(
removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, node.filePath),
),
)}'`
}),
].join('\n'),
'// Create/Update Routes',
false,
),
)}'`
}),
].join('\n')
const createRoutes = [
...sortedRouteNodes.map((node) => {
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import })`
}),
].join('\n')
const createRouteUpdates = [
sortedRouteNodes
.map((node) => {
const loaderNode = routePiecesByPath[node.routePath]?.loader
const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy
return [
`const ${node.variableName}Route = ${node.variableName}Import.update({
`const ${node.variableName}Route = ${node.variableName}.update({
${[
`path: '${node.cleanedPath}'`,
`getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
rustHandlersNodes.includes(node.path || '')
? 'hasHandler: true'
: '',
]
.filter(Boolean)
.join(',')}
} as any)`,
// Verify if needed
loaderNode
? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash(
removeExt(
@@ -302,25 +298,24 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
].join('')
})
.join('\n\n'),
...[
'// Populate the FileRoutesByPath interface',
`declare module '@tanstack/react-router' {
interface FileRoutesByPath {
${routeNodes
.map((routeNode) => {
return `'${removeTrailingUnderscores(routeNode.routePath)}': {
preLoaderRoute: typeof ${routeNode.variableName}Import
parentRoute: typeof ${
routeNode.parent?.variableName
? `${routeNode.parent.variableName}Import`
: 'rootRoute'
}
}`
})
.join('\n')}
}
}`,
],
]
const routeImports = [
'// This file is auto-generated by Tuono',
"import { createRoute } from 'tuono'",
[
`import RootImport from './${replaceBackslash(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, ROOT_PATH_ID),
),
)}'`,
].join('\n'),
imports,
'const rootRoute = createRoute({ isRoot: true, component: RootImport });',
createRoutes,
'// Create/Update Routes',
createRouteUpdates,
'// Create and export the route tree',
`export const routeTree = rootRoute.addChildren([${routeConfigChildrenText}])`,
]
@@ -1,5 +1,5 @@
import { fileURLToPath, pathToFileURL } from 'url'
import { routeGenerator } from 'tuono-routes-generator'
import { routeGenerator } from '../routes-generator'
import { normalize } from 'path'
// eslint-disable-next-line sort-imports
@@ -8,7 +8,7 @@ import { SPLIT_PREFIX } from './constants'
import type { Plugin } from 'vite'
const ROUTES_DIRECTORY_PATH = 'src/routes'
const ROUTES_DIRECTORY_PATH = './src/routes'
const DEBUG = true
let lock = false
@@ -40,7 +40,7 @@ export function RouterGenerator(): Plugin {
}
return {
name: 'vite-plugin-fs-router-generator',
name: 'vite-plugin-tuono-fs-router-generator',
configResolved: async (): Promise<void> => {
await generate()
},
@@ -57,17 +57,18 @@ export function RouterGenerator(): Plugin {
export function RouterCodeSplitter(): Plugin {
const ROOT: string = process.cwd()
console.log('ROOT', ROOT)
return {
name: 'vite-plugin-fs-router-code-splitter',
name: 'vite-plugin-tuono-fs-router-code-splitter',
enforce: 'pre',
resolveId(source): string {
resolveId(source): string | null {
if (source.startsWith(SPLIT_PREFIX + ':')) {
return source.replace(SPLIT_PREFIX + ':', '')
}
return ''
return null
},
async transform(code, id): Promise<void> {
async transform(code, id): Promise<any> {
const url = pathToFileURL(id)
url.searchParams.delete('v')
id = fileURLToPath(url).replace(/\\/g, '/')
@@ -77,6 +78,7 @@ export function RouterCodeSplitter(): Plugin {
if (DEBUG) console.info('Route: ', id)
if (id.includes(SPLIT_PREFIX)) {
console.log('Split')
if (DEBUG) console.info('Splitting route: ', id)
const compiled = await splitFile({
@@ -101,7 +103,11 @@ export function RouterCodeSplitter(): Plugin {
}
return compiled
} else {
console.log('Non split')
}
return null
},
}
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '../router'
export function hydrate(routeTree: any) {
// Create a new router instance
const router = createRouter({ routeTree })
// Render the app
const rootElement = document.getElementById('__tuono')!
ReactDOM.hydrateRoot(
rootElement,
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)
}
+11 -3
View File
@@ -1,3 +1,11 @@
export default function sum(a: number, b: number): number {
return a + b
}
import {
createRoute,
createRootRoute,
createRouter,
Link,
RouterProvider,
} from './router'
export type { TuonoProps } from './types'
export { createRoute, createRootRoute, createRouter, Link, RouterProvider }
@@ -0,0 +1,18 @@
import { useRouterStore } from '../hooks/useRouterStore'
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
export default function Link(
props: AnchorHTMLAttributes<HTMLAnchorElement>,
): JSX.Element {
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
props.onClick?.(e)
useRouterStore.setState({ location: { pathname: props.href || '' } })
window.history.pushState('', '', props.href)
}
return (
<a {...props} onClick={handleTransition}>
{props.children}
</a>
)
}
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { getRouteByPathname } from './Matches'
import { cleanup } from '@testing-library/react'
describe('Test getRouteByPathname fn', () => {
afterEach(() => {
cleanup()
})
test('match routes by ids', () => {
vi.mock('../hooks/useRouter.tsx', () => ({
useRouter: (): { routesById: Record<string, any> } => {
return {
routesById: {
'/': { id: '/' },
'/about': { id: '/about' },
'/posts/': { id: '/posts/' }, // posts/index
'/posts/[post]': { id: '/posts/[post]' },
'/posts/defined-post': { id: '/posts/defined-post' },
'/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' },
},
}
},
}))
expect(getRouteByPathname('/')?.id).toBe('/')
expect(getRouteByPathname('/not-found')?.id).toBe(undefined)
expect(getRouteByPathname('/about')?.id).toBe('/about')
expect(getRouteByPathname('/posts/')?.id).toBe('/posts/')
expect(getRouteByPathname('/posts/dynamic-post')?.id).toBe('/posts/[post]')
expect(getRouteByPathname('/posts/defined-post')?.id).toBe(
'/posts/defined-post',
)
expect(getRouteByPathname('/posts/dynamic-post/dynamic-comment')?.id).toBe(
'/posts/[post]/[comment]',
)
})
})
@@ -0,0 +1,66 @@
import { useRouter } from '../hooks/useRouter'
import { useRouterStore } from '../hooks/useRouterStore'
import type { Route } from '../route'
import { RouteMatch } from './RouteMatch'
import NotFound from './NotFound'
interface MatchesProps {
// user defined props
serverSideProps: any
}
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
export function getRouteByPathname(pathname: string): Route | undefined {
const { routesById } = useRouter()
if (routesById[pathname]) return routesById[pathname]
const dynamicRoutes = Object.keys(routesById).filter((route) =>
DYNAMIC_PATH_REGEX.test(route),
)
if (!dynamicRoutes.length) return
const pathSegments = pathname.split('/').filter(Boolean)
let match = undefined
// TODO: Check algo efficiency
for (const dynamicRoute of dynamicRoutes) {
const dynamicRouteSegments = dynamicRoute.split('/').filter(Boolean)
const routeSegmentsCollector: string[] = []
for (let i = 0; i < dynamicRouteSegments.length; i++) {
if (
dynamicRouteSegments[i] === pathSegments[i] ||
DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '')
) {
routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '')
} else {
break
}
}
if (routeSegmentsCollector.length === pathSegments.length) {
match = `/${routeSegmentsCollector.join('/')}`
break
}
}
if (!match) return
return routesById[match]
}
export function Matches({ serverSideProps }: MatchesProps): JSX.Element {
const location = useRouterStore((st) => st.location)
const route = getRouteByPathname(location.pathname)
if (!route) {
return <NotFound />
}
return <RouteMatch route={route} serverSideProps={serverSideProps} />
}
@@ -0,0 +1,21 @@
import { useRouter } from '../hooks/useRouter'
import { RouteMatch } from './RouteMatch'
import Link from './Link'
export default function NotFound(): JSX.Element {
const router = useRouter()
const custom404Route = router.routesById['/404']
// Check if exists a custom 404 error page
if (custom404Route) {
return <RouteMatch route={custom404Route} />
}
return (
<>
<h1>404 Not found</h1>
<Link href="/">Return home</Link>
</>
)
}
@@ -0,0 +1,30 @@
import type { Route } from '../route'
import { useServerSideProps } from '../hooks/useServerSideProps'
interface MatchProps {
route: Route
// User defined server side props
serverSideProps: any
}
/**
* Returns the route match with the root element if exists
*
* It handles the fetch of the client side resources
*/
export const RouteMatch = ({
route,
serverSideProps,
}: MatchProps): JSX.Element => {
const { data, isLoading } = useServerSideProps(route, serverSideProps)
if (!route.isRoot) {
return route.options.getParentRoute().component({
children: route.options.component({ data, isLoading }),
data,
isLoading,
})
}
return route.options.component({ data, isLoading })
}
@@ -0,0 +1,20 @@
import * as React from 'react'
import type { Router } from '../router'
const routerContext = React.createContext<Router>(null!)
const TUONO_CONTEXT = '__TUONO_CONTEXT__'
export function getRouterContext(): any {
if (typeof document === 'undefined') {
return routerContext
}
if (window[TUONO_CONTEXT as any]) {
return window[TUONO_CONTEXT as any]
}
window[TUONO_CONTEXT as any] = routerContext as any
return routerContext
}
@@ -0,0 +1,86 @@
import { getRouterContext } from './RouterContext'
import { Matches } from './Matches'
import { useRouterStore } from '../hooks/useRouterStore'
import React, { useLayoutEffect, type ReactNode } from 'react'
type Router = any
interface RouterContextProviderProps {
router: Router
children: ReactNode
}
function RouterContextProvider({
router,
children,
...rest
}: RouterContextProviderProps): JSX.Element {
// Allow the router to update options on the router instance
router.update({
...router.options,
...rest,
context: {
...router.options.context,
...rest.context,
},
})
const routerContext = getRouterContext()
const pendingElement = router.options.defaultPendingComponent ? (
<router.options.defaultPendingComponent />
) : null
return (
<React.Suspense fallback={pendingElement}>
<routerContext.Provider value={router}>{children}</routerContext.Provider>
</React.Suspense>
)
}
interface RouterProviderProps {
router: Router
serverProps?: ServerProps
}
interface ServerProps {
router: Location
props: any
}
const initRouterStore = (props?: ServerProps): void => {
const updateLocation = useRouterStore((st) => st.updateLocation)
if (typeof window === 'undefined') {
updateLocation({
pathname: props?.router.pathname || '',
hash: '',
href: '',
searchStr: '',
})
}
useLayoutEffect(() => {
const { pathname, hash, href, search } = window.location
updateLocation({
pathname,
hash,
href,
searchStr: search,
search: new URLSearchParams(search),
})
}, [])
}
export function RouterProvider({
router,
serverProps,
}: RouterProviderProps): JSX.Element {
initRouterStore(serverProps)
return (
<RouterContextProvider router={router}>
<Matches serverSideProps={serverProps?.props} />
</RouterContextProvider>
)
}
@@ -0,0 +1,7 @@
import * as React from 'react'
import { getRouterContext } from '../components/RouterContext'
import type { Router } from '../router'
export function useRouter(): Router {
return React.useContext(getRouterContext())
}
@@ -0,0 +1,39 @@
import { create } from 'zustand'
export interface ParsedLocation {
href: string
pathname: string
search?: URLSearchParams
searchStr: string
hash: string
}
interface RouterState {
isLoading: boolean
isTransitioning: boolean
status: 'idle'
location: ParsedLocation
matches: string[]
pendingMatches: string[]
cachedMatches: string[]
statusCode: 200
updateLocation: (loc: ParsedLocation) => void
}
export const useRouterStore = create<RouterState>()((set) => ({
isLoading: false,
isTransitioning: false,
status: 'idle',
location: {
href: '',
pathname: '',
search: undefined,
searchStr: '',
hash: '',
},
matches: [],
pendingMatches: [],
cachedMatches: [],
statusCode: 200,
updateLocation: (location: ParsedLocation): void => set({ location }),
}))

Some files were not shown because too many files have changed in this diff Show More