mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-27 13:52:47 -07:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1726312d34 | |||
| 444d1479b0 | |||
| 2949ea4dfa | |||
| 8ab292b2f9 | |||
| 9d8bfd9826 | |||
| 8a6685a6d0 | |||
| 78990d3c08 | |||
| 227662489b | |||
| 5ce410f233 | |||
| 67d4e1bcac | |||
| 3e6aa540fa | |||
| b412371287 | |||
| f62fc0b2b9 | |||
| 35d8154649 | |||
| 6e794e9500 | |||
| e4d4a75b88 | |||
| bc7ffc2246 | |||
| 74e2f4e873 | |||
| d9d62a91f7 | |||
| 77b34fbb7b | |||
| d3040aa63f | |||
| 672e4b69d7 | |||
| 9cd90ba62a | |||
| b6d0bb13ef | |||
| 7401a59346 | |||
| 622ae93d68 | |||
| b4207feb7e | |||
| 332fdb70ca | |||
| b9abfb64c0 | |||
| 3ca8861d85 | |||
| 0c93aab13f | |||
| 208b49cfc8 | |||
| db0a2d9d1d | |||
| c123e93433 | |||
| 35557d296d | |||
| d0d6550d7e | |||
| 38d9be2bb0 | |||
| fcb33d208c | |||
| f831ad6c7a | |||
| 3bfed5fabc | |||
| e1ce3ba2fd | |||
| a31f048ffd | |||
| 9c707466db | |||
| 4ff899729b | |||
| a8345d3509 | |||
| 1c3d6e2d78 | |||
| dde5ae7827 | |||
| aeef19cda3 | |||
| adec05c6f8 | |||
| 2c1e8f7db8 | |||
| 2c1e8f0cc2 | |||
| 5d434aeb68 |
@@ -0,0 +1,23 @@
|
||||
## Description
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Expected behaviour
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## How to reproduce
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## Tooling and environment
|
||||
- Tuono version [e.g. 0.4.0]
|
||||
- OS: [e.g. MacOS, Windows]
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Node version: [e.g. 20.0.0]
|
||||
- Rust version: [e.g. 1.79.0]
|
||||
- Crate version: [e.g. 1.78.0]
|
||||
- Node Package Manger version: [e.g. pnpm: 9.5.0]
|
||||
|
||||
## Additional context
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,18 @@
|
||||
## Context
|
||||
|
||||
<!--
|
||||
Explain the context and why you're making that change. What is the problem
|
||||
you're trying to solve? If a new feature is being added, describe the intended
|
||||
use case that feature fulfills.
|
||||
-->
|
||||
|
||||
## Description
|
||||
|
||||
<!--
|
||||
Thank you for your Pull Request. Please provide a description above and review
|
||||
the requirements below.
|
||||
|
||||
Bug fixes and new features should include tests.
|
||||
|
||||
Contributors guide: https://github.com/Valerioageno/tuono/blob/main/CONTRIBUTING.md
|
||||
-->
|
||||
@@ -14,6 +14,15 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
lint_and_fmt:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
build_and_test:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
name: Build and test rust crates
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
valerioageno@yahoo.it.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,21 @@
|
||||

|
||||

|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
# How to contribute to Tuono
|
||||
|
||||
## Contributions
|
||||
Any feature contribution or suggestion is strongly appreciated.
|
||||
Since the current project size there isn't yet a defined way to start a discussion. Consider to [open a new issue](https://github.com/Valerioageno/tuono/issues/new)
|
||||
or to reach me using my email address [valerioageno@yahoo.it](mailto:valerioageno@ahoo.it). I'm also available on twitter (X) DMs @valerioageno.
|
||||
|
||||
## Bugs
|
||||
|
||||
**Did you find a bug?**
|
||||
- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/Valerioageno/tuono/issues).
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Valerioageno/tuono/issues/new). Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring.
|
||||
|
||||
**Did you write a patch that fixes a bug?**
|
||||
- Open a new GitHub pull request with the patch.
|
||||
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||
- The pull requests must pass all the CI pipelines
|
||||
+1
-1
@@ -6,7 +6,7 @@ members = [
|
||||
"crates/tuono_lib_macros",
|
||||
]
|
||||
exclude = [
|
||||
"examples/playground",
|
||||
"examples/mdx",
|
||||
"examples/tuono",
|
||||
"examples/tutorial"
|
||||
]
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
<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>
|
||||
<div align="center">
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg" />
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg" />
|
||||
@@ -17,53 +14,69 @@
|
||||
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||
Why Tuono? Just a badass name.
|
||||
|
||||
> If you want to see how this project actually works check the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) page.
|
||||
> Check out the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) to get started.
|
||||
|
||||
## Introduction
|
||||
|
||||
NodeJs/Deno/Bun are the only tools that make a React app fullstack right? (no)
|
||||
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be fullstack right? (no)**
|
||||
|
||||
Tuono wants to prove that it's possible creating fully fledged react applications without the need to host them on a JS runtime server leveraging the best of the two worlds:
|
||||
super powered server and amazing development experience.
|
||||
Tuono is a fullstack React framework with the server side written in Rust.
|
||||
Because of this Tuono is extremely fast and the requests are handled by multithreaded Rust server.
|
||||
React is still React - it is just superpowered.
|
||||
|
||||
**Rust is an hard language then writing server side code is hard as well right? (no again)**
|
||||
|
||||
Tuono provides a collection of utilities to handle the server side code seamlessly with the React code.
|
||||
Each server side route is managed with a separate file alongside the React route. The routing is handled
|
||||
by Tuono based on the files defined within the `./src/routes` directory.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Typescript
|
||||
- [x] FS routing
|
||||
- [x] Hot Module Reload
|
||||
- [x] CSS modules
|
||||
- [x] Rust based SSR
|
||||
- [x] Multi thread backend
|
||||
- [x] Development environment
|
||||
- [ ] Create custom APIs
|
||||
- [ ] Image optimization
|
||||
- [ ] Build optimization
|
||||
- [ ] Server streamed content
|
||||
- 🟦 Typescript
|
||||
- 🌐 Routing
|
||||
- 🔥 Hot Module Reload
|
||||
- 🍭 CSS modules
|
||||
- 🧬 Server Side Rendering
|
||||
- 🧵 Multi thread backend
|
||||
- ⌨️ MDX support
|
||||
- ⚙️ Build optimizations
|
||||
- Custom APIs*
|
||||
- Image optimization*
|
||||
- Server streamed content*
|
||||
|
||||
> *Development in progress
|
||||
|
||||
## Getting started
|
||||
|
||||
As already mentioned above I strongly suggest you to take a look at the
|
||||
[tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md).
|
||||
|
||||
Tuono is the CLI that provides all the needed commands to handle the fullstack project.
|
||||
To download it is required [cargo](https://doc.rust-lang.org/cargo/) which is the [rust](https://www.rust-lang.org/)
|
||||
package manager.
|
||||
|
||||
Then run `cargo install tuono`.
|
||||
|
||||
To list all the available commands run `tuono -h`
|
||||
|
||||
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` (or `-t`) flag - check the
|
||||
[examples](https://github.com/Valerioageno/tuono/tree/main/examples) folder).
|
||||
|
||||
Then to run the local development environment run inside the project folder `tuono dev`
|
||||
|
||||
Finally when the project will be ready to be deployed just run `tuono build` to create the final React assets
|
||||
and to set the server project in the `production` mode.
|
||||
|
||||
Now to execute it just run `cargo run --release`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- rust
|
||||
- cargo
|
||||
- node
|
||||
- pnpm (other package managers support will be added soon)
|
||||
- npm/pnpm/yarn*
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
cargo install tuono
|
||||
```
|
||||
|
||||
## Create a new project
|
||||
|
||||
```
|
||||
tuono new [NAME]
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
tuono dev
|
||||
```
|
||||
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||
|
||||
## Folder structure
|
||||
|
||||
@@ -79,7 +92,7 @@ tuono dev
|
||||
```
|
||||
|
||||
## Contributing
|
||||
Any help or suggestion will be appreciated.
|
||||
Any help or suggestion will be appreciated and encouraged.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.1.7"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
@@ -29,4 +30,5 @@ glob = "0.3.1"
|
||||
regex = "1.10.4"
|
||||
reqwest = {version = "0.12.4", features =["blocking", "json"]}
|
||||
serde_json = "1.0"
|
||||
fs_extra = "1.3.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
use glob::glob;
|
||||
use glob::GlobError;
|
||||
use std::collections::{hash_map::Entry, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::route::Route;
|
||||
|
||||
const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"];
|
||||
const IGNORE_FILES: [&str; 1] = ["__root"];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
pub route_map: HashMap<String, Route>,
|
||||
pub base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut app = App {
|
||||
route_map: HashMap::new(),
|
||||
base_path,
|
||||
};
|
||||
|
||||
app.collect_routes();
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
fn collect_routes(&mut self) {
|
||||
glob(self.base_path.join("src/routes/**/*.*").to_str().unwrap())
|
||||
.expect("Failed to read glob pattern")
|
||||
.for_each(|entry| {
|
||||
if self.should_collect_route(&entry) {
|
||||
self.collect_route(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn should_collect_route(&self, entry: &Result<PathBuf, GlobError>) -> bool {
|
||||
let file_extension = entry.as_ref().unwrap().extension().unwrap();
|
||||
let file_name = entry.as_ref().unwrap().file_stem().unwrap();
|
||||
|
||||
if IGNORE_EXTENSIONS.iter().any(|val| val == &file_extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if IGNORE_FILES.iter().any(|val| val == &file_name) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_route(&mut self, path_buf: Result<PathBuf, GlobError>) {
|
||||
let entry = path_buf.unwrap();
|
||||
let base_path_str = self.base_path.to_str().unwrap();
|
||||
let path = entry
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace(&format!("{base_path_str}/src/routes"), "")
|
||||
.replace(".rs", "")
|
||||
.replace(".tsx", "");
|
||||
|
||||
if entry.extension().unwrap() == "rs" {
|
||||
if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) {
|
||||
let mut route = Route::new(path);
|
||||
route.update_axum_info();
|
||||
route_map.insert(route);
|
||||
} else {
|
||||
let route = self.route_map.get_mut(&path).unwrap();
|
||||
route.update_axum_info();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Entry::Vacant(route_map) = self.route_map.entry(path.clone()) {
|
||||
let route = Route::new(path);
|
||||
route_map.insert(route);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_dynamic_routes(&self) -> bool {
|
||||
self.route_map.iter().any(|(_, route)| route.is_dynamic)
|
||||
}
|
||||
|
||||
pub fn build_react_prod(&self) {
|
||||
Command::new("./node_modules/.bin/tuono-build-prod")
|
||||
.output()
|
||||
.expect("Failed to build the react source");
|
||||
}
|
||||
|
||||
pub fn run_rust_server(&self) -> Child {
|
||||
Command::new("cargo")
|
||||
.arg("run")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to run the rust server")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_collect_routes() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index", "index"),
|
||||
("/about", "about"),
|
||||
("/posts/index", "posts_index"),
|
||||
("/posts/[post]", "posts_dyn_post"),
|
||||
("/posts/UPPERCASE", "posts_uppercase"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, module_import)| {
|
||||
assert_eq!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.unwrap()
|
||||
.axum_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.module_import,
|
||||
String::from(module_import)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_multi_level_axum_paths() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/any-post.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [
|
||||
("/index", "/"),
|
||||
("/about", "/about"),
|
||||
("/posts/index", "/posts"),
|
||||
("/posts/any-post", "/posts/any-post"),
|
||||
("/posts/[post]", "/posts/:post"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, expected_path)| {
|
||||
assert_eq!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.unwrap()
|
||||
.axum_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.axum_route,
|
||||
String::from(expected_path)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_whitelisted_extensions() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.css",
|
||||
"/home/user/Documents/tuono/src/routes/index.scss",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.sass",
|
||||
];
|
||||
|
||||
routes.into_iter().for_each(|route| {
|
||||
if app.should_collect_route(&Ok(PathBuf::from(route))) {
|
||||
app.collect_route(Ok(PathBuf::from(route)))
|
||||
}
|
||||
});
|
||||
|
||||
assert!(app.route_map.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_whitelisted_files() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/__root.tsx",
|
||||
"/home/user/Documents/tuono/src/routes/posts/__root.tsx",
|
||||
];
|
||||
|
||||
routes.into_iter().for_each(|route| {
|
||||
if app.should_collect_route(&Ok(PathBuf::from(route))) {
|
||||
app.collect_route(Ok(PathBuf::from(route)))
|
||||
}
|
||||
});
|
||||
|
||||
assert!(app.route_map.is_empty())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_parse_routes_with_server_handler() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
||||
"/home/user/Documents/tuono/src/routes/about.tsx",
|
||||
"/home/user/Documents/tuono/src/routes/index.tsx",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
let results = [("/about", true), ("/index", false)];
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.for_each(|(path, expected_has_server_handler)| {
|
||||
if expected_has_server_handler {
|
||||
assert!(app.route_map.get(path).unwrap().axum_info.is_some())
|
||||
} else {
|
||||
assert!(app.route_map.get(path).unwrap().axum_info.is_none())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_dynamic_routes_works() {
|
||||
let mut app = App::new();
|
||||
app.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(app.has_dynamic_routes());
|
||||
|
||||
let mut app2 = App::new();
|
||||
app2.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/[post].rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app2.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(app2.has_dynamic_routes());
|
||||
|
||||
let mut app3 = App::new();
|
||||
app3.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| app3.collect_route(Ok(PathBuf::from(route))));
|
||||
|
||||
assert!(!app3.has_dynamic_routes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use fs_extra::dir::{copy, CopyOptions};
|
||||
use std::path::PathBuf;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::scaffold_project;
|
||||
use crate::source_builder::{bundle_axum_source, check_tuono_folder, create_client_entry_files};
|
||||
use crate::watch;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
/// Start the development environment
|
||||
Dev,
|
||||
/// Build the production assets
|
||||
Build {
|
||||
#[arg(short, long = "static")]
|
||||
/// Statically generate the website HTML
|
||||
ssg: bool,
|
||||
},
|
||||
/// Scaffold a new project
|
||||
New {
|
||||
/// The folder in which load the project. Default is the current directory.
|
||||
folder_name: Option<String>,
|
||||
/// The template to use to scaffold the project. The template should match one of the tuono
|
||||
/// examples
|
||||
#[arg(short, long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "The react/rust fullstack framework")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn init_tuono_folder(mode: Mode) -> std::io::Result<()> {
|
||||
check_tuono_folder()?;
|
||||
bundle_axum_source(mode)?;
|
||||
create_client_entry_files()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn app() -> std::io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
match args.action {
|
||||
Actions::Dev => {
|
||||
init_tuono_folder(Mode::Dev)?;
|
||||
watch::watch().unwrap();
|
||||
}
|
||||
Actions::Build { ssg } => {
|
||||
init_tuono_folder(Mode::Prod)?;
|
||||
let app = App::new();
|
||||
|
||||
if ssg && app.has_dynamic_routes() {
|
||||
// TODO: allow dynamic routes static generation
|
||||
println!("Cannot statically build dynamic routes");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.build_react_prod();
|
||||
|
||||
if ssg {
|
||||
println!("SSG: generation started");
|
||||
|
||||
let static_dir = PathBuf::from("out/static");
|
||||
|
||||
if static_dir.is_dir() {
|
||||
std::fs::remove_dir_all(&static_dir)
|
||||
.expect("Failed to clear the out/static folder");
|
||||
}
|
||||
|
||||
std::fs::create_dir(&static_dir).expect("Failed to create static output dir");
|
||||
|
||||
copy(
|
||||
"./out/client",
|
||||
static_dir,
|
||||
&CopyOptions::new().overwrite(true).content_only(true),
|
||||
)
|
||||
.expect("Failed to clone assets into static output folder");
|
||||
|
||||
let mut rust_server = app.run_rust_server();
|
||||
|
||||
let reqwest = reqwest::blocking::Client::builder()
|
||||
.user_agent("")
|
||||
.build()
|
||||
.expect("Failed to build reqwest client");
|
||||
|
||||
// Wait for server
|
||||
let mut is_server_ready = false;
|
||||
|
||||
while !is_server_ready {
|
||||
if reqwest.get("http://localhost:3000").send().is_ok() {
|
||||
is_server_ready = true
|
||||
}
|
||||
// TODO: add maximum tries
|
||||
sleep(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
app.route_map
|
||||
.iter()
|
||||
.for_each(|(_, route)| route.save_ssg_html(&reqwest));
|
||||
|
||||
// Close server
|
||||
let _ = rust_server.kill();
|
||||
};
|
||||
|
||||
println!("Build successfully finished");
|
||||
}
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+5
-63
@@ -1,65 +1,7 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::process::Command;
|
||||
|
||||
mod source_builder;
|
||||
use source_builder::{bundle_axum_source, create_client_entry_files};
|
||||
|
||||
use crate::source_builder::check_tuono_folder;
|
||||
mod app;
|
||||
pub mod cli;
|
||||
mod mode;
|
||||
mod route;
|
||||
mod scaffold_project;
|
||||
mod source_builder;
|
||||
mod watch;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
/// Start the development environment
|
||||
Dev,
|
||||
/// Build the production assets
|
||||
Build,
|
||||
/// Scaffold a new project
|
||||
New {
|
||||
/// The folder in which load the project. Default is the current directory.
|
||||
folder_name: Option<String>,
|
||||
/// The template to use to scaffold the project. The template should match one of the tuono
|
||||
/// examples
|
||||
#[arg(short, long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "The react/rust fullstack framework")]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn init_tuono_folder() -> 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,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use tuono::cli;
|
||||
use tuono::cli::app;
|
||||
|
||||
fn main() {
|
||||
cli().unwrap();
|
||||
app().expect("Failed to start the CLI")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Prod,
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn as_str<'a>(&self) -> &'a str {
|
||||
if *self == Mode::Dev {
|
||||
return "Mode::Dev";
|
||||
}
|
||||
"Mode::Prod"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_correctly_print_the_mode_as_str() {
|
||||
let dev = Mode::Dev.as_str();
|
||||
let prod = Mode::Prod.as_str();
|
||||
assert_eq!(dev, "Mode::Dev");
|
||||
assert_eq!(prod, "Mode::Prod");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use regex::Regex;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::Url;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct AxumInfo {
|
||||
// Path for importing the module
|
||||
pub module_import: String,
|
||||
// path for the the axum router
|
||||
pub axum_route: String,
|
||||
}
|
||||
|
||||
impl AxumInfo {
|
||||
pub fn new(path: String) -> Self {
|
||||
// Remove first slash
|
||||
let mut module = path.chars();
|
||||
module.next();
|
||||
|
||||
let axum_route = path.replace("/index", "");
|
||||
|
||||
if axum_route.is_empty() {
|
||||
return AxumInfo {
|
||||
module_import: module.as_str().to_string().replace('/', "_"),
|
||||
axum_route: "/".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if has_dynamic_path(&path) {
|
||||
return AxumInfo {
|
||||
module_import: module
|
||||
.as_str()
|
||||
.to_string()
|
||||
.replace('/', "_")
|
||||
.replace('[', "dyn_")
|
||||
.replace(']', ""),
|
||||
axum_route: axum_route.replace('[', ":").replace(']', ""),
|
||||
};
|
||||
}
|
||||
|
||||
AxumInfo {
|
||||
module_import: module.as_str().to_string().replace('/', "_").to_lowercase(),
|
||||
axum_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
path: String,
|
||||
pub is_dynamic: bool,
|
||||
pub axum_info: Option<AxumInfo>,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn new(cleaned_path: String) -> Self {
|
||||
Route {
|
||||
path: cleaned_path.clone(),
|
||||
axum_info: None,
|
||||
is_dynamic: has_dynamic_path(&cleaned_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_axum_info(&mut self) {
|
||||
self.axum_info = Some(AxumInfo::new(self.path.clone()))
|
||||
}
|
||||
|
||||
pub fn save_ssg_html(&self, reqwest: &Client) {
|
||||
let path = &self.path.replace("index", "");
|
||||
|
||||
let mut response = reqwest
|
||||
.get(format!("http://localhost:3000{path}"))
|
||||
.send()
|
||||
.unwrap();
|
||||
|
||||
let file_path = PathBuf::from(format!("out/static{}.html", &self.path));
|
||||
|
||||
let parent_dir = file_path.parent().unwrap();
|
||||
|
||||
if !parent_dir.is_dir() {
|
||||
create_all(parent_dir, false).expect("Failed to create parent directories");
|
||||
}
|
||||
|
||||
let mut file = File::create(file_path).expect("Failed to create the HTML file");
|
||||
|
||||
io::copy(&mut response, &mut file).expect("Failed to write the HTML on the file");
|
||||
|
||||
// Saving also the server response
|
||||
if self.axum_info.is_some() {
|
||||
let data_file_path =
|
||||
PathBuf::from(&format!("out/static/__tuono/data{}/data.json", path));
|
||||
|
||||
let data_parent_dir = data_file_path.parent().unwrap();
|
||||
|
||||
if !data_parent_dir.is_dir() {
|
||||
create_all(data_parent_dir, false)
|
||||
.expect("Failed to create data parent directories");
|
||||
}
|
||||
|
||||
let base = Url::parse("http://localhost:3000/__tuono/data/data.json").unwrap();
|
||||
|
||||
let path = if path == "/" { "" } else { path };
|
||||
|
||||
let pathname = &format!("/__tuono/data{path}/data.json");
|
||||
|
||||
let url = base
|
||||
.join(pathname)
|
||||
.expect("Failed to build the reqwest URL");
|
||||
|
||||
let mut response = reqwest.get(url).send().unwrap();
|
||||
|
||||
let mut data_file =
|
||||
File::create(data_file_path).expect("Failed to create the JSON file");
|
||||
|
||||
io::copy(&mut response, &mut data_file).expect("Failed to write the JSON on the file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_find_dynamic_paths() {
|
||||
let routes = [
|
||||
("/home/user/Documents/tuono/src/routes/about.rs", false),
|
||||
("/home/user/Documents/tuono/src/routes/index.rs", false),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_create_the_axum_infos() {
|
||||
let info = AxumInfo::new("/index".to_string());
|
||||
|
||||
assert_eq!(info.axum_route, "/");
|
||||
assert_eq!(info.module_import, "index");
|
||||
|
||||
let dyn_info = AxumInfo::new("/[posts]".to_string());
|
||||
|
||||
assert_eq!(dyn_info.axum_route, "/:posts");
|
||||
assert_eq!(dyn_info.module_import, "dyn_posts");
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ fn outro(folder_name: String) {
|
||||
}
|
||||
|
||||
println!("\nInstall the dependencies:");
|
||||
println!("pnpm install");
|
||||
println!("npm install");
|
||||
|
||||
println!("\nRun the local environment:");
|
||||
println!("tuono dev");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use glob::glob;
|
||||
use glob::GlobError;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::route::AxumInfo;
|
||||
use crate::route::Route;
|
||||
|
||||
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
@@ -32,146 +33,26 @@ 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;
|
||||
use tuono_lib::{tokio, Mode, Server, axum::Router};
|
||||
// AXUM_GET_ROUTE_HANDLER
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
Ssr::create_platform();
|
||||
|
||||
let fetch = Client::new();
|
||||
|
||||
let app = Router::new()
|
||||
let router = 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();
|
||||
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
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, &"")
|
||||
.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()),
|
||||
}
|
||||
Server::init(router, MODE).start().await
|
||||
}
|
||||
"##;
|
||||
|
||||
const ROOT_FOLDER: &str = "src/routes";
|
||||
const ROUTE_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");
|
||||
@@ -181,64 +62,89 @@ fn create_main_file(base_path: &Path, bundled_file: &String) {
|
||||
.expect("write failed");
|
||||
}
|
||||
|
||||
fn create_routes_declaration(routes: &HashMap<PathBuf, Route>) -> String {
|
||||
fn create_routes_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
|
||||
for (_, route) in routes.iter() {
|
||||
let Route {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = &route;
|
||||
let Route { axum_info, .. } = &route;
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::route))"#
|
||||
));
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}", get({module_import}::api))"#
|
||||
));
|
||||
if axum_info.is_some() {
|
||||
let AxumInfo {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = axum_info.as_ref().unwrap();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::route))"#
|
||||
));
|
||||
let slash = if axum_route.ends_with('/') { "" } else { "/" };
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}{slash}data.json", get({module_import}::api))"#
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
fn create_modules_declaration(routes: &HashMap<PathBuf, Route>) -> String {
|
||||
fn create_modules_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||
|
||||
for (path, route) in routes.iter() {
|
||||
let module_name = &route.module_import;
|
||||
let path_str = path.to_str().unwrap();
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROOT_FOLDER}{path_str}"]
|
||||
mod {module_name};
|
||||
"#
|
||||
))
|
||||
if route.axum_info.is_some() {
|
||||
let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROUTE_FOLDER}{path}.rs"]
|
||||
mod {module_import};
|
||||
"#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
pub fn bundle_axum_source() -> io::Result<()> {
|
||||
pub fn bundle_axum_source(mode: Mode) -> io::Result<()> {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
let app = App::new();
|
||||
|
||||
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),
|
||||
);
|
||||
let bundled_file = generate_axum_source(&app, mode);
|
||||
|
||||
create_main_file(&base_path, &bundled_file);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_axum_source(app: &App, mode: Mode) -> String {
|
||||
let src = AXUM_ENTRY_POINT
|
||||
.replace(
|
||||
"// ROUTE_BUILDER\n",
|
||||
&create_routes_declaration(&app.route_map),
|
||||
)
|
||||
.replace(
|
||||
"// MODULE_IMPORTS\n",
|
||||
&create_modules_declaration(&app.route_map),
|
||||
)
|
||||
.replace("/*MODE*/", mode.as_str());
|
||||
|
||||
let has_server_handlers = app
|
||||
.route_map
|
||||
.iter()
|
||||
.filter(|(_, route)| route.axum_info.is_some())
|
||||
.collect::<Vec<(&String, &Route)>>()
|
||||
.is_empty();
|
||||
|
||||
if !has_server_handlers {
|
||||
return src.replace(
|
||||
"// AXUM_GET_ROUTE_HANDLER",
|
||||
"use tuono_lib::axum::routing::get;",
|
||||
);
|
||||
}
|
||||
src
|
||||
}
|
||||
|
||||
pub fn check_tuono_folder() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
@@ -266,94 +172,37 @@ 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,
|
||||
),
|
||||
];
|
||||
fn should_set_the_correct_mode() {
|
||||
let source_builder = App::new();
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||
|
||||
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);
|
||||
|
||||
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_routes() {
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
fn should_not_load_the_axum_get_function() {
|
||||
let source_builder = App::new();
|
||||
|
||||
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)
|
||||
)
|
||||
})
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multi_level_axum_paths() {
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
fn should_load_the_axum_get_function() {
|
||||
let mut source_builder = App::new();
|
||||
|
||||
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",
|
||||
];
|
||||
let mut route = Route::new(String::from("index.tsx"));
|
||||
route.update_axum_info();
|
||||
|
||||
routes
|
||||
.into_iter()
|
||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
||||
source_builder
|
||||
.route_map
|
||||
.insert(String::from("index.rs"), 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)
|
||||
)
|
||||
})
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-11
@@ -6,6 +6,7 @@ use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
use watchexec_supervisor::job::{start_job, Job};
|
||||
|
||||
use crate::mode::Mode;
|
||||
use crate::source_builder::bundle_axum_source;
|
||||
|
||||
fn watch_react_src() -> Job {
|
||||
@@ -57,28 +58,32 @@ pub async fn watch() -> Result<()> {
|
||||
build_ssr_bundle.to_wait().await;
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let mut should_reload = false;
|
||||
let mut should_reload_ssr_bundle = false;
|
||||
let mut should_reload_rust_server = false;
|
||||
|
||||
for event in action.events.iter() {
|
||||
for path in event.paths() {
|
||||
if path.0.to_string_lossy().ends_with(".rs")
|
||||
// Either tsx and jsx
|
||||
|| path.0.to_string_lossy().ends_with("sx")
|
||||
{
|
||||
should_reload = true
|
||||
if path.0.to_string_lossy().ends_with(".rs") {
|
||||
should_reload_rust_server = true
|
||||
}
|
||||
|
||||
// Either tsx and jsx
|
||||
if path.0.to_string_lossy().ends_with("sx") {
|
||||
should_reload_ssr_bundle = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload {
|
||||
if should_reload_rust_server {
|
||||
println!("Reloading...");
|
||||
run_server.stop();
|
||||
bundle_axum_source(Mode::Dev).expect("Failed to bunlde rust source");
|
||||
run_server.start();
|
||||
}
|
||||
|
||||
if should_reload_ssr_bundle {
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source().expect("Failed to bunlde rust source");
|
||||
run_server.start();
|
||||
println!("Ready!");
|
||||
should_reload = false;
|
||||
}
|
||||
|
||||
// if Ctrl-C is received, quit
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.1.7"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
@@ -18,11 +19,22 @@ name = "tuono_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
ssr_rs = "0.5.2"
|
||||
axum = "0.7.5"
|
||||
ssr_rs = "0.5.5"
|
||||
axum = {version = "0.7.5", features = ["json", "ws"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
erased-serde = "0.4.5"
|
||||
serde_json = "1.0"
|
||||
serde_urlencoded = "0.7.1"
|
||||
reqwest = {version = "0.12.4", features = ["json", "stream"]}
|
||||
once_cell = "1.19.0"
|
||||
regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.7"}
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.8.0"}
|
||||
# Match the same version used by axum
|
||||
tokio-tungstenite = "0.21.0"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
tungstenite = "0.23.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn catch_all(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
request: Request,
|
||||
) -> Html<String> {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = Payload::new(&req, &"").client_payload().unwrap();
|
||||
|
||||
let result = Js::render_to_string(Some(&payload));
|
||||
|
||||
match result {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
mod catch_all;
|
||||
mod manifest;
|
||||
mod mode;
|
||||
mod payload;
|
||||
mod request;
|
||||
mod response;
|
||||
mod server;
|
||||
mod ssr;
|
||||
mod vite_reverse_proxy;
|
||||
mod vite_websocket_proxy;
|
||||
|
||||
pub mod ssr;
|
||||
|
||||
pub use mode::Mode;
|
||||
pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::{Props, Response};
|
||||
pub use ssr_rs::Ssr;
|
||||
pub use server::Server;
|
||||
pub use tuono_lib_macros::handler;
|
||||
|
||||
// Re-exports
|
||||
pub use axum;
|
||||
pub use reqwest;
|
||||
pub use tokio;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BundleInfo {
|
||||
/// TODO: Add also the import field and load the dynamic
|
||||
/// values in the payload bundles.
|
||||
pub file: String,
|
||||
#[serde(default = "default_css_vector")]
|
||||
pub css: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_css_vector() -> Vec<String> {
|
||||
Vec::with_capacity(0)
|
||||
}
|
||||
|
||||
/// Manifest is the mapping between the vite output bundled files
|
||||
/// and the originals.
|
||||
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
||||
pub static MANIFEST: OnceCell<HashMap<String, BundleInfo>> = OnceCell::new();
|
||||
|
||||
pub fn load_manifest() {
|
||||
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH)).unwrap();
|
||||
let reader = BufReader::new(file);
|
||||
let manifest = serde_json::from_reader(reader).unwrap();
|
||||
MANIFEST.set(remap_manifest_keys(manifest)).unwrap();
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn correctly_parse_the_manifest_json() {
|
||||
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 parsed_manifest =
|
||||
serde_json::from_str::<HashMap<String, BundleInfo>>(manifest_example).unwrap();
|
||||
|
||||
let mut result = HashMap::new();
|
||||
result.insert(
|
||||
"../src/routes/index.tsx".to_string(),
|
||||
BundleInfo {
|
||||
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()],
|
||||
},
|
||||
);
|
||||
|
||||
result.insert(
|
||||
"meta-tags-lib".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/meta-lib.js".to_string(),
|
||||
css: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(parsed_manifest, result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_remap_the_manifest() {
|
||||
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
||||
parsed_manifest.insert(
|
||||
"../src/routes/index.tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/index.js".to_string(),
|
||||
css: vec!["assets/index.css".to_string()],
|
||||
},
|
||||
);
|
||||
parsed_manifest.insert(
|
||||
"../src/routes/about.jsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/about.js".to_string(),
|
||||
css: vec!["assets/about.css".to_string()],
|
||||
},
|
||||
);
|
||||
parsed_manifest.insert(
|
||||
"../src/routes/posts/[post].tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/posts/[post].js".to_string(),
|
||||
css: vec!["assets/posts/[post].css".to_string()],
|
||||
},
|
||||
);
|
||||
parsed_manifest.insert(
|
||||
"client-main.tsx".to_string(),
|
||||
BundleInfo {
|
||||
file: "assets/main.js".to_string(),
|
||||
css: vec!["assets/main.css".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
let remapped = remap_manifest_keys(parsed_manifest);
|
||||
|
||||
assert_eq!(
|
||||
remapped.get("/").unwrap().file,
|
||||
"assets/index.js".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
remapped.get("/about").unwrap().file,
|
||||
"assets/about.js".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Clone, Copy)]
|
||||
pub enum Mode {
|
||||
Dev,
|
||||
Prod,
|
||||
}
|
||||
|
||||
pub static GLOBAL_MODE: OnceCell<Mode> = OnceCell::new();
|
||||
@@ -1,25 +1,296 @@
|
||||
use crate::manifest::MANIFEST;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use erased_serde::Serialize;
|
||||
use regex::Regex;
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
|
||||
use crate::request::Location;
|
||||
use crate::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)]
|
||||
/// This is the data shared to the client
|
||||
/// This is the payload sent to the client for hydration
|
||||
pub struct Payload<'a> {
|
||||
router: Location<'a>,
|
||||
router: Location,
|
||||
props: &'a dyn Serialize,
|
||||
mode: Mode,
|
||||
#[serde(rename(serialize = "jsBundles"))]
|
||||
js_bundles: Option<Vec<&'a String>>,
|
||||
#[serde(rename(serialize = "cssBundles"))]
|
||||
css_bundles: Option<Vec<&'a String>>,
|
||||
}
|
||||
|
||||
impl<'a> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
pub fn new(req: &'a Request, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
Payload {
|
||||
router: req.location(),
|
||||
props,
|
||||
mode: *GLOBAL_MODE.get().expect("Failed to load the current mode"),
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client_payload(&self) -> Result<String, serde_json::Error> {
|
||||
pub fn client_payload(&mut self) -> Result<String, serde_json::Error> {
|
||||
if self.mode == Mode::Prod {
|
||||
self.add_bundle_sources();
|
||||
}
|
||||
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) {
|
||||
// Manifest should always be loaded. The load happen before starting
|
||||
// the server.
|
||||
let manifest = MANIFEST.get().expect("Failed to load manifest");
|
||||
|
||||
// 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.router.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>>();
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use axum::http::Uri;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::manifest::BundleInfo;
|
||||
|
||||
fn prepare_payload<'a>(uri: Option<&'a str>, mode: Mode) -> Payload<'a> {
|
||||
let mut manifest_mock = HashMap::new();
|
||||
manifest_mock.insert(
|
||||
"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(
|
||||
"/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
|
||||
.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
|
||||
let location = Location::from(uri);
|
||||
|
||||
Payload {
|
||||
router: location,
|
||||
props: &None::<Option<()>>,
|
||||
mode,
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_bundles_on_mode_prod() {
|
||||
let mut payload = prepare_payload(None, Mode::Prod);
|
||||
|
||||
let _ = payload.client_payload();
|
||||
assert_eq!(
|
||||
payload.js_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.js".to_string(),
|
||||
&"assets/index.js".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
payload.css_bundles,
|
||||
Some(vec![
|
||||
&"assets/bundled-file.css".to_string(),
|
||||
&"assets/index.css".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_load_the_bundles_on_mode_dev() {
|
||||
let mut payload = prepare_payload(None, Mode::Dev);
|
||||
let _ = payload.client_payload();
|
||||
assert!(payload.js_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_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()
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,44 @@ use std::collections::HashMap;
|
||||
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
|
||||
/// Location must match client side interface
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Location {
|
||||
href: String,
|
||||
pathname: String,
|
||||
#[serde(rename(serialize = "searchStr"))]
|
||||
search_str: String,
|
||||
search: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub fn pathname(&self) -> &String {
|
||||
&self.pathname
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uri> for Location {
|
||||
fn from(uri: Uri) -> Self {
|
||||
let query = uri.query().unwrap_or("");
|
||||
Location {
|
||||
// TODO: build correct href
|
||||
href: uri.to_string(),
|
||||
pathname: uri.path().to_string(),
|
||||
search_str: query.to_string(),
|
||||
search: serde_urlencoded::from_str(query).unwrap_or(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request<'a> {
|
||||
uri: &'a Uri,
|
||||
pub headers: &'a HeaderMap,
|
||||
pub struct Request {
|
||||
uri: Uri,
|
||||
pub headers: 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> {
|
||||
impl Request {
|
||||
pub fn new(uri: Uri, headers: HeaderMap, params: HashMap<String, String>) -> Request {
|
||||
Request {
|
||||
uri,
|
||||
headers,
|
||||
@@ -34,14 +48,7 @@ impl<'a> Request<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Location<'a> {
|
||||
Location {
|
||||
href: self.uri.to_string(),
|
||||
pathname: &self.uri.path(),
|
||||
// TODO: handler search map
|
||||
search: HashMap::new(),
|
||||
search_str: &self.uri.query().unwrap_or(""),
|
||||
hash: "",
|
||||
}
|
||||
pub fn location(&self) -> Location {
|
||||
Location::from(self.uri.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::Request;
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse};
|
||||
use axum::Json;
|
||||
use erased_serde::Serialize;
|
||||
|
||||
@@ -15,6 +15,41 @@ pub enum Response {
|
||||
Props(Props),
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResponseInfo {
|
||||
redirect_destination: Option<String>,
|
||||
}
|
||||
|
||||
impl JsonResponseInfo {
|
||||
fn new(redirect_destination: Option<String>) -> JsonResponseInfo {
|
||||
JsonResponseInfo {
|
||||
redirect_destination,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResponse<'a> {
|
||||
data: Option<&'a dyn Serialize>,
|
||||
info: JsonResponseInfo,
|
||||
}
|
||||
|
||||
impl<'a> JsonResponse<'a> {
|
||||
fn new(props: &'a dyn Serialize) -> Self {
|
||||
JsonResponse {
|
||||
data: Some(props),
|
||||
info: JsonResponseInfo::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_redirect(destination: String) -> Self {
|
||||
JsonResponse {
|
||||
data: None,
|
||||
info: JsonResponseInfo::new(Some(destination)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub fn new(data: impl Serialize + 'static) -> Self {
|
||||
Props {
|
||||
@@ -32,25 +67,32 @@ impl Props {
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse {
|
||||
pub fn render_to_string(&self, req: Request) -> AxumResponse {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
let payload = Payload::new(&req, data).client_payload().unwrap();
|
||||
|
||||
match Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) {
|
||||
Ok(html) => (*http_code, Html(html)),
|
||||
Err(_) => (*http_code, Html("500 Internal server error".to_string())),
|
||||
match Js::render_to_string(Some(&payload)) {
|
||||
Ok(html) => (*http_code, Html(html)).into_response(),
|
||||
Err(_) => {
|
||||
(*http_code, Html("500 Internal server error".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Handle here other enum arms
|
||||
_ => todo!(),
|
||||
Self::Redirect(to) => Redirect::permanent(to).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json(&self) -> impl IntoResponse {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => (*http_code, Json(data)).into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json("{}")).into_response(),
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
(*http_code, Json(JsonResponse::new(data))).into_response()
|
||||
}
|
||||
Self::Redirect(destination) => (
|
||||
StatusCode::PERMANENT_REDIRECT,
|
||||
Json(JsonResponse::new_redirect(destination.to_string())),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
|
||||
use crate::manifest::load_manifest;
|
||||
use axum::routing::{get, Router};
|
||||
use ssr_rs::Ssr;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::{
|
||||
catch_all::catch_all, vite_reverse_proxy::vite_reverse_proxy,
|
||||
vite_websocket_proxy::vite_websocket_proxy,
|
||||
};
|
||||
|
||||
const DEV_PUBLIC_DIR: &str = "public";
|
||||
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||
|
||||
pub struct Server {
|
||||
router: Router<reqwest::Client>,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn init(router: Router<reqwest::Client>, mode: Mode) -> Server {
|
||||
Ssr::create_platform();
|
||||
|
||||
GLOBAL_MODE.set(mode).unwrap();
|
||||
|
||||
if mode == Mode::Prod {
|
||||
load_manifest()
|
||||
}
|
||||
|
||||
Server { router, mode }
|
||||
}
|
||||
|
||||
pub async fn start(&self) {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
|
||||
let fetch = reqwest::Client::new();
|
||||
|
||||
if self.mode == Mode::Dev {
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.route("/vite-server/", get(vite_websocket_proxy))
|
||||
.route("/vite-server/*path", get(vite_reverse_proxy))
|
||||
.fallback_service(ServeDir::new(DEV_PUBLIC_DIR).fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.expect("Failed to serve development server");
|
||||
} else {
|
||||
println!("\nProduction app ready at http://localhost:3000/");
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.fallback_service(ServeDir::new(PROD_PUBLIC_DIR).fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.expect("Failed to serve production server");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,48 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use ssr_rs::Ssr;
|
||||
use std::cell::RefCell;
|
||||
use std::fs::read_to_string;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// For the server side rendering we need to split the implementation between dev and prod.
|
||||
/// This completely remove the multi-thread optimization on dev but allow the dev server to
|
||||
/// update the SSR result without reloading the whole server.
|
||||
pub struct Js;
|
||||
|
||||
impl Js {
|
||||
pub fn render_to_string(payload: Option<&str>) -> Result<String, &'static str> {
|
||||
let mode = GLOBAL_MODE.get().expect("Failed to get GLOBAL_MODE");
|
||||
|
||||
if *mode == Mode::Dev {
|
||||
DevJs::render_to_string(payload)
|
||||
} else {
|
||||
ProdJs::SSR.with(|ssr| ssr.borrow_mut().render_to_string(payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProdJs;
|
||||
|
||||
impl ProdJs {
|
||||
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(),
|
||||
""
|
||||
read_to_string(PathBuf::from("./out/server/prod-server.js")).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DevJs;
|
||||
|
||||
impl DevJs {
|
||||
pub fn render_to_string(params: Option<&str>) -> Result<String, &'static str> {
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from("./.tuono/server/dev-server.js"))
|
||||
.expect("Server bundle not found"),
|
||||
"",
|
||||
)
|
||||
.unwrap()
|
||||
.render_to_string(params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use reqwest::Client;
|
||||
|
||||
const VITE_URL: &str = "http://localhost:3001/vite-server";
|
||||
|
||||
pub async fn vite_reverse_proxy(
|
||||
State(client): State<Client>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match client.get(format!("{VITE_URL}/{path}")).send().await {
|
||||
Ok(res) => {
|
||||
let mut response_builder = Response::builder().status(res.status().as_u16());
|
||||
|
||||
{
|
||||
let headers = response_builder.headers_mut().unwrap();
|
||||
res.headers().into_iter().for_each(|(name, value)| {
|
||||
let name = HeaderName::from_bytes(name.as_ref()).unwrap();
|
||||
let value = HeaderValue::from_bytes(value.as_ref()).unwrap();
|
||||
headers.insert(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
response_builder
|
||||
.body(Body::from_stream(res.bytes_stream()))
|
||||
.unwrap()
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use axum::extract::ws::{self, WebSocket, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::{Error, Message};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::ClientRequestBuilder;
|
||||
|
||||
const VITE_WS: &str = "ws://localhost:3001/vite-server/";
|
||||
const VITE_WS_PROTOCOL: &str = "vite-hmr";
|
||||
|
||||
/// This is the entry point to proxy the vite's WebSocket.
|
||||
/// The proxy is needed for allowing all the development requests to point
|
||||
/// to localhost:3000/*. This enabled the framework to be developed in a Docker
|
||||
/// environment with just the 3000 port exposed.
|
||||
pub async fn vite_websocket_proxy(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||
ws.protocols([VITE_WS_PROTOCOL]).on_upgrade(handle_socket)
|
||||
}
|
||||
|
||||
async fn handle_socket(mut tuono_socket: WebSocket) {
|
||||
// Send back a message to confirm connection
|
||||
if tuono_socket
|
||||
.send(ws::Message::Ping("tuono connected".into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// If is error close the connection
|
||||
return;
|
||||
}
|
||||
|
||||
let vite_ws_request = ClientRequestBuilder::new(VITE_WS.parse().unwrap())
|
||||
.with_sub_protocol(VITE_WS_PROTOCOL)
|
||||
.into_client_request()
|
||||
.expect("Failed to create vite WS request");
|
||||
|
||||
let vite_socket = match connect_async(vite_ws_request).await {
|
||||
Ok((stream, _)) => {
|
||||
// Connected to vite's WebSocket
|
||||
stream
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect to vite's WebSocket. Error: {e}");
|
||||
// As fallback vite automatically connect to port 3001.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut vite_sender, mut vite_receiver) = vite_socket.split();
|
||||
let (mut tuono_sender, mut tuono_receiver) = tuono_socket.split();
|
||||
|
||||
// Handle browser messages.
|
||||
// Every message gets forwarded to the vite WebSocket
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = tuono_receiver.next().await {
|
||||
if let Ok(msg) = msg {
|
||||
let msg_to_vite = match msg.clone() {
|
||||
ws::Message::Text(str) => Message::Text(str),
|
||||
ws::Message::Pong(payload) => Message::Pong(payload),
|
||||
ws::Message::Ping(payload) => Message::Ping(payload),
|
||||
ws::Message::Binary(payload) => Message::Binary(payload),
|
||||
// Hard to match axum and tungstenite close payload.
|
||||
// Not a priority
|
||||
ws::Message::Close(_) => Message::Close(None),
|
||||
};
|
||||
|
||||
vite_sender
|
||||
.send(msg_to_vite)
|
||||
.await
|
||||
.expect("Failed to tunnel msg to vite's WebSocket");
|
||||
|
||||
msg
|
||||
} else {
|
||||
// Close browser's WebSocket connection.
|
||||
return;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Handle vite messages.
|
||||
// Every message gets forwarded to the browser.
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = vite_receiver.next().await {
|
||||
let msg_to_browser = match msg {
|
||||
Message::Text(str) => ws::Message::Text(str),
|
||||
Message::Ping(payload) => ws::Message::Ping(payload),
|
||||
Message::Pong(payload) => ws::Message::Pong(payload),
|
||||
Message::Binary(payload) => ws::Message::Binary(payload),
|
||||
// Hard to match axum and tungstenite close payload.
|
||||
// Not a priority
|
||||
Message::Close(_) => ws::Message::Close(None),
|
||||
_ => {
|
||||
eprintln!("Unexpected message from the vite WebSocket to the browser: {msg:?}");
|
||||
ws::Message::Text("Unhandled".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = tuono_sender.send(msg_to_browser).await {
|
||||
if err.to_string() != Error::AlreadyClosed.to_string() {
|
||||
eprintln!("Failed to send back message from vite to browser: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.1.7"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
|
||||
@@ -8,35 +8,34 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let fn_name = item.clone().sig.ident;
|
||||
|
||||
quote! {
|
||||
use axum::response::IntoResponse;
|
||||
use tuono_lib::axum::response::IntoResponse;
|
||||
use std::collections::HashMap;
|
||||
use axum::extract::{State, Path};
|
||||
use reqwest::Client;
|
||||
use tuono_lib::axum::extract::{State, Path};
|
||||
|
||||
#item
|
||||
|
||||
pub async fn route(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
State(client): State<tuono_lib::reqwest::Client>,
|
||||
request: tuono_lib::axum::extract::Request
|
||||
) -> impl IntoResponse {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
|
||||
#fn_name(req.clone(), client).await.render_to_string(req)
|
||||
}
|
||||
|
||||
pub async fn api(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
State(client): State<tuono_lib::reqwest::Client>,
|
||||
request: tuono_lib::axum::extract::Request
|
||||
) -> impl IntoResponse{
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
|
||||
#fn_name(req.clone(), client).await.json()
|
||||
}
|
||||
|
||||
@@ -7,5 +7,3 @@ mod handler;
|
||||
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
handler::handler_core(args, item)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Documentation
|
||||
|
||||
This folder contains the documentation related to the `tuono` framework.
|
||||
|
||||
The framework expects to enable SSG and MDX support in the next months.
|
||||
At that time a standalone website will be built with tuono to hold the whole documentation.
|
||||
|
||||
The following docs are currently actively maintained:
|
||||
- [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
|
||||
|
||||
## Contributions
|
||||
Any help or suggestion will be strongly appreciated.
|
||||
+168
-11
@@ -4,7 +4,13 @@ This tutorial is meant for giving you a sneak peek of the framework and is inten
|
||||
|
||||
The first part is about the project setup and the base knowledge needed to work with tuono. The actual tutorial starts at [Tutorial introduction](#tutorial-introduction).
|
||||
|
||||
> If you have already installed the tuono CLI you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
|
||||
> I'd love to hear your thoughts about the framework and the tutorial - feel free to reach me
|
||||
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or on Twitter (X) DMs
|
||||
[@valerioageno](https://twitter.com/valerioageno)
|
||||
|
||||
This tutorial is **not meant** for people that don't know React - in that case I suggest you to first read the [React doc](https://react.dev/);
|
||||
|
||||
Typescript and Rust knowledge is not a requirement though!
|
||||
|
||||
## Table of Content
|
||||
|
||||
@@ -17,6 +23,9 @@ The first part is about the project setup and the base knowledge needed to work
|
||||
* [Create a stand-alone component](#create-a-stand-alone-component)
|
||||
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||
* [Error handling](#error-handling)
|
||||
* [Seo and meta tags](#seo-and-meta-tags)
|
||||
* [Handle redirections](#handle-redirections)
|
||||
* [Building for production](#building-for-production)
|
||||
* [Conclusion](#conclusion)
|
||||
|
||||
## CLI Installation
|
||||
@@ -46,7 +55,7 @@ $ tuono new tuono-tutorial
|
||||
Get into the project folder and install the dependencies with:
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
$ npm install
|
||||
```
|
||||
|
||||
Open it with your favourite code editor.
|
||||
@@ -91,7 +100,7 @@ The file `index.rs` represents the server side capabilities for the index route.
|
||||
|
||||
- Passing server side props
|
||||
- Changing http status code
|
||||
- Redirect/Rewrite to a different route (Available soon)
|
||||
- Redirecting to a different route
|
||||
|
||||
## Tutorial introduction
|
||||
|
||||
@@ -99,6 +108,9 @@ Now that we have some knowledge about the project structure let’s start the re
|
||||
|
||||
The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately.
|
||||
|
||||
> If you have already installed the tuono CLI and you prefer read the code instead of writing it yourself
|
||||
you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
|
||||
|
||||
## Fetch all the pokemons
|
||||
|
||||
To start let’s fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file.
|
||||
@@ -108,6 +120,7 @@ Clear the `index.rs` file and paste:
|
||||
```rust
|
||||
// src/routes/index.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::Client;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
@@ -124,7 +137,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
@@ -273,7 +286,7 @@ Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.mod
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `pnpm i -D sass` and run again `tuono dev`
|
||||
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `npm i -D sass` and run again `tuono dev`
|
||||
|
||||
Then import the styles into the `PokemonLink` component as following:
|
||||
|
||||
@@ -317,6 +330,7 @@ Let’s first work on the server side file. Paste into the new `[pokemon].rs` fi
|
||||
```rust
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::Client;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
@@ -330,7 +344,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_pokemon(req: Request, fetch: Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
@@ -457,8 +471,9 @@ Let's see how it works!
|
||||
|
||||
```diff
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
-- use tuono_lib::reqwest::Client;
|
||||
++ use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
@@ -472,7 +487,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_pokemon(req: Request, fetch: Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
@@ -496,8 +511,9 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
|
||||
```diff
|
||||
// src/routes/index.rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
-- use tuono_lib::reqwest::Client;
|
||||
++ use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
@@ -514,7 +530,7 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
@@ -532,9 +548,150 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response
|
||||
If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon`) you will
|
||||
correctly receive a 404 status code in the console.
|
||||
|
||||
## Seo and meta tags
|
||||
|
||||
The website now works and the http errors are meaningful but we should also take care to be meaningful
|
||||
for the web crawlers. The best way to do it is to enrich the meta tags like the `<title>` and the
|
||||
`<description>`.
|
||||
|
||||
To do so `tuono` exposes also the `<Head />` component useful exactly for handling this scenario. Let's update the `/` and the
|
||||
`/pokemons/[pokemon]` routes with this.
|
||||
|
||||
```diff
|
||||
// src/routes/index.tsx
|
||||
import type { TuonoProps } from "tuono";
|
||||
++ import { Head } from "tuono"
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface IndexProps {
|
||||
results: Pokemon[]
|
||||
}
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
}: TuonoProps<IndexProps>): JSX.Element {
|
||||
if (!data?.results) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
++ <Head>
|
||||
++ <title>Tuono tutorial</title>
|
||||
++ </Head>
|
||||
<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>
|
||||
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
|
||||
{data.results.map((pokemon) => {
|
||||
return pokemon.name;
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
// src/routes/pokemons/[pokemon].tsx
|
||||
-- import { TuonoProps } from "tuono";
|
||||
++ import { TuonoProps, Head } from "tuono";
|
||||
import PokemonView from "../../components/PokemonView";
|
||||
|
||||
export default function Pokemon({ data }: TuonoProps): JSX.Element {
|
||||
-- return <PokemonView pokemon={data} />;
|
||||
++ return (
|
||||
++ <>
|
||||
++ <Head>
|
||||
++ <title>Pokemon: ${data?.name}</title>
|
||||
++ </Head>
|
||||
++ <PokemonView pokemon={data} />
|
||||
++ </>
|
||||
++ )
|
||||
}
|
||||
```
|
||||
|
||||
The `Head` component takes as children any valid HTML meta tag.
|
||||
|
||||
## Handle redirections
|
||||
|
||||
What if there is a pokemon among all of them that should be considered the GOAT? What
|
||||
we are going to do right now is creating a new route `/pokemons/GOAT` that points to the best
|
||||
pokemon of the first generation.
|
||||
|
||||
First let's create a new route by just creating an new file `/pokemons/GOAT.rs` and pasting the following code:
|
||||
|
||||
```rs
|
||||
// src/routes/pokemons/GOAT.rs
|
||||
use tuono_lib::{reqwest::Client, Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn redirect_to_goat(_: Request, _: Client) -> Response {
|
||||
// Of course the GOAT is mewtwo - feel free to select your favourite 😉
|
||||
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
Now let's create the button in the home page to actually point to it!
|
||||
|
||||
```diff
|
||||
// src/routes/index.tsx
|
||||
|
||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||
++ <PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||
{data.results.map((pokemon, i) => {
|
||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||
})}
|
||||
</ul>
|
||||
```
|
||||
|
||||
Now at [http://localhost:3000/](http:/localhost:3000/) you will find a new link at the beginning of the list.
|
||||
Click on it and see the application automatically redirecting you to your favourite pokemon's route!
|
||||
|
||||
## Building for production
|
||||
|
||||
The source now is ready to be released. Both server and client have been managed in a unoptimized way
|
||||
to easy the development experience. To build the project to the production state just run:
|
||||
|
||||
```shell
|
||||
$ tuono build
|
||||
```
|
||||
|
||||
This command just created the final assets within the `out` directory. To run then the prodiction server
|
||||
run:
|
||||
|
||||
```shell
|
||||
$ cargo run --release
|
||||
```
|
||||
|
||||
Check again [`http://localhost:3000/`](http://localhost:3000/) - This environment now has all the
|
||||
optimizations ready to unleash the power of a rust server that seamessly renders a React application!🚀
|
||||
|
||||
> Note: The `out` directory is not standalone. You can't rely just on it to run the production server.
|
||||
|
||||
## Conclusion
|
||||
|
||||
That’s it! You just created a multi thread full stack application with rust and react.
|
||||
|
||||
The project is still under heavy development and the script for the production build (`tuono build`) is not ready yet but
|
||||
The project is still under heavy development and many features are not ready yet but
|
||||
I hope you got the taste of what is like working with rust and react in the same stack.
|
||||
|
||||
As I mentioned in the introduction I'd love to hear what you thought about the framework and the tutorial - feel free to reach me
|
||||
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs [@valerioageno](https://twitter.com/valerioageno).
|
||||
|
||||
@@ -16,4 +16,13 @@ $ tuono new [NAME] --template [TEMPLATE]
|
||||
|
||||
`[TEMPLATE]` is the folder name.
|
||||
|
||||
## Troubleshooting for contributors
|
||||
|
||||
There is a common error that happens during example development due to
|
||||
pnpm workspace about the findings of different react versions.
|
||||
|
||||
To overcome this issue run within the single example folder:
|
||||
|
||||
```
|
||||
pnpm install --ignore-workspace && pnpm upgrade --save
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "tuono"
|
||||
path = ".tuono/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Tuono starter
|
||||
|
||||
This is the starter tuono project. To download it run in your terminal:
|
||||
|
||||
```shell
|
||||
$ tuono new [NAME]
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tuono": "link:../../packages/tuono"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
|
||||
interface RootRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
|
||||
return (
|
||||
<main className="main">
|
||||
<MDXProvider components={{}}>{children}</MDXProvider>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Index page
|
||||
@@ -0,0 +1,109 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fbfbfb;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 673px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: black;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
font-size: 200px;
|
||||
line-height: 200px;
|
||||
z-index: 0;
|
||||
letter-spacing: -2px;
|
||||
margin-left: -8px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title span {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 140px;
|
||||
height: 30px;
|
||||
border: solid 3px black;
|
||||
border-radius: 10px;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: white;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-left: 240px;
|
||||
position: relative;
|
||||
top: 25px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.rust {
|
||||
animation: rotate 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.react {
|
||||
top: 33px;
|
||||
left: 28px;
|
||||
animation: rotate 6s linear infinite reverse;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 30px;
|
||||
line-height: 30px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.subtitle-wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -8,11 +8,6 @@ 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"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use tuono_lib::reqwest;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -7,7 +8,7 @@ struct MyResponse<'a> {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
|
||||
async fn get_server_side_props(_req: Request, _fetch: reqwest::Client) -> Response {
|
||||
Response::Props(Props::new(MyResponse {
|
||||
subtitle: "The react / rust fullstack framework",
|
||||
}))
|
||||
|
||||
@@ -8,11 +8,6 @@ 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"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ export default function PokemonLink({
|
||||
id: number
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
href={`/pokemons/${pokemon.name}`}
|
||||
id={pokemon.name}
|
||||
>
|
||||
{pokemon.name}
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/routes/index.rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
@@ -17,8 +17,8 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
|
||||
match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
@@ -27,5 +27,5 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response
|
||||
"{}", // Return empty JSON
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/index.tsx
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import { Head, type TuonoProps } from 'tuono'
|
||||
import PokemonLink from '../components/PokemonLink'
|
||||
|
||||
interface Pokemon {
|
||||
@@ -19,6 +19,9 @@ export default function IndexPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Tuono tutorial</title>
|
||||
</Head>
|
||||
<header className="header">
|
||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||
Crates
|
||||
@@ -37,6 +40,7 @@ export default function IndexPage({
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||
<PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||
{data.results.map((pokemon, i) => {
|
||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/routes/pokemons/GOAT.rs
|
||||
use tuono_lib::{reqwest::Client, Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn redirect_to_goat(_: Request, _: Client) -> Response {
|
||||
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::reqwest::{Client, StatusCode};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
@@ -14,11 +14,11 @@ struct Pokemon {
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
async fn get_pokemon(req: Request, fetch: Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||
@@ -30,5 +30,5 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
"{}",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import { Head, type TuonoProps } from 'tuono'
|
||||
import PokemonView from '../../components/PokemonView'
|
||||
|
||||
interface Pokemon {
|
||||
@@ -11,5 +11,12 @@ interface Pokemon {
|
||||
export default function PokemonPage({
|
||||
data,
|
||||
}: TuonoProps<Pokemon>): JSX.Element {
|
||||
return <PokemonView pokemon={data} />
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Pokemon: {data?.name}</title>
|
||||
</Head>
|
||||
<PokemonView pokemon={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-8
@@ -2,17 +2,19 @@
|
||||
"name": "workspace",
|
||||
"packageManager": "pnpm@9.1.1",
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"format": "turbo format",
|
||||
"format:check": "turbo format:check",
|
||||
"types": "turbo types",
|
||||
"test": "turbo test"
|
||||
"dev": "turbo dev --filter tuono",
|
||||
"build": "turbo build --filter tuono",
|
||||
"lint": "turbo lint --filter tuono",
|
||||
"format": "turbo format --filter tuono",
|
||||
"format:check": "turbo format:check --filter tuono",
|
||||
"types": "turbo types --filter tuono",
|
||||
"test": "turbo test",
|
||||
"test:watch": "turbo test:watch"
|
||||
},
|
||||
"workspaces": [
|
||||
"tuono",
|
||||
"tuono-lazy-fn-vite-plugin"
|
||||
"tuono-lazy-fn-vite-plugin",
|
||||
"tuono-fs-router-vite-plugin"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# tuono-fs-router-vite-plugin
|
||||
|
||||
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||
|
||||
This package specifically handles the file system based routing.
|
||||
|
||||
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin is strongly inspired by the [@tanstack/router](https://tanstack.com/router/latest)
|
||||
route generator plugin.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "tuono-fs-router-vite-plugin",
|
||||
"version": "0.8.0",
|
||||
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
|
||||
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||
"types": "tsc --noEmit",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"main": "dist/cjs/index.cjs",
|
||||
"module": "dist/esm/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/types": "^7.24.0",
|
||||
"prettier": "^3.2.4",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/config": "^0.7.11",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"vitest": "^1.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildRouteConfig } from './build-route-config'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
filePath: 'posts/my-post.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/my-post.tsx',
|
||||
routePath: '/posts/my-post',
|
||||
variableName: 'PostsMyPost',
|
||||
parent: {
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
path: '/posts/__root',
|
||||
cleanedPath: '/posts',
|
||||
children: undefined,
|
||||
},
|
||||
path: '/posts/my-post',
|
||||
cleanedPath: '/posts/my-post',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/index.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
|
||||
routePath: '/posts/',
|
||||
variableName: 'PostsIndex',
|
||||
parent: {
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/home/valerio/Documents/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
path: '/posts/__root',
|
||||
cleanedPath: '/posts',
|
||||
children: undefined,
|
||||
},
|
||||
path: '/posts/',
|
||||
cleanedPath: '/posts/',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/[post].tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
|
||||
routePath: '/posts/',
|
||||
variableName: 'PostspostIndex',
|
||||
parent: {
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
path: '/posts/__root',
|
||||
cleanedPath: '/posts',
|
||||
children: undefined,
|
||||
},
|
||||
path: '/posts/',
|
||||
cleanedPath: '/posts/',
|
||||
},
|
||||
]
|
||||
|
||||
describe('buildRouteConfig works', async () => {
|
||||
it('Should build the correct config', () => {
|
||||
const expectedConfig =
|
||||
'PostsMyPostRoute,PostsIndexRoute,PostspostIndexRoute'
|
||||
const config = buildRouteConfig(routes)
|
||||
expect(config).toStrictEqual(expectedConfig)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { spaces } from './utils'
|
||||
import type { RouteNode } from './types'
|
||||
|
||||
export function buildRouteConfig(nodes: RouteNode[], depth = 1): string {
|
||||
const children = nodes.map((node) => {
|
||||
const route = `${node.variableName}Route`
|
||||
|
||||
if (node.children?.length) {
|
||||
const childConfigs = buildRouteConfig(node.children, depth + 1)
|
||||
return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])`
|
||||
}
|
||||
|
||||
return route
|
||||
})
|
||||
|
||||
return children.filter(Boolean).join(`,`)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ROUTES_FOLDER = './src/routes/'
|
||||
export const ROOT_PATH_ID = '__root'
|
||||
export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
+20
-91
@@ -1,27 +1,26 @@
|
||||
import * as fs from 'fs'
|
||||
import * as fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { buildRouteConfig } from './build-route-config'
|
||||
import { hasParentRoute } from './has-parent-route'
|
||||
|
||||
import {
|
||||
cleanPath,
|
||||
determineNodePath,
|
||||
multiSortBy,
|
||||
spaces,
|
||||
replaceBackslash,
|
||||
removeExt,
|
||||
routePathToVariable,
|
||||
removeGroups,
|
||||
removeLastSlash,
|
||||
removeUnderscores,
|
||||
removeLayoutSegments,
|
||||
} from './utils'
|
||||
|
||||
import type { Config, RouteNode, RouteSubNode } from './types'
|
||||
import type { Config, RouteNode } from './types'
|
||||
import { ROUTES_FOLDER, ROOT_PATH_ID, GENERATED_ROUTE_TREE } from './constants'
|
||||
|
||||
import { format } from 'prettier'
|
||||
|
||||
const ROUTES_FOLDER = './src/routes/'
|
||||
const ROOT_PATH_ID = '__root'
|
||||
const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
import { sortRouteNodes } from './sort-route-nodes'
|
||||
|
||||
let latestTask = 0
|
||||
|
||||
@@ -50,7 +49,7 @@ async function getRouteNodes(
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
await recurse(relativePath)
|
||||
} else if (fullPath.match(/\.(tsx|ts|jsx|js)$/)) {
|
||||
} else if (fullPath.match(/\.(tsx|ts|jsx|js|mdx)$/)) {
|
||||
const filePath = replaceBackslash(path.join(dir, dirent.name))
|
||||
const filePathNoExt = removeExt(filePath)
|
||||
let routePath =
|
||||
@@ -60,9 +59,6 @@ async function getRouteNodes(
|
||||
|
||||
// Remove the index from the route path and
|
||||
// if the route path is empty, use `/'
|
||||
|
||||
const isLoader = routePath.includes('-loading')
|
||||
|
||||
if (routePath === 'index') {
|
||||
routePath = '/'
|
||||
}
|
||||
@@ -73,7 +69,6 @@ async function getRouteNodes(
|
||||
filePath,
|
||||
fullPath,
|
||||
routePath,
|
||||
isLoader,
|
||||
variableName,
|
||||
})
|
||||
} else if (fullPath.match(/\.(rs)$/)) {
|
||||
@@ -99,38 +94,6 @@ async function getRouteNodes(
|
||||
return { routeNodes, rustHandlersNodes }
|
||||
}
|
||||
|
||||
export function hasParentRoute(
|
||||
routes: RouteNode[],
|
||||
node: RouteNode,
|
||||
routePathToCheck: string | undefined,
|
||||
): RouteNode | null {
|
||||
if (!routePathToCheck || routePathToCheck === '/') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sortedNodes = multiSortBy(routes, [
|
||||
(d): number => d.routePath.length * -1,
|
||||
(d): string | undefined => d.variableName,
|
||||
]).filter((d) => d.routePath !== `/${ROOT_PATH_ID}`)
|
||||
|
||||
for (const route of sortedNodes) {
|
||||
if (route.routePath === '/') continue
|
||||
|
||||
if (
|
||||
routePathToCheck.startsWith(`${route.routePath}/`) &&
|
||||
route.routePath !== routePathToCheck
|
||||
) {
|
||||
return route
|
||||
}
|
||||
}
|
||||
|
||||
const segments = routePathToCheck.split('/')
|
||||
segments.pop() // Remove the last segment
|
||||
const parentRoutePath = segments.join('/')
|
||||
|
||||
return hasParentRoute(routes, node, parentRoutePath)
|
||||
}
|
||||
|
||||
export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
if (!isFirst) {
|
||||
isFirst = true
|
||||
@@ -153,27 +116,12 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
|
||||
await getRouteNodes(config)
|
||||
|
||||
const preRouteNodes = multiSortBy(beforeRouteNodes, [
|
||||
(d): number => (d.routePath === '/' ? -1 : 1),
|
||||
(d): number => d.routePath.split('/').length,
|
||||
(d): number => (d.filePath.match(/[./]index[.]/) ? 1 : -1),
|
||||
(d): number =>
|
||||
d.filePath.match(
|
||||
/[./](component|errorComponent|pendingComponent|loader|lazy)[.]/,
|
||||
)
|
||||
? 1
|
||||
: -1,
|
||||
(d): number => (d.filePath.match(/[./]route[.]/) ? -1 : 1),
|
||||
(d): number => (d.routePath.endsWith('/') ? -1 : 1),
|
||||
(d): string => d.routePath,
|
||||
]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || ''))
|
||||
const preRouteNodes = sortRouteNodes(beforeRouteNodes)
|
||||
|
||||
const routePiecesByPath: Record<string, RouteSubNode> = {}
|
||||
const routeNodes: RouteNode[] = []
|
||||
|
||||
// Loop over the flat list of routeNodes and
|
||||
// build up a tree based on the routeNodes' routePath
|
||||
const routeNodes: RouteNode[] = []
|
||||
|
||||
const handleNode = async (node: RouteNode): Promise<void> => {
|
||||
const parentRoute = hasParentRoute(routeNodes, node, node.routePath)
|
||||
|
||||
@@ -181,8 +129,8 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
|
||||
node.path = determineNodePath(node)
|
||||
|
||||
node.cleanedPath = removeGroups(
|
||||
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
|
||||
node.cleanedPath = removeLastSlash(
|
||||
removeGroups(removeUnderscores(removeLayoutSegments(node.path)) ?? ''),
|
||||
)
|
||||
|
||||
if (node.parent) {
|
||||
@@ -196,29 +144,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
await handleNode(node)
|
||||
}
|
||||
|
||||
function buildRouteConfig(nodes: RouteNode[], depth = 1): string {
|
||||
const children = nodes.map((node) => {
|
||||
if (node.isLayout) {
|
||||
return
|
||||
}
|
||||
|
||||
if (node.isLayout && !node.children?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const route = `${node.variableName}Route`
|
||||
|
||||
if (node.children?.length) {
|
||||
const childConfigs = buildRouteConfig(node.children, depth + 1)
|
||||
return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])`
|
||||
}
|
||||
|
||||
return route
|
||||
})
|
||||
|
||||
return children.filter(Boolean).join(`,`)
|
||||
}
|
||||
|
||||
const routeConfigChildrenText = buildRouteConfig(routeNodes)
|
||||
|
||||
const sortedRouteNodes = multiSortBy(routeNodes, [
|
||||
@@ -230,9 +155,10 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
|
||||
const imports = [
|
||||
...sortedRouteNodes.map((node) => {
|
||||
const extension = node.filePath.endsWith('mdx') ? '.mdx' : ''
|
||||
return `const ${
|
||||
node.variableName
|
||||
}Import = lazy(() => import('./${replaceBackslash(
|
||||
}Import = dynamic(() => import('./${replaceBackslash(
|
||||
removeExt(
|
||||
path.relative(
|
||||
path.dirname(config.generatedRouteTree),
|
||||
@@ -240,13 +166,16 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
),
|
||||
false,
|
||||
),
|
||||
)}'))`
|
||||
)}${extension}'))`
|
||||
}),
|
||||
].join('\n')
|
||||
|
||||
const createRoutes = [
|
||||
...sortedRouteNodes.map((node) => {
|
||||
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import })`
|
||||
const isRoot = node.routePath.endsWith(ROOT_PATH_ID)
|
||||
const rootDeclaration = isRoot ? ', isRoot: true' : ''
|
||||
|
||||
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import${rootDeclaration} })`
|
||||
}),
|
||||
].join('\n')
|
||||
|
||||
@@ -256,7 +185,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
return [
|
||||
`const ${node.variableName}Route = ${node.variableName}.update({
|
||||
${[
|
||||
`path: '${node.cleanedPath}'`,
|
||||
!node.path?.endsWith(ROOT_PATH_ID) && `path: '${node.cleanedPath}'`,
|
||||
`getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
|
||||
rustHandlersNodes.includes(node.path || '')
|
||||
? 'hasHandler: true'
|
||||
@@ -272,7 +201,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
|
||||
const routeImports = [
|
||||
'// This file is auto-generated by Tuono',
|
||||
"import { createRoute, lazy } from 'tuono'",
|
||||
"import { createRoute, dynamic } from 'tuono'",
|
||||
[
|
||||
`import RootImport from './${replaceBackslash(
|
||||
path.relative(
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hasParentRoute } from './has-parent-route'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
filePath: 'posts/[post].tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/[post].tsx',
|
||||
routePath: '/posts/[post]',
|
||||
variableName: 'Postspost',
|
||||
path: '/posts/[post]',
|
||||
cleanedPath: '/posts/[post]',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
path: '/posts/__root',
|
||||
cleanedPath: '/posts',
|
||||
},
|
||||
{
|
||||
filePath: 'index.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/index.tsx',
|
||||
routePath: '/',
|
||||
variableName: 'Index',
|
||||
path: '/',
|
||||
cleanedPath: '/',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/my-post.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/my-post.tsx',
|
||||
routePath: '/posts/my-post',
|
||||
variableName: 'PostsMyPost',
|
||||
path: '/posts/my-post',
|
||||
cleanedPath: '/posts/my-post',
|
||||
},
|
||||
]
|
||||
|
||||
const parent = {
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
path: '/posts/__root',
|
||||
cleanedPath: '/posts',
|
||||
}
|
||||
|
||||
const myPost = {
|
||||
filePath: 'posts/my-post.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/my-post.tsx',
|
||||
routePath: '/posts/my-post',
|
||||
variableName: 'PostsMyPost',
|
||||
path: '/posts/my-post',
|
||||
cleanedPath: '/posts/my-post',
|
||||
}
|
||||
|
||||
const dynamicRoute = {
|
||||
filePath: 'posts/[post].tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/[post].tsx',
|
||||
routePath: '/posts/[post]',
|
||||
variableName: 'Postspost',
|
||||
path: '/posts/[post]',
|
||||
cleanedPath: '/posts/[post]',
|
||||
}
|
||||
|
||||
describe('hasParentRoute works', async () => {
|
||||
it('Should detect parent route', () => {
|
||||
const parentRoute = hasParentRoute(routes, myPost, myPost.path)
|
||||
expect(parentRoute).toStrictEqual(parent)
|
||||
})
|
||||
|
||||
it('Should detect parent route for dynamic routes', () => {
|
||||
const parentRoute = hasParentRoute(routes, dynamicRoute, dynamicRoute.path)
|
||||
expect(parentRoute).toStrictEqual(parent)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ROOT_PATH_ID } from './constants'
|
||||
import { multiSortBy } from './utils'
|
||||
import type { RouteNode } from './types'
|
||||
|
||||
export function hasParentRoute(
|
||||
routes: RouteNode[],
|
||||
node: RouteNode,
|
||||
routePathToCheck = '/',
|
||||
): RouteNode | null {
|
||||
const segments = routePathToCheck.split('/')
|
||||
segments.pop() // Remove the last segment
|
||||
const parentRoutePath = segments.join('/')
|
||||
|
||||
if (!parentRoutePath || parentRoutePath === '/') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sortedNodes = multiSortBy(routes, [
|
||||
(d): number => d.routePath.length * -1,
|
||||
(d): string | undefined => d.variableName,
|
||||
])
|
||||
// Exclude base __root file
|
||||
.filter((d) => d.routePath !== `/${ROOT_PATH_ID}`)
|
||||
|
||||
for (const route of sortedNodes) {
|
||||
if (route.routePath === '/') continue
|
||||
|
||||
if (
|
||||
route.routePath.startsWith(parentRoutePath) &&
|
||||
route.routePath.endsWith(ROOT_PATH_ID)
|
||||
) {
|
||||
return route
|
||||
}
|
||||
}
|
||||
|
||||
return hasParentRoute(routes, node, parentRoutePath)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { routeGenerator } from './generator'
|
||||
|
||||
import { normalize } from 'path'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
|
||||
let lock = false
|
||||
|
||||
export default function RouterGenerator(): 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sortRouteNodes } from './sort-route-nodes'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
filePath: 'index.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/index.tsx',
|
||||
routePath: '/',
|
||||
variableName: 'Index',
|
||||
path: '/',
|
||||
cleanedPath: '/',
|
||||
},
|
||||
{
|
||||
filePath: 'about.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/about.tsx',
|
||||
routePath: '/about',
|
||||
variableName: 'About',
|
||||
path: '/about',
|
||||
cleanedPath: '/about',
|
||||
},
|
||||
{
|
||||
filePath: '__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/__root.tsx',
|
||||
routePath: '/__root',
|
||||
variableName: 'root',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/[post].tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/[post].tsx',
|
||||
routePath: '/posts/[post]',
|
||||
variableName: 'Postspost',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/my-post.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/my-post.tsx',
|
||||
routePath: '/posts/my-post',
|
||||
variableName: 'PostsMyPost',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/index.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
|
||||
routePath: '/posts/',
|
||||
variableName: 'PostsIndex',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
},
|
||||
]
|
||||
|
||||
const expectedSorting = [
|
||||
{
|
||||
filePath: 'index.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/index.tsx',
|
||||
routePath: '/',
|
||||
variableName: 'Index',
|
||||
path: '/',
|
||||
cleanedPath: '/',
|
||||
},
|
||||
{
|
||||
filePath: 'about.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/about.tsx',
|
||||
routePath: '/about',
|
||||
variableName: 'About',
|
||||
path: '/about',
|
||||
cleanedPath: '/about',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/__root.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/__root.tsx',
|
||||
routePath: '/posts/__root',
|
||||
variableName: 'Postsroot',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/my-post.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/my-post.tsx',
|
||||
routePath: '/posts/my-post',
|
||||
variableName: 'PostsMyPost',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/index.tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/index.tsx',
|
||||
routePath: '/posts/',
|
||||
variableName: 'PostsIndex',
|
||||
},
|
||||
{
|
||||
filePath: 'posts/[post].tsx',
|
||||
fullPath:
|
||||
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root-dynamic/routes/posts/[post].tsx',
|
||||
routePath: '/posts/[post]',
|
||||
variableName: 'Postspost',
|
||||
},
|
||||
]
|
||||
|
||||
describe('sortRouteNodes works', async () => {
|
||||
it('Should correctly sort the nodes', () => {
|
||||
const sorted = sortRouteNodes(routes)
|
||||
expect(sorted).toStrictEqual(expectedSorting)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RouteNode } from './types'
|
||||
import { multiSortBy } from './utils'
|
||||
import { ROOT_PATH_ID } from './constants'
|
||||
|
||||
// Routes need to be sorted in order to iterate over the handleNode fn
|
||||
// with first the items that might be parent routes
|
||||
export const sortRouteNodes = (routes: RouteNode[]): RouteNode[] =>
|
||||
multiSortBy(routes, [
|
||||
(d): number => (d.routePath === '/' ? -1 : 1),
|
||||
(d): number => d.routePath.split('/').length,
|
||||
// Dynamic route
|
||||
(d): number => (d.routePath.endsWith(']') ? 1 : -1),
|
||||
(d): number => (d.filePath.match(/[./]index[.]/) ? 1 : -1),
|
||||
(d): number => (d.filePath.match(/[./]route[.]/) ? -1 : 1),
|
||||
(d): number => (d.routePath.endsWith('/') ? -1 : 1),
|
||||
(d): string => d.routePath,
|
||||
]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || ''))
|
||||
-12
@@ -5,23 +5,11 @@ export interface RouteNode {
|
||||
path?: string
|
||||
cleanedPath?: string
|
||||
isLayout?: boolean
|
||||
isLoader: boolean
|
||||
children?: RouteNode[]
|
||||
parent?: RouteNode
|
||||
variableName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface RouteSubNode {
|
||||
component?: RouteNode
|
||||
errorComponent?: RouteNode
|
||||
pendingComponent?: RouteNode
|
||||
loader?: RouteNode
|
||||
lazy?: RouteNode
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
folderName: string
|
||||
generatedRouteTree: string
|
||||
+6
@@ -100,6 +100,12 @@ export function trimPathLeft(pathToTrim: string): string {
|
||||
return pathToTrim === '/' ? pathToTrim : pathToTrim.replace(/^\/{1,}/, '')
|
||||
}
|
||||
|
||||
export function removeLastSlash(str: string): string {
|
||||
if (str.length > 1 && str.endsWith('/'))
|
||||
return str.substring(0, str.length - 1)
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* The `node.path` is used as the `id` in the route definition.
|
||||
* This function checks if the given node has a parent and if so, it determines the correct path for the given node.
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'fs/promises'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { routeGenerator } from '../src/generator'
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about.mdx'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const PostsrootImport = dynamic(() => import('./routes/posts/__root'))
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostspostImport = dynamic(() => import('./routes/posts/[post]'))
|
||||
const PostsIndexImport = dynamic(() => import('./routes/posts/index'))
|
||||
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const Postsroot = createRoute({ component: PostsrootImport, isRoot: true })
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
const Postspost = createRoute({ component: PostspostImport })
|
||||
const PostsIndex = createRoute({ component: PostsIndexImport })
|
||||
const PostsMyPost = createRoute({ component: PostsMyPostImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const PostsrootRoute = Postsroot.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const PostspostRoute = Postspost.update({
|
||||
path: '/posts/[post]',
|
||||
getParentRoute: () => PostsrootRoute,
|
||||
})
|
||||
|
||||
const PostsIndexRoute = PostsIndex.update({
|
||||
path: '/posts',
|
||||
getParentRoute: () => PostsrootRoute,
|
||||
})
|
||||
|
||||
const PostsMyPostRoute = PostsMyPost.update({
|
||||
path: '/posts/my-post',
|
||||
getParentRoute: () => PostsrootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
IndexRoute,
|
||||
AboutRoute,
|
||||
PostsrootRoute.addChildren([
|
||||
PostsMyPostRoute,
|
||||
PostsIndexRoute,
|
||||
PostspostRoute,
|
||||
]),
|
||||
PostsMyPostRoute,
|
||||
PostsIndexRoute,
|
||||
PostspostRoute,
|
||||
])
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
+1
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,40 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
const PostsMyPost = createRoute({ component: PostsMyPostImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const PostsMyPostRoute = PostsMyPost.update({
|
||||
path: '/posts/my-post',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
IndexRoute,
|
||||
AboutRoute,
|
||||
PostsMyPostRoute,
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,29 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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',
|
||||
}),
|
||||
)
|
||||
@@ -1 +1,8 @@
|
||||
# tuono-lazy-fn-vite-plugin
|
||||
|
||||
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||
|
||||
This package specifically handles the transpiling of the `dynamic` function
|
||||
allowing custom componenents to be lazy loaded but also server side rendered.
|
||||
|
||||
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono-lazy-fn-vite-plugin",
|
||||
"version": "0.1.7",
|
||||
"version": "0.8.0",
|
||||
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
@@ -9,6 +9,7 @@
|
||||
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||
"types": "tsc --noEmit",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const TUONO_DYNAMIC_FN_ID = 'dynamic'
|
||||
export const REACT_LAZY_FN_ID = 'lazy'
|
||||
export const TUONO_MAIN_PACKAGE = 'tuono'
|
||||
@@ -2,6 +2,12 @@ import type { Plugin } from 'vite'
|
||||
import * as babel from '@babel/core'
|
||||
import type { PluginItem } from '@babel/core'
|
||||
|
||||
import {
|
||||
TUONO_MAIN_PACKAGE,
|
||||
TUONO_DYNAMIC_FN_ID,
|
||||
REACT_LAZY_FN_ID,
|
||||
} from './constants'
|
||||
|
||||
import * as t from '@babel/types'
|
||||
|
||||
import type {
|
||||
@@ -13,15 +19,16 @@ import type {
|
||||
} from '@babel/types'
|
||||
|
||||
/**
|
||||
* This plugin just removes the `lazy` imported function from any tuono import
|
||||
* This plugin just removes the `dynamic` imported function from any tuono import
|
||||
*/
|
||||
const RemoveTuonoLazyImport: PluginItem = {
|
||||
name: 'remove-tuono-lazy-import-plugin',
|
||||
visitor: {
|
||||
ImportSpecifier: (path) => {
|
||||
if ((path.node.imported as Identifier).name === 'lazy') {
|
||||
if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) {
|
||||
if (
|
||||
(path.parentPath.node as ImportDeclaration).source.value === 'tuono'
|
||||
(path.parentPath.node as ImportDeclaration).source.value ===
|
||||
TUONO_MAIN_PACKAGE
|
||||
) {
|
||||
path.remove()
|
||||
}
|
||||
@@ -31,11 +38,13 @@ const RemoveTuonoLazyImport: PluginItem = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Import { lazy } from 'react'
|
||||
* This plugin adds: "Import { lazy } from 'react'"
|
||||
* and translate dynamic call into a React.lazy call
|
||||
*/
|
||||
const ImportReactLazy: PluginItem = {
|
||||
name: 'import-react-lazy-plugin',
|
||||
visitor: {
|
||||
// Add the import statement
|
||||
Program: (path: any) => {
|
||||
let isReactImported = false
|
||||
|
||||
@@ -50,25 +59,36 @@ const ImportReactLazy: PluginItem = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!isReactImported) {
|
||||
const importDeclaration = t.importDeclaration(
|
||||
[t.importSpecifier(t.identifier('lazy'), t.identifier('lazy'))],
|
||||
[
|
||||
t.importSpecifier(
|
||||
t.identifier(REACT_LAZY_FN_ID),
|
||||
t.identifier(REACT_LAZY_FN_ID),
|
||||
),
|
||||
],
|
||||
t.stringLiteral('react'),
|
||||
)
|
||||
path.unshiftContainer('body', importDeclaration)
|
||||
}
|
||||
},
|
||||
// Update lazy function name from `dynamic` to `lazy`
|
||||
CallExpression: (path: any) => {
|
||||
if (path.node.callee?.name === TUONO_DYNAMIC_FN_ID) {
|
||||
path.node.callee.name = REACT_LAZY_FN_ID
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* For the server side we need to statically import the lazy loaded components
|
||||
*/
|
||||
const TurnLazyToStaticImport: PluginItem = {
|
||||
name: 'turn-lazy-to-static-import-plugin',
|
||||
const TurnLazyIntoStaticImport: PluginItem = {
|
||||
name: 'turn-lazy-into-static-import-plugin',
|
||||
visitor: {
|
||||
VariableDeclaration: (path) => {
|
||||
path.node.declarations.forEach((el) => {
|
||||
const init = el.init as CallExpression
|
||||
if ((init.callee as Identifier).name === 'lazy') {
|
||||
if ((init.callee as Identifier).name === TUONO_DYNAMIC_FN_ID) {
|
||||
const importName = (el.id as Identifier).name
|
||||
const importPath = (
|
||||
(
|
||||
@@ -96,14 +116,17 @@ export function LazyLoadingPlugin(): Plugin {
|
||||
name: 'vite-plugin-tuono-lazy-loading',
|
||||
enforce: 'pre',
|
||||
transform(code, _id, opts): string | undefined | null {
|
||||
if (code.includes('lazy') && code.includes('tuono')) {
|
||||
if (
|
||||
code.includes(TUONO_DYNAMIC_FN_ID) &&
|
||||
code.includes(TUONO_MAIN_PACKAGE)
|
||||
) {
|
||||
const res = babel.transformSync(code, {
|
||||
plugins: [
|
||||
['@babel/plugin-syntax-jsx', {}],
|
||||
['@babel/plugin-syntax-typescript', { isTSX: true }],
|
||||
[RemoveTuonoLazyImport],
|
||||
[!opts?.ssr ? ImportReactLazy : []],
|
||||
[opts?.ssr ? TurnLazyToStaticImport : []],
|
||||
[opts?.ssr ? TurnLazyIntoStaticImport : []],
|
||||
],
|
||||
sourceMaps: true,
|
||||
})
|
||||
|
||||
@@ -2,10 +2,10 @@ import { it, expect, describe } from 'vitest'
|
||||
import { LazyLoadingPlugin } from '../src'
|
||||
|
||||
const SOURCE_CODE = `
|
||||
import { createRoute, lazy } from 'tuono'
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
const IndexImport = lazy(() => import('./../src/routes/index'))
|
||||
const PokemonspokemonImport = lazy(
|
||||
const IndexImport = dynamic(() => import('./../src/routes/index'))
|
||||
const PokemonspokemonImport = dynamic(
|
||||
() => import('./../src/routes/pokemons/[pokemon]'),
|
||||
)
|
||||
`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.1.7",
|
||||
"version": "0.8.0",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
@@ -9,6 +9,7 @@
|
||||
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||
"types": "tsc --noEmit",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"type": "module",
|
||||
@@ -83,11 +84,13 @@
|
||||
"@babel/template": "^7.24.0",
|
||||
"@babel/traverse": "^7.24.1",
|
||||
"@babel/types": "^7.24.0",
|
||||
"@mdx-js/rollup": "^3.0.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^20.12.7",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"prettier": "^3.2.4",
|
||||
"react-meta-tags": "^1.0.1",
|
||||
"tuono-fs-router-vite-plugin": "workspace:*",
|
||||
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||
"vite": "^5.2.11",
|
||||
"zustand": "4.4.7"
|
||||
@@ -100,10 +103,17 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^3.2.4",
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"keywords": [
|
||||
"react",
|
||||
"typescript",
|
||||
"fullstack",
|
||||
"framework",
|
||||
"rust"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { build, createServer, InlineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { ViteFsRouter } from './tuono-vite-plugin'
|
||||
import ViteFsRouter from 'tuono-fs-router-vite-plugin'
|
||||
import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||
import mdx from '@mdx-js/rollup'
|
||||
|
||||
const BASE_CONFIG: InlineConfig = {
|
||||
root: '.tuono',
|
||||
@@ -9,9 +10,16 @@ const BASE_CONFIG: InlineConfig = {
|
||||
publicDir: '../public',
|
||||
cacheDir: 'cache',
|
||||
envDir: '../',
|
||||
plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()],
|
||||
plugins: [
|
||||
{ enforce: 'pre', ...mdx() },
|
||||
react(),
|
||||
ViteFsRouter(),
|
||||
LazyLoadingPlugin(),
|
||||
],
|
||||
}
|
||||
|
||||
const VITE_PORT = 3001
|
||||
|
||||
export function developmentSSRBundle() {
|
||||
;(async () => {
|
||||
await build({
|
||||
@@ -43,8 +51,10 @@ export function developmentCSRWatch() {
|
||||
;(async () => {
|
||||
const server = await createServer({
|
||||
...BASE_CONFIG,
|
||||
// Entry point for the development vite proxy
|
||||
base: '/vite-server/',
|
||||
server: {
|
||||
port: 3001,
|
||||
port: VITE_PORT,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
@@ -77,7 +87,7 @@ export function buildProd() {
|
||||
...BASE_CONFIG,
|
||||
build: {
|
||||
ssr: true,
|
||||
minify: false,
|
||||
minify: true,
|
||||
outDir: '../out/server',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user