mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-31 06:42:45 -07:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19782a2dbe | |||
| b755e658f5 | |||
| cc0279b964 | |||
| 099da549b5 | |||
| 92a76032eb | |||
| cd17eebf96 | |||
| 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 |
@@ -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
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
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:
|
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
|
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
|
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",
|
"crates/tuono_lib_macros",
|
||||||
]
|
]
|
||||||
exclude = [
|
exclude = [
|
||||||
"examples/playground",
|
"examples/mdx",
|
||||||
"examples/tuono",
|
"examples/tuono",
|
||||||
"examples/tutorial"
|
"examples/tutorial"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px">
|
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px">
|
||||||
</p>
|
</p>
|
||||||
<h1 align="center">Tuono<br>The react/rust fullstack framework</h1>
|
<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">
|
<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/rust.yml/badge.svg" />
|
||||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.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/).
|
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||||
Why Tuono? Just a badass name.
|
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
|
## 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:
|
Tuono is a fullstack React framework with the server side written in Rust.
|
||||||
super powered server and amazing development experience.
|
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
|
## Features
|
||||||
|
|
||||||
- [x] Typescript
|
- 🟦 Typescript
|
||||||
- [x] FS routing
|
- 🌐 Routing
|
||||||
- [x] Hot Module Reload
|
- 🔥 Hot Module Reload
|
||||||
- [x] CSS modules
|
- 🍭 CSS modules
|
||||||
- [x] Rust based SSR
|
- 🧬 Server Side Rendering
|
||||||
- [x] Multi thread backend
|
- 🧵 Multi thread backend
|
||||||
- [x] Development environment
|
- ⌨️ MDX support
|
||||||
- [ ] Create custom APIs
|
- ⚙️ Build optimizations
|
||||||
- [ ] Image optimization
|
- Custom APIs*
|
||||||
- [ ] Build optimization
|
- Image optimization*
|
||||||
- [ ] Server streamed content
|
- 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
|
## Requirements
|
||||||
|
|
||||||
- rust
|
- rust
|
||||||
- cargo
|
- cargo
|
||||||
- node
|
- node
|
||||||
- pnpm (other package managers support will be added soon)
|
- npm/pnpm/yarn*
|
||||||
|
|
||||||
## Installation
|
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||||
|
|
||||||
```
|
|
||||||
cargo install tuono
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create a new project
|
|
||||||
|
|
||||||
```
|
|
||||||
tuono new [NAME]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```
|
|
||||||
tuono dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Folder structure
|
## Folder structure
|
||||||
|
|
||||||
@@ -79,7 +92,7 @@ tuono dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Any help or suggestion will be appreciated.
|
Any help or suggestion will be appreciated and encouraged.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono"
|
name = "tuono"
|
||||||
version = "0.1.8"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "The react/rust fullstack framework"
|
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"
|
readme = "../../README.md"
|
||||||
license-file = "../../LICENSE.md"
|
license-file = "../../LICENSE.md"
|
||||||
categories = ["web-programming"]
|
categories = ["web-programming"]
|
||||||
@@ -29,4 +30,5 @@ glob = "0.3.1"
|
|||||||
regex = "1.10.4"
|
regex = "1.10.4"
|
||||||
reqwest = {version = "0.12.4", features =["blocking", "json"]}
|
reqwest = {version = "0.12.4", features =["blocking", "json"]}
|
||||||
serde_json = "1.0"
|
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};
|
mod app;
|
||||||
use std::process::Command;
|
pub mod cli;
|
||||||
|
mod mode;
|
||||||
mod source_builder;
|
mod route;
|
||||||
use source_builder::{bundle_axum_source, create_client_entry_files};
|
|
||||||
|
|
||||||
use crate::source_builder::check_tuono_folder;
|
|
||||||
mod scaffold_project;
|
mod scaffold_project;
|
||||||
|
mod source_builder;
|
||||||
mod watch;
|
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() {
|
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!("\nInstall the dependencies:");
|
||||||
println!("pnpm install");
|
println!("npm install");
|
||||||
|
|
||||||
println!("\nRun the local environment:");
|
println!("\nRun the local environment:");
|
||||||
println!("tuono dev");
|
println!("tuono dev");
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use glob::glob;
|
|
||||||
use glob::GlobError;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::prelude::*;
|
use std::io::prelude::*;
|
||||||
use std::path::Path;
|
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
|
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||||
// Do not manually update this file
|
// Do not manually update this file
|
||||||
@@ -32,146 +33,26 @@ pub const AXUM_ENTRY_POINT: &str = r##"
|
|||||||
// File automatically generated
|
// File automatically generated
|
||||||
// Do not manually change it
|
// Do not manually change it
|
||||||
|
|
||||||
use axum::extract::{Path, Request};
|
use tuono_lib::{tokio, Mode, Server, axum::Router};
|
||||||
use axum::response::Html;
|
// AXUM_GET_ROUTE_HANDLER
|
||||||
use axum::{routing::get, Router};
|
|
||||||
use tower_http::services::ServeDir;
|
const MODE: Mode = /*MODE*/;
|
||||||
use std::collections::HashMap;
|
|
||||||
use tuono_lib::{ssr, Ssr};
|
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
// MODULE_IMPORTS
|
// MODULE_IMPORTS
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
Ssr::create_platform();
|
let router = Router::new()
|
||||||
|
|
||||||
let fetch = Client::new();
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
// ROUTE_BUILDER
|
// 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();
|
Server::init(router, MODE).start().await
|
||||||
|
|
||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
const ROOT_FOLDER: &str = "src/routes";
|
const ROUTE_FOLDER: &str = "src/routes";
|
||||||
const DEV_FOLDER: &str = ".tuono";
|
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) {
|
fn create_main_file(base_path: &Path, bundled_file: &String) {
|
||||||
let mut data_file =
|
let mut data_file =
|
||||||
fs::File::create(base_path.join(".tuono/main.rs")).expect("creation failed");
|
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");
|
.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");
|
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||||
|
|
||||||
for (_, route) in routes.iter() {
|
for (_, route) in routes.iter() {
|
||||||
let Route {
|
let Route { axum_info, .. } = &route;
|
||||||
axum_route,
|
|
||||||
module_import,
|
|
||||||
} = &route;
|
|
||||||
|
|
||||||
route_declarations.push_str(&format!(
|
if axum_info.is_some() {
|
||||||
r#".route("{axum_route}", get({module_import}::route))"#
|
let AxumInfo {
|
||||||
));
|
axum_route,
|
||||||
route_declarations.push_str(&format!(
|
module_import,
|
||||||
r#".route("/__tuono/data{axum_route}", get({module_import}::api))"#
|
} = 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
|
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");
|
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||||
|
|
||||||
for (path, route) in routes.iter() {
|
for (path, route) in routes.iter() {
|
||||||
let module_name = &route.module_import;
|
if route.axum_info.is_some() {
|
||||||
let path_str = path.to_str().unwrap();
|
let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap();
|
||||||
route_declarations.push_str(&format!(
|
|
||||||
r#"#[path="../{ROOT_FOLDER}{path_str}"]
|
route_declarations.push_str(&format!(
|
||||||
mod {module_name};
|
r#"#[path="../{ROUTE_FOLDER}{path}.rs"]
|
||||||
"#
|
mod {module_import};
|
||||||
))
|
"#
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
route_declarations
|
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 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 = generate_axum_source(&app, mode);
|
||||||
|
|
||||||
let bundled_file = AXUM_ENTRY_POINT
|
|
||||||
.replace(
|
|
||||||
"// ROUTE_BUILDER\n",
|
|
||||||
&create_routes_declaration(&source_builder.route_map),
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
"// MODULE_IMPORTS\n",
|
|
||||||
&create_modules_declaration(&source_builder.route_map),
|
|
||||||
);
|
|
||||||
|
|
||||||
create_main_file(&base_path, &bundled_file);
|
create_main_file(&base_path, &bundled_file);
|
||||||
|
|
||||||
Ok(())
|
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<()> {
|
pub fn check_tuono_folder() -> io::Result<()> {
|
||||||
let dev_folder = Path::new(DEV_FOLDER);
|
let dev_folder = Path::new(DEV_FOLDER);
|
||||||
if !&dev_folder.is_dir() {
|
if !&dev_folder.is_dir() {
|
||||||
@@ -266,94 +172,37 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_dynamic_paths() {
|
fn should_set_the_correct_mode() {
|
||||||
let routes = [
|
let source_builder = App::new();
|
||||||
("/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
|
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||||
.into_iter()
|
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
|
||||||
|
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);
|
||||||
|
|
||||||
|
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collect_routes() {
|
fn should_not_load_the_axum_get_function() {
|
||||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
let source_builder = App::new();
|
||||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
|
||||||
|
|
||||||
let routes = [
|
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
|
||||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
|
||||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
|
||||||
];
|
|
||||||
|
|
||||||
routes
|
|
||||||
.into_iter()
|
|
||||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
|
||||||
|
|
||||||
let results = [
|
|
||||||
("/index.rs", "index"),
|
|
||||||
("/about.rs", "about"),
|
|
||||||
("/posts/index.rs", "posts_index"),
|
|
||||||
("/posts/[post].rs", "posts_dyn_post"),
|
|
||||||
];
|
|
||||||
|
|
||||||
results.into_iter().for_each(|(path, module_import)| {
|
|
||||||
assert_eq!(
|
|
||||||
source_builder
|
|
||||||
.route_map
|
|
||||||
.get(&PathBuf::from(path))
|
|
||||||
.unwrap()
|
|
||||||
.module_import,
|
|
||||||
String::from(module_import)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_multi_level_axum_paths() {
|
fn should_load_the_axum_get_function() {
|
||||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
let mut source_builder = App::new();
|
||||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
|
||||||
|
|
||||||
let routes = [
|
let mut route = Route::new(String::from("index.tsx"));
|
||||||
"/home/user/Documents/tuono/src/routes/about.rs",
|
route.update_axum_info();
|
||||||
"/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
|
source_builder
|
||||||
.into_iter()
|
.route_map
|
||||||
.for_each(|route| source_builder.collect_route(Ok(PathBuf::from(route))));
|
.insert(String::from("index.rs"), route);
|
||||||
|
|
||||||
let results = [
|
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||||
("/index.rs", "/"),
|
assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||||
("/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)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -6,6 +6,7 @@ use watchexec::Watchexec;
|
|||||||
use watchexec_signals::Signal;
|
use watchexec_signals::Signal;
|
||||||
use watchexec_supervisor::job::{start_job, Job};
|
use watchexec_supervisor::job::{start_job, Job};
|
||||||
|
|
||||||
|
use crate::mode::Mode;
|
||||||
use crate::source_builder::bundle_axum_source;
|
use crate::source_builder::bundle_axum_source;
|
||||||
|
|
||||||
fn watch_react_src() -> Job {
|
fn watch_react_src() -> Job {
|
||||||
@@ -57,28 +58,33 @@ pub async fn watch() -> Result<()> {
|
|||||||
build_ssr_bundle.to_wait().await;
|
build_ssr_bundle.to_wait().await;
|
||||||
|
|
||||||
let wx = Watchexec::new(move |mut action| {
|
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 event in action.events.iter() {
|
||||||
for path in event.paths() {
|
for path in event.paths() {
|
||||||
if path.0.to_string_lossy().ends_with(".rs")
|
let file_path = path.0.to_string_lossy();
|
||||||
// Either tsx and jsx
|
if file_path.ends_with(".rs") {
|
||||||
|| path.0.to_string_lossy().ends_with("sx")
|
should_reload_rust_server = true
|
||||||
{
|
}
|
||||||
should_reload = true
|
|
||||||
|
// Either tsx, jsx and mdx
|
||||||
|
if file_path.ends_with("sx") || file_path.ends_with("mdx") {
|
||||||
|
should_reload_ssr_bundle = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_reload {
|
if should_reload_rust_server {
|
||||||
println!("Reloading...");
|
println!("Reloading...");
|
||||||
run_server.stop();
|
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.stop();
|
||||||
build_ssr_bundle.start();
|
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
|
// if Ctrl-C is received, quit
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib"
|
name = "tuono_lib"
|
||||||
version = "0.1.8"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "The react/rust fullstack framework"
|
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"
|
readme = "../../README.md"
|
||||||
license-file = "../../LICENSE.md"
|
license-file = "../../LICENSE.md"
|
||||||
categories = ["web-programming"]
|
categories = ["web-programming"]
|
||||||
@@ -18,11 +19,22 @@ name = "tuono_lib"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ssr_rs = "0.5.2"
|
ssr_rs = "0.5.5"
|
||||||
axum = "0.7.5"
|
axum = {version = "0.7.5", features = ["json", "ws"]}
|
||||||
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
serde = { version = "1.0.202", features = ["derive"] }
|
serde = { version = "1.0.202", features = ["derive"] }
|
||||||
erased-serde = "0.4.5"
|
erased-serde = "0.4.5"
|
||||||
serde_json = "1.0"
|
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.8"}
|
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.8.2"}
|
||||||
|
# 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 payload;
|
||||||
mod request;
|
mod request;
|
||||||
mod response;
|
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 payload::Payload;
|
||||||
pub use request::Request;
|
pub use request::Request;
|
||||||
pub use response::{Props, Response};
|
pub use response::{Props, Response};
|
||||||
pub use ssr_rs::Ssr;
|
pub use server::Server;
|
||||||
pub use tuono_lib_macros::handler;
|
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 erased_serde::Serialize;
|
||||||
|
use regex::Regex;
|
||||||
use serde::Serialize as SerdeSerialize;
|
use serde::Serialize as SerdeSerialize;
|
||||||
|
|
||||||
use crate::request::Location;
|
use crate::request::{Location, Request};
|
||||||
use crate::Request;
|
|
||||||
|
fn has_dynamic_path(route: &str) -> bool {
|
||||||
|
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||||
|
regex.is_match(route)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(SerdeSerialize)]
|
#[derive(SerdeSerialize)]
|
||||||
/// This is the data shared to the client
|
/// This is the payload sent to the client for hydration
|
||||||
pub struct Payload<'a> {
|
pub struct Payload<'a> {
|
||||||
router: Location<'a>,
|
router: Location,
|
||||||
props: &'a dyn Serialize,
|
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> {
|
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 {
|
Payload {
|
||||||
router: req.location(),
|
router: req.location(),
|
||||||
props,
|
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)
|
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};
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Request<'a> {
|
pub struct Request {
|
||||||
uri: &'a Uri,
|
uri: Uri,
|
||||||
pub headers: &'a HeaderMap,
|
pub headers: HeaderMap,
|
||||||
pub params: HashMap<String, String>,
|
pub params: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Location must match client side interface
|
impl Request {
|
||||||
#[derive(Serialize, Debug)]
|
pub fn new(uri: Uri, headers: HeaderMap, params: HashMap<String, String>) -> Request {
|
||||||
pub struct Location<'a> {
|
|
||||||
href: String,
|
|
||||||
pathname: &'a str,
|
|
||||||
search: HashMap<String, String>,
|
|
||||||
search_str: &'a str,
|
|
||||||
/// Server does not need it. Will be hanlder client side
|
|
||||||
hash: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Request<'a> {
|
|
||||||
pub fn new(
|
|
||||||
uri: &'a Uri,
|
|
||||||
headers: &'a HeaderMap,
|
|
||||||
params: HashMap<String, String>,
|
|
||||||
) -> Request<'a> {
|
|
||||||
Request {
|
Request {
|
||||||
uri,
|
uri,
|
||||||
headers,
|
headers,
|
||||||
@@ -34,14 +48,7 @@ impl<'a> Request<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn location(&self) -> Location<'a> {
|
pub fn location(&self) -> Location {
|
||||||
Location {
|
Location::from(self.uri.to_owned())
|
||||||
href: self.uri.to_string(),
|
|
||||||
pathname: &self.uri.path(),
|
|
||||||
// TODO: handler search map
|
|
||||||
search: HashMap::new(),
|
|
||||||
search_str: &self.uri.query().unwrap_or(""),
|
|
||||||
hash: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::Request;
|
use crate::Request;
|
||||||
use crate::{ssr::Js, Payload};
|
use crate::{ssr::Js, Payload};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{Html, IntoResponse};
|
use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use erased_serde::Serialize;
|
use erased_serde::Serialize;
|
||||||
|
|
||||||
@@ -15,6 +15,41 @@ pub enum Response {
|
|||||||
Props(Props),
|
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 {
|
impl Props {
|
||||||
pub fn new(data: impl Serialize + 'static) -> Self {
|
pub fn new(data: impl Serialize + 'static) -> Self {
|
||||||
Props {
|
Props {
|
||||||
@@ -32,25 +67,32 @@ impl Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Response {
|
impl Response {
|
||||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse {
|
pub fn render_to_string(&self, req: Request) -> AxumResponse {
|
||||||
match self {
|
match self {
|
||||||
Self::Props(Props { data, http_code }) => {
|
Self::Props(Props { data, http_code }) => {
|
||||||
let payload = Payload::new(&req, data).client_payload().unwrap();
|
let payload = Payload::new(&req, data).client_payload().unwrap();
|
||||||
|
|
||||||
match Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) {
|
match Js::render_to_string(Some(&payload)) {
|
||||||
Ok(html) => (*http_code, Html(html)),
|
Ok(html) => (*http_code, Html(html)).into_response(),
|
||||||
Err(_) => (*http_code, Html("500 Internal server error".to_string())),
|
Err(_) => {
|
||||||
|
(*http_code, Html("500 Internal server error".to_string())).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO: Handle here other enum arms
|
Self::Redirect(to) => Redirect::permanent(to).into_response(),
|
||||||
_ => todo!(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn json(&self) -> impl IntoResponse {
|
pub fn json(&self) -> impl IntoResponse {
|
||||||
match self {
|
match self {
|
||||||
Self::Props(Props { data, http_code }) => (*http_code, Json(data)).into_response(),
|
Self::Props(Props { data, http_code }) => {
|
||||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json("{}")).into_response(),
|
(*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 ssr_rs::Ssr;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::fs::read_to_string;
|
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;
|
pub struct Js;
|
||||||
|
|
||||||
impl 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! {
|
thread_local! {
|
||||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||||
// TODO: handle here dev/prod source
|
|
||||||
Ssr::from(
|
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()
|
).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]
|
[package]
|
||||||
name = "tuono_lib_macros"
|
name = "tuono_lib_macros"
|
||||||
version = "0.1.8"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "The react/rust fullstack framework"
|
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"
|
readme = "../../README.md"
|
||||||
license-file = "../../LICENSE.md"
|
license-file = "../../LICENSE.md"
|
||||||
categories = ["web-programming"]
|
categories = ["web-programming"]
|
||||||
|
|||||||
@@ -8,35 +8,34 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
let fn_name = item.clone().sig.ident;
|
let fn_name = item.clone().sig.ident;
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
use axum::response::IntoResponse;
|
use tuono_lib::axum::response::IntoResponse;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use axum::extract::{State, Path};
|
use tuono_lib::axum::extract::{State, Path};
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
#item
|
#item
|
||||||
|
|
||||||
pub async fn route(
|
pub async fn route(
|
||||||
Path(params): Path<HashMap<String, String>>,
|
Path(params): Path<HashMap<String, String>>,
|
||||||
State(client): State<Client>,
|
State(client): State<tuono_lib::reqwest::Client>,
|
||||||
request: axum::extract::Request
|
request: tuono_lib::axum::extract::Request
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let pathname = &request.uri();
|
let pathname = request.uri();
|
||||||
let headers = &request.headers();
|
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)
|
#fn_name(req.clone(), client).await.render_to_string(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn api(
|
pub async fn api(
|
||||||
Path(params): Path<HashMap<String, String>>,
|
Path(params): Path<HashMap<String, String>>,
|
||||||
State(client): State<Client>,
|
State(client): State<tuono_lib::reqwest::Client>,
|
||||||
request: axum::extract::Request
|
request: tuono_lib::axum::extract::Request
|
||||||
) -> impl IntoResponse{
|
) -> impl IntoResponse{
|
||||||
let pathname = &request.uri();
|
let pathname = request.uri();
|
||||||
let headers = &request.headers();
|
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()
|
#fn_name(req.clone(), client).await.json()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ mod handler;
|
|||||||
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
handler::handler_core(args, item)
|
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).
|
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
|
## 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 a stand-alone component](#create-a-stand-alone-component)
|
||||||
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||||
* [Error handling](#error-handling)
|
* [Error handling](#error-handling)
|
||||||
|
* [Seo and meta tags](#seo-and-meta-tags)
|
||||||
|
* [Handle redirections](#handle-redirections)
|
||||||
|
* [Building for production](#building-for-production)
|
||||||
* [Conclusion](#conclusion)
|
* [Conclusion](#conclusion)
|
||||||
|
|
||||||
## CLI Installation
|
## CLI Installation
|
||||||
@@ -46,7 +55,7 @@ $ tuono new tuono-tutorial
|
|||||||
Get into the project folder and install the dependencies with:
|
Get into the project folder and install the dependencies with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ pnpm install
|
$ npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Open it with your favourite code editor.
|
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
|
- Passing server side props
|
||||||
- Changing http status code
|
- Changing http status code
|
||||||
- Redirect/Rewrite to a different route (Available soon)
|
- Redirecting to a different route
|
||||||
|
|
||||||
## Tutorial introduction
|
## 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.
|
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
|
## 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.
|
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
|
```rust
|
||||||
// src/routes/index.rs
|
// src/routes/index.rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tuono_lib::reqwest::Client;
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||||
@@ -124,7 +137,7 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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 {
|
return match fetch.get(ALL_POKEMON).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let data = res.json::<Pokemons>().await.unwrap();
|
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:
|
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
|
```rust
|
||||||
// src/routes/pokemons/[pokemon].rs
|
// src/routes/pokemons/[pokemon].rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tuono_lib::reqwest::Client;
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||||
@@ -330,7 +344,7 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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
|
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||||
let pokemon = req.params.get("pokemon").unwrap();
|
let pokemon = req.params.get("pokemon").unwrap();
|
||||||
|
|
||||||
@@ -457,8 +471,9 @@ Let's see how it works!
|
|||||||
|
|
||||||
```diff
|
```diff
|
||||||
// src/routes/pokemons/[pokemon].rs
|
// src/routes/pokemons/[pokemon].rs
|
||||||
++ use reqwest::StatusCode;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
-- use tuono_lib::reqwest::Client;
|
||||||
|
++ use tuono_lib::reqwest::{Client, StatusCode};
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||||
@@ -472,7 +487,7 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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
|
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||||
let pokemon = req.params.get("pokemon").unwrap();
|
let pokemon = req.params.get("pokemon").unwrap();
|
||||||
|
|
||||||
@@ -496,8 +511,9 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
|||||||
|
|
||||||
```diff
|
```diff
|
||||||
// src/routes/index.rs
|
// src/routes/index.rs
|
||||||
++ use reqwest::StatusCode;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
-- use tuono_lib::reqwest::Client;
|
||||||
|
++ use tuono_lib::reqwest::{Client, StatusCode};
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||||
@@ -514,7 +530,7 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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 {
|
return match fetch.get(ALL_POKEMON).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let data = res.json::<Pokemons>().await.unwrap();
|
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
|
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.
|
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
|
## Conclusion
|
||||||
|
|
||||||
That’s it! You just created a multi thread full stack application with rust and react.
|
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.
|
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.
|
`[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"
|
path = ".tuono/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[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/"}
|
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||||
serde_json = "1.0"
|
serde = { version = "1.0.202", features = ["derive"] }
|
||||||
reqwest = {version = "0.12.4", features = ["json"]}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use tuono_lib::reqwest;
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -7,7 +8,7 @@ struct MyResponse<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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 {
|
Response::Props(Props::new(MyResponse {
|
||||||
subtitle: "The react / rust fullstack framework",
|
subtitle: "The react / rust fullstack framework",
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ name = "tuono"
|
|||||||
path = ".tuono/main.rs"
|
path = ".tuono/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[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/"}
|
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||||
serde_json = "1.0"
|
serde = { version = "1.0.202", features = ["derive"] }
|
||||||
reqwest = {version = "0.12.4", features = ["json"]}
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ export default function PokemonLink({
|
|||||||
id: number
|
id: number
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
|
<Link
|
||||||
|
className={styles.link}
|
||||||
|
href={`/pokemons/${pokemon.name}`}
|
||||||
|
id={pokemon.name}
|
||||||
|
>
|
||||||
{pokemon.name}
|
{pokemon.name}
|
||||||
<img
|
<img
|
||||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/routes/index.rs
|
// src/routes/index.rs
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tuono_lib::reqwest::{Client, StatusCode};
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||||
@@ -17,8 +17,8 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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 {
|
match fetch.get(ALL_POKEMON).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let data = res.json::<Pokemons>().await.unwrap();
|
let data = res.json::<Pokemons>().await.unwrap();
|
||||||
Response::Props(Props::new(data))
|
Response::Props(Props::new(data))
|
||||||
@@ -27,5 +27,5 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response
|
|||||||
"{}", // Return empty JSON
|
"{}", // Return empty JSON
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
)),
|
)),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/routes/index.tsx
|
// src/routes/index.tsx
|
||||||
import type { TuonoProps } from 'tuono'
|
import { Head, type TuonoProps } from 'tuono'
|
||||||
import PokemonLink from '../components/PokemonLink'
|
import PokemonLink from '../components/PokemonLink'
|
||||||
|
|
||||||
interface Pokemon {
|
interface Pokemon {
|
||||||
@@ -19,6 +19,9 @@ export default function IndexPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Tuono tutorial</title>
|
||||||
|
</Head>
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||||
Crates
|
Crates
|
||||||
@@ -37,6 +40,7 @@ export default function IndexPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||||
|
<PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||||
{data.results.map((pokemon, i) => {
|
{data.results.map((pokemon, i) => {
|
||||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={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
|
// src/routes/pokemons/[pokemon].rs
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tuono_lib::reqwest::{Client, StatusCode};
|
||||||
use tuono_lib::{Props, Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||||
@@ -14,11 +14,11 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[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
|
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||||
let pokemon = req.params.get("pokemon").unwrap();
|
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) => {
|
Ok(res) => {
|
||||||
if res.status() == StatusCode::NOT_FOUND {
|
if res.status() == StatusCode::NOT_FOUND {
|
||||||
return Response::Props(Props::new_with_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,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
)),
|
)),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TuonoProps } from 'tuono'
|
import { Head, type TuonoProps } from 'tuono'
|
||||||
import PokemonView from '../../components/PokemonView'
|
import PokemonView from '../../components/PokemonView'
|
||||||
|
|
||||||
interface Pokemon {
|
interface Pokemon {
|
||||||
@@ -11,5 +11,12 @@ interface Pokemon {
|
|||||||
export default function PokemonPage({
|
export default function PokemonPage({
|
||||||
data,
|
data,
|
||||||
}: TuonoProps<Pokemon>): JSX.Element {
|
}: TuonoProps<Pokemon>): JSX.Element {
|
||||||
return <PokemonView pokemon={data} />
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Pokemon: {data?.name}</title>
|
||||||
|
</Head>
|
||||||
|
<PokemonView pokemon={data} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-7
@@ -2,18 +2,19 @@
|
|||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
"packageManager": "pnpm@9.1.1",
|
"packageManager": "pnpm@9.1.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev --filter tuono",
|
||||||
"build": "turbo build",
|
"build": "turbo build --filter tuono",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint --filter tuono",
|
||||||
"format": "turbo format",
|
"format": "turbo format --filter tuono",
|
||||||
"format:check": "turbo format:check",
|
"format:check": "turbo format:check --filter tuono",
|
||||||
"types": "turbo types",
|
"types": "turbo types --filter tuono",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
"test:watch": "turbo test:watch"
|
"test:watch": "turbo test:watch"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"tuono",
|
"tuono",
|
||||||
"tuono-lazy-fn-vite-plugin"
|
"tuono-lazy-fn-vite-plugin",
|
||||||
|
"tuono-fs-router-vite-plugin"
|
||||||
],
|
],
|
||||||
"author": "Valerio Ageno",
|
"author": "Valerio Ageno",
|
||||||
"license": "MIT",
|
"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.2",
|
||||||
|
"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'
|
||||||
+18
-89
@@ -1,27 +1,26 @@
|
|||||||
import * as fs from 'fs'
|
|
||||||
import * as fsp from 'fs/promises'
|
import * as fsp from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { buildRouteConfig } from './build-route-config'
|
||||||
|
import { hasParentRoute } from './has-parent-route'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cleanPath,
|
cleanPath,
|
||||||
determineNodePath,
|
determineNodePath,
|
||||||
multiSortBy,
|
multiSortBy,
|
||||||
spaces,
|
|
||||||
replaceBackslash,
|
replaceBackslash,
|
||||||
removeExt,
|
removeExt,
|
||||||
routePathToVariable,
|
routePathToVariable,
|
||||||
removeGroups,
|
removeGroups,
|
||||||
|
removeLastSlash,
|
||||||
removeUnderscores,
|
removeUnderscores,
|
||||||
removeLayoutSegments,
|
removeLayoutSegments,
|
||||||
} from './utils'
|
} 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'
|
import { format } from 'prettier'
|
||||||
|
import { sortRouteNodes } from './sort-route-nodes'
|
||||||
const ROUTES_FOLDER = './src/routes/'
|
|
||||||
const ROOT_PATH_ID = '__root'
|
|
||||||
const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
|
||||||
|
|
||||||
let latestTask = 0
|
let latestTask = 0
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ async function getRouteNodes(
|
|||||||
|
|
||||||
if (dirent.isDirectory()) {
|
if (dirent.isDirectory()) {
|
||||||
await recurse(relativePath)
|
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 filePath = replaceBackslash(path.join(dir, dirent.name))
|
||||||
const filePathNoExt = removeExt(filePath)
|
const filePathNoExt = removeExt(filePath)
|
||||||
let routePath =
|
let routePath =
|
||||||
@@ -60,9 +59,6 @@ async function getRouteNodes(
|
|||||||
|
|
||||||
// Remove the index from the route path and
|
// Remove the index from the route path and
|
||||||
// if the route path is empty, use `/'
|
// if the route path is empty, use `/'
|
||||||
|
|
||||||
const isLoader = routePath.includes('-loading')
|
|
||||||
|
|
||||||
if (routePath === 'index') {
|
if (routePath === 'index') {
|
||||||
routePath = '/'
|
routePath = '/'
|
||||||
}
|
}
|
||||||
@@ -73,7 +69,6 @@ async function getRouteNodes(
|
|||||||
filePath,
|
filePath,
|
||||||
fullPath,
|
fullPath,
|
||||||
routePath,
|
routePath,
|
||||||
isLoader,
|
|
||||||
variableName,
|
variableName,
|
||||||
})
|
})
|
||||||
} else if (fullPath.match(/\.(rs)$/)) {
|
} else if (fullPath.match(/\.(rs)$/)) {
|
||||||
@@ -99,38 +94,6 @@ async function getRouteNodes(
|
|||||||
return { routeNodes, rustHandlersNodes }
|
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> {
|
export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||||
if (!isFirst) {
|
if (!isFirst) {
|
||||||
isFirst = true
|
isFirst = true
|
||||||
@@ -153,27 +116,12 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
|
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
|
||||||
await getRouteNodes(config)
|
await getRouteNodes(config)
|
||||||
|
|
||||||
const preRouteNodes = multiSortBy(beforeRouteNodes, [
|
const preRouteNodes = sortRouteNodes(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 routePiecesByPath: Record<string, RouteSubNode> = {}
|
const routeNodes: RouteNode[] = []
|
||||||
|
|
||||||
// Loop over the flat list of routeNodes and
|
// Loop over the flat list of routeNodes and
|
||||||
// build up a tree based on the routeNodes' routePath
|
// build up a tree based on the routeNodes' routePath
|
||||||
const routeNodes: RouteNode[] = []
|
|
||||||
|
|
||||||
const handleNode = async (node: RouteNode): Promise<void> => {
|
const handleNode = async (node: RouteNode): Promise<void> => {
|
||||||
const parentRoute = hasParentRoute(routeNodes, node, node.routePath)
|
const parentRoute = hasParentRoute(routeNodes, node, node.routePath)
|
||||||
|
|
||||||
@@ -181,8 +129,8 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
|
|
||||||
node.path = determineNodePath(node)
|
node.path = determineNodePath(node)
|
||||||
|
|
||||||
node.cleanedPath = removeGroups(
|
node.cleanedPath = removeLastSlash(
|
||||||
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
|
removeGroups(removeUnderscores(removeLayoutSegments(node.path)) ?? ''),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (node.parent) {
|
if (node.parent) {
|
||||||
@@ -196,29 +144,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
await handleNode(node)
|
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 routeConfigChildrenText = buildRouteConfig(routeNodes)
|
||||||
|
|
||||||
const sortedRouteNodes = multiSortBy(routeNodes, [
|
const sortedRouteNodes = multiSortBy(routeNodes, [
|
||||||
@@ -230,6 +155,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
|
|
||||||
const imports = [
|
const imports = [
|
||||||
...sortedRouteNodes.map((node) => {
|
...sortedRouteNodes.map((node) => {
|
||||||
|
const extension = node.filePath.endsWith('mdx') ? '.mdx' : ''
|
||||||
return `const ${
|
return `const ${
|
||||||
node.variableName
|
node.variableName
|
||||||
}Import = dynamic(() => import('./${replaceBackslash(
|
}Import = dynamic(() => import('./${replaceBackslash(
|
||||||
@@ -240,13 +166,16 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
)}'))`
|
)}${extension}'))`
|
||||||
}),
|
}),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const createRoutes = [
|
const createRoutes = [
|
||||||
...sortedRouteNodes.map((node) => {
|
...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')
|
].join('\n')
|
||||||
|
|
||||||
@@ -256,7 +185,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
return [
|
return [
|
||||||
`const ${node.variableName}Route = ${node.variableName}.update({
|
`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`,
|
`getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
|
||||||
rustHandlersNodes.includes(node.path || '')
|
rustHandlersNodes.includes(node.path || '')
|
||||||
? 'hasHandler: true'
|
? 'hasHandler: true'
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { hasParentRoute } from './has-parent-route'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
filePath: 'posts/[post].tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/[post].tsx',
|
||||||
|
routePath: '/posts/[post]',
|
||||||
|
variableName: 'Postspost',
|
||||||
|
path: '/posts/[post]',
|
||||||
|
cleanedPath: '/posts/[post]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filePath: 'posts/__root.tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/__root.tsx',
|
||||||
|
routePath: '/posts/__root',
|
||||||
|
variableName: 'Postsroot',
|
||||||
|
path: '/posts/__root',
|
||||||
|
cleanedPath: '/posts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filePath: 'index.tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/index.tsx',
|
||||||
|
routePath: '/',
|
||||||
|
variableName: 'Index',
|
||||||
|
path: '/',
|
||||||
|
cleanedPath: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filePath: 'posts/my-post.tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/my-post.tsx',
|
||||||
|
routePath: '/posts/my-post',
|
||||||
|
variableName: 'PostsMyPost',
|
||||||
|
path: '/posts/my-post',
|
||||||
|
cleanedPath: '/posts/my-post',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const parent = {
|
||||||
|
filePath: 'posts/__root.tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/__root.tsx',
|
||||||
|
routePath: '/posts/__root',
|
||||||
|
variableName: 'Postsroot',
|
||||||
|
path: '/posts/__root',
|
||||||
|
cleanedPath: '/posts',
|
||||||
|
}
|
||||||
|
|
||||||
|
const myPost = {
|
||||||
|
filePath: 'posts/my-post.tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/my-post.tsx',
|
||||||
|
routePath: '/posts/my-post',
|
||||||
|
variableName: 'PostsMyPost',
|
||||||
|
path: '/posts/my-post',
|
||||||
|
cleanedPath: '/posts/my-post',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicRoute = {
|
||||||
|
filePath: 'posts/[post].tsx',
|
||||||
|
fullPath:
|
||||||
|
'/tuono/packages/fs-router-vite-plugin/tests/generator/multi-level-root/routes/posts/[post].tsx',
|
||||||
|
routePath: '/posts/[post]',
|
||||||
|
variableName: 'Postspost',
|
||||||
|
path: '/posts/[post]',
|
||||||
|
cleanedPath: '/posts/[post]',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hasParentRoute works', async () => {
|
||||||
|
it('Should detect parent route', () => {
|
||||||
|
const parentRoute = hasParentRoute(routes, myPost, myPost.path)
|
||||||
|
expect(parentRoute).toStrictEqual(parent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should detect parent route for dynamic routes', () => {
|
||||||
|
const parentRoute = hasParentRoute(routes, dynamicRoute, dynamicRoute.path)
|
||||||
|
expect(parentRoute).toStrictEqual(parent)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ROOT_PATH_ID } from './constants'
|
||||||
|
import { multiSortBy } from './utils'
|
||||||
|
import type { RouteNode } from './types'
|
||||||
|
|
||||||
|
export function hasParentRoute(
|
||||||
|
routes: RouteNode[],
|
||||||
|
node: RouteNode,
|
||||||
|
routePathToCheck = '/',
|
||||||
|
): RouteNode | null {
|
||||||
|
const segments = routePathToCheck.split('/')
|
||||||
|
segments.pop() // Remove the last segment
|
||||||
|
const parentRoutePath = segments.join('/')
|
||||||
|
|
||||||
|
if (!parentRoutePath || parentRoutePath === '/') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedNodes = multiSortBy(routes, [
|
||||||
|
(d): number => d.routePath.length * -1,
|
||||||
|
(d): string | undefined => d.variableName,
|
||||||
|
])
|
||||||
|
// Exclude base __root file
|
||||||
|
.filter((d) => d.routePath !== `/${ROOT_PATH_ID}`)
|
||||||
|
|
||||||
|
for (const route of sortedNodes) {
|
||||||
|
if (route.routePath === '/') continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
route.routePath.startsWith(parentRoutePath) &&
|
||||||
|
route.routePath.endsWith(ROOT_PATH_ID)
|
||||||
|
) {
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasParentRoute(routes, node, parentRoutePath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
path?: string
|
||||||
cleanedPath?: string
|
cleanedPath?: string
|
||||||
isLayout?: boolean
|
isLayout?: boolean
|
||||||
isLoader: boolean
|
|
||||||
children?: RouteNode[]
|
children?: RouteNode[]
|
||||||
parent?: RouteNode
|
parent?: RouteNode
|
||||||
variableName?: string
|
variableName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
export interface RouteSubNode {
|
|
||||||
component?: RouteNode
|
|
||||||
errorComponent?: RouteNode
|
|
||||||
pendingComponent?: RouteNode
|
|
||||||
loader?: RouteNode
|
|
||||||
lazy?: RouteNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
folderName: string
|
folderName: string
|
||||||
generatedRouteTree: string
|
generatedRouteTree: string
|
||||||
+6
@@ -100,6 +100,12 @@ export function trimPathLeft(pathToTrim: string): string {
|
|||||||
return pathToTrim === '/' ? pathToTrim : pathToTrim.replace(/^\/{1,}/, '')
|
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.
|
* 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.
|
* 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
|
# 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",
|
"name": "tuono-lazy-fn-vite-plugin",
|
||||||
"version": "0.1.8",
|
"version": "0.8.2",
|
||||||
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono",
|
"name": "tuono",
|
||||||
"version": "0.1.8",
|
"version": "0.8.2",
|
||||||
"description": "The react/rust fullstack framework",
|
"description": "The react/rust fullstack framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
@@ -84,11 +84,13 @@
|
|||||||
"@babel/template": "^7.24.0",
|
"@babel/template": "^7.24.0",
|
||||||
"@babel/traverse": "^7.24.1",
|
"@babel/traverse": "^7.24.1",
|
||||||
"@babel/types": "^7.24.0",
|
"@babel/types": "^7.24.0",
|
||||||
|
"@mdx-js/rollup": "^3.0.1",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"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:*",
|
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"zustand": "4.4.7"
|
"zustand": "4.4.7"
|
||||||
@@ -101,10 +103,17 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
|
"prettier": "^3.2.4",
|
||||||
"vitest": "^1.5.2"
|
"vitest": "^1.5.2"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"keywords": [],
|
"keywords": [
|
||||||
|
"react",
|
||||||
|
"typescript",
|
||||||
|
"fullstack",
|
||||||
|
"framework",
|
||||||
|
"rust"
|
||||||
|
],
|
||||||
"author": "Valerio Ageno",
|
"author": "Valerio Ageno",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { build, createServer, InlineConfig } from 'vite'
|
import { build, createServer, InlineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react-swc'
|
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 { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||||
|
import mdx from '@mdx-js/rollup'
|
||||||
|
|
||||||
const BASE_CONFIG: InlineConfig = {
|
const BASE_CONFIG: InlineConfig = {
|
||||||
root: '.tuono',
|
root: '.tuono',
|
||||||
@@ -9,9 +10,20 @@ const BASE_CONFIG: InlineConfig = {
|
|||||||
publicDir: '../public',
|
publicDir: '../public',
|
||||||
cacheDir: 'cache',
|
cacheDir: 'cache',
|
||||||
envDir: '../',
|
envDir: '../',
|
||||||
plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()],
|
optimizeDeps: {
|
||||||
|
exclude: ['@mdx-js/react'],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
{ enforce: 'pre', ...mdx({ providerImportSource: '@mdx-js/react' }) },
|
||||||
|
// @ts-ignore: TS configuration issue.
|
||||||
|
react({ include: /\.(jsx|js|mdx|md|tsx|ts)$/ }),
|
||||||
|
ViteFsRouter(),
|
||||||
|
LazyLoadingPlugin(),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VITE_PORT = 3001
|
||||||
|
|
||||||
export function developmentSSRBundle() {
|
export function developmentSSRBundle() {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await build({
|
await build({
|
||||||
@@ -43,8 +55,11 @@ export function developmentCSRWatch() {
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
|
// Entry point for the development vite proxy
|
||||||
|
base: '/vite-server/',
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: 3001,
|
port: VITE_PORT,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -77,7 +92,7 @@ export function buildProd() {
|
|||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
build: {
|
build: {
|
||||||
ssr: true,
|
ssr: true,
|
||||||
minify: false,
|
minify: true,
|
||||||
outDir: '../out/server',
|
outDir: '../out/server',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Overview:
|
|
||||||
*
|
|
||||||
* src/routes = Routes entry point
|
|
||||||
* src/routes/layout.tsx = Shared layout
|
|
||||||
* src/routes/index.tsx = xyz.com
|
|
||||||
* src/routes/about.tsx = xyz.com/about
|
|
||||||
* src/routes/about-loading.tsx = xyz.com/about - while loading data
|
|
||||||
* src/routes/about/index.tsx = xyz.com/about
|
|
||||||
* src/routes/posts/[slug].tsx = xyz.com/posts/my-lovely-post
|
|
||||||
* src/routes/posts/[...params].tsx = xyz.com/posts/my-lovely-post/commend-id-304
|
|
||||||
* src/routes/404.tsx = Not found
|
|
||||||
*
|
|
||||||
* public/ = Public files
|
|
||||||
*
|
|
||||||
* All the routes are lazy loaded!
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { routeGenerator } from './generator'
|
|
||||||
|
|
||||||
export { routeGenerator }
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import * as babel from '@babel/core'
|
|
||||||
import * as t from '@babel/types'
|
|
||||||
import { eliminateUnreferencedIdentifiers } from './eliminateUnreferencedIdentifiers'
|
|
||||||
import { SPLIT_PREFIX } from './constants'
|
|
||||||
import type { BabelFileResult } from '@babel/core'
|
|
||||||
|
|
||||||
type SplitModulesById = Record<
|
|
||||||
string,
|
|
||||||
{ id: string; node: t.FunctionExpression }
|
|
||||||
>
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
filename: string
|
|
||||||
opts: {
|
|
||||||
minify: boolean
|
|
||||||
root: string
|
|
||||||
}
|
|
||||||
imported: Record<string, boolean>
|
|
||||||
refs: Set<any>
|
|
||||||
serverIndex: number
|
|
||||||
splitIndex: number
|
|
||||||
splitModulesById: SplitModulesById
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MakeCompileFnArgs {
|
|
||||||
root: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MakeCompileFnReturnArgs {
|
|
||||||
code: string
|
|
||||||
filename: string
|
|
||||||
getBabelConfig: () => { plugins: any[] }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompileOutput {
|
|
||||||
code: string
|
|
||||||
map: BabelFileResult['map']
|
|
||||||
}
|
|
||||||
|
|
||||||
type SplitNodeType = (typeof splitNodeTypes)[number]
|
|
||||||
|
|
||||||
export const makeCompile = ({
|
|
||||||
root,
|
|
||||||
}: MakeCompileFnArgs): ((args: MakeCompileFnReturnArgs) => CompileOutput) => {
|
|
||||||
return ({ code, getBabelConfig, filename }) => {
|
|
||||||
const res = babel.transformSync(code, {
|
|
||||||
plugins: [
|
|
||||||
['@babel/plugin-syntax-jsx', {}],
|
|
||||||
['@babel/plugin-syntax-typescript', { isTSX: true }],
|
|
||||||
...getBabelConfig().plugins,
|
|
||||||
],
|
|
||||||
root,
|
|
||||||
filename,
|
|
||||||
sourceMaps: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res?.code) {
|
|
||||||
return {
|
|
||||||
code: res.code,
|
|
||||||
map: res.map,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
map: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SplitFileFnArgs {
|
|
||||||
code: string
|
|
||||||
compile: (args: MakeCompileFnReturnArgs) => CompileOutput
|
|
||||||
filename: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: We don't need the loader
|
|
||||||
const splitNodeTypes = ['component', 'loader'] as const
|
|
||||||
|
|
||||||
// Reusable function to get literal value or resolve variable to literal
|
|
||||||
function resolveIdentifier(path: any, node: any) {
|
|
||||||
if (t.isIdentifier(node)) {
|
|
||||||
const binding = path.scope.getBinding(node.name)
|
|
||||||
if (binding) {
|
|
||||||
const declarator = binding.path.node
|
|
||||||
if (t.isObjectExpression(declarator.init)) {
|
|
||||||
return declarator.init
|
|
||||||
} else if (t.isFunctionDeclaration(declarator.init)) {
|
|
||||||
return declarator.init
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
export const splitFile = async ({
|
|
||||||
code,
|
|
||||||
compile,
|
|
||||||
filename,
|
|
||||||
}: SplitFileFnArgs): Promise<CompileOutput> => {
|
|
||||||
return compile({
|
|
||||||
code,
|
|
||||||
filename,
|
|
||||||
getBabelConfig: () => ({
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
visitor: {
|
|
||||||
Program: {
|
|
||||||
enter(
|
|
||||||
programPath: babel.NodePath<t.Program>,
|
|
||||||
state: State,
|
|
||||||
): void {
|
|
||||||
const splitNodesByType: Record<
|
|
||||||
SplitNodeType,
|
|
||||||
t.Node | undefined
|
|
||||||
> = {
|
|
||||||
component: undefined,
|
|
||||||
loader: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the node
|
|
||||||
// NOTE: I'd use a more NextJS approach.
|
|
||||||
// All the "default exported" fns are the page.
|
|
||||||
programPath.traverse(
|
|
||||||
{
|
|
||||||
CallExpression: (path) => {
|
|
||||||
if (path.node.callee.type === 'Identifier') {
|
|
||||||
// TODO: Update this to support just "default exported" fns
|
|
||||||
if (path.node.callee.name === 'createFileRoute') {
|
|
||||||
if (
|
|
||||||
path.parentPath.node.type === 'CallExpression'
|
|
||||||
) {
|
|
||||||
const options = resolveIdentifier(
|
|
||||||
path,
|
|
||||||
path.parentPath.node.arguments[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (t.isObjectExpression(options)) {
|
|
||||||
options.properties.forEach((prop) => {
|
|
||||||
if (t.isObjectProperty(prop)) {
|
|
||||||
splitNodeTypes.forEach((type) => {
|
|
||||||
if (t.isIdentifier(prop.key)) {
|
|
||||||
if (prop.key.name === 'type') {
|
|
||||||
splitNodesByType[type] = prop.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove all of the options
|
|
||||||
options.properties = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: We don't need to iterate since we don't support the loader
|
|
||||||
splitNodeTypes.forEach((splitType) => {
|
|
||||||
let splitNode = splitNodesByType[splitType]
|
|
||||||
|
|
||||||
if (!splitNode) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
while (t.isIdentifier(splitNode)) {
|
|
||||||
const binding = programPath.scope.getBinding(
|
|
||||||
splitNode.name,
|
|
||||||
)
|
|
||||||
splitNode = binding?.path.node
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the node to the program
|
|
||||||
if (splitNode) {
|
|
||||||
if (t.isFunctionDeclaration(splitNode)) {
|
|
||||||
programPath.pushContainer(
|
|
||||||
'body',
|
|
||||||
t.variableDeclaration('const', [
|
|
||||||
t.variableDeclarator(
|
|
||||||
t.identifier(splitType),
|
|
||||||
t.functionExpression(
|
|
||||||
splitNode.id || null,
|
|
||||||
splitNode.params,
|
|
||||||
splitNode.body,
|
|
||||||
splitNode.generator,
|
|
||||||
splitNode.async,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
} else if (
|
|
||||||
t.isFunctionExpression(splitNode) ||
|
|
||||||
t.isArrowFunctionExpression(splitNode)
|
|
||||||
) {
|
|
||||||
programPath.pushContainer(
|
|
||||||
'body',
|
|
||||||
t.variableDeclaration('const', [
|
|
||||||
t.variableDeclarator(
|
|
||||||
t.identifier(splitType),
|
|
||||||
splitType as any,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
} else if (t.isImportSpecifier(splitNode)) {
|
|
||||||
programPath.pushContainer(
|
|
||||||
'body',
|
|
||||||
t.variableDeclaration('const', [
|
|
||||||
t.variableDeclarator(
|
|
||||||
t.identifier(splitType),
|
|
||||||
splitNode.local,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Unexpected splitNode type ${splitNode.type}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the splitNode exists at the top of the program
|
|
||||||
// then we need to remove that copy
|
|
||||||
programPath.node.body = programPath.node.body.filter(
|
|
||||||
(node) => {
|
|
||||||
return node !== splitNode
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Export the node
|
|
||||||
programPath.pushContainer('body', [
|
|
||||||
t.exportNamedDeclaration(null, [
|
|
||||||
t.exportSpecifier(
|
|
||||||
t.identifier(splitType),
|
|
||||||
t.identifier(splitType),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Convert exports to imports from the original file
|
|
||||||
programPath.traverse({
|
|
||||||
ExportNamedDeclaration(path) {
|
|
||||||
// e.g. export const x = 1 or export { x }
|
|
||||||
// becomes
|
|
||||||
// import { x } from '${opts.id}'
|
|
||||||
if (path.node.declaration) {
|
|
||||||
if (t.isVariableDeclaration(path.node.declaration)) {
|
|
||||||
path.replaceWith(
|
|
||||||
t.importDeclaration(
|
|
||||||
path.node.declaration.declarations.map((decl) =>
|
|
||||||
t.importSpecifier(
|
|
||||||
t.identifier((decl.id as any).name),
|
|
||||||
t.identifier((decl.id as any).name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
t.stringLiteral(
|
|
||||||
filename.split(`?${SPLIT_PREFIX}`)[0] as string,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
eliminateUnreferencedIdentifiers(programPath)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const SPLIT_PREFIX = 'sp-'
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user