mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-27 13:52:47 -07:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cd90ba62a | |||
| b6d0bb13ef | |||
| 7401a59346 | |||
| 622ae93d68 | |||
| b4207feb7e | |||
| 332fdb70ca | |||
| b9abfb64c0 | |||
| 3ca8861d85 | |||
| 0c93aab13f | |||
| 208b49cfc8 | |||
| db0a2d9d1d | |||
| c123e93433 | |||
| 35557d296d | |||
| d0d6550d7e | |||
| 38d9be2bb0 | |||
| fcb33d208c | |||
| f831ad6c7a | |||
| 3bfed5fabc | |||
| e1ce3ba2fd | |||
| a31f048ffd | |||
| 9c707466db | |||
| 4ff899729b | |||
| a8345d3509 | |||
| 1c3d6e2d78 | |||
| dde5ae7827 | |||
| aeef19cda3 | |||
| adec05c6f8 | |||
| 2c1e8f7db8 | |||
| 2c1e8f0cc2 | |||
| 5d434aeb68 | |||
| 54e7d3a397 | |||
| 7937291281 | |||
| 777f932266 | |||
| f37cb701b3 | |||
| 6498905377 | |||
| ed6c75fe37 | |||
| f759960641 | |||
| 75e8e51464 | |||
| 75ee140525 | |||
| 8d09637a7a | |||
| 62bfd35fbd | |||
| 091c94d609 | |||
| a72656cb4b | |||
| b3ef9f0ee6 | |||
| 0e4debdb8a | |||
| 476fae0e70 | |||
| 3de9d1be66 | |||
| 00916f188d | |||
| fff5f92893 | |||
| 8b796fc1f4 | |||
| 5ac679785b | |||
| 38b6cc8c65 | |||
| abb2d658c6 | |||
| 3587c085b9 | |||
| 581a7d79b3 | |||
| e21b80a5d8 | |||
| e0fc19ecaf | |||
| 0be0ce4c15 | |||
| 792358ea9c | |||
| 26c0c2488d | |||
| 70b4b9c28f | |||
| 8b4db423b4 | |||
| 7d65ede581 | |||
| 5b11f68fbb | |||
| 0d33e6887b | |||
| 5bb620a590 | |||
| 469386388b | |||
| 8728499f90 | |||
| 2e82dde2c3 | |||
| 253dd44d0d | |||
| 821b38e269 | |||
| 753bd21c14 | |||
| 10757d4ef4 | |||
| d436637086 | |||
| 0b5679ee07 | |||
| 97b6cdfaaa | |||
| b10d01cae0 | |||
| 2262264cae | |||
| bedd23eed8 | |||
| 12a1b12bac | |||
| c141e0b843 | |||
| 4d12fd9d20 | |||
| 5322fe910a | |||
| 70a6aa1a67 | |||
| 8d2be4178b | |||
| e89f2f63f8 | |||
| f6e7ebd33e | |||
| 5ea209de0b | |||
| a4b2dd222d | |||
| c84f5d479b | |||
| 9251d445d3 | |||
| c1ff7e36a8 |
@@ -0,0 +1 @@
|
||||
examples/** linguist-documentation
|
||||
@@ -25,14 +25,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install turbo
|
||||
run: pnpm install turbo --global
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish --no-git-checks --dry-run --filter ./packages/tuono
|
||||
run: pnpm publish -r --no-git-checks --dry-run
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -76,14 +73,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install turbo
|
||||
run: pnpm install turbo --global
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpm publish --no-git-checks --filter ./packages/tuono
|
||||
run: pnpm publish -r --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'crates/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'crates/**'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
lint_and_fmt:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
build_and_test:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
name: Build and test rust crates
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
toolchain:
|
||||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||
- run: cargo build --verbose
|
||||
- run: cargo test --verbose
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Typescript CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'packages/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'packages/**'
|
||||
|
||||
jobs:
|
||||
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
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
|
||||
- name: Test project
|
||||
run: pnpm test
|
||||
|
||||
fmt-lint-and-types:
|
||||
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
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Types
|
||||
run: pnpm types
|
||||
@@ -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
|
||||
+2
-1
@@ -7,5 +7,6 @@ members = [
|
||||
]
|
||||
exclude = [
|
||||
"examples/playground",
|
||||
"examples/tuono"
|
||||
"examples/tuono",
|
||||
"examples/tutorial"
|
||||
]
|
||||
|
||||
@@ -5,52 +5,95 @@
|
||||
<p align="center">
|
||||
⚠️ This project is under heavy development. API might drastically change ⚠️
|
||||
</p>
|
||||
<div align="center">
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg" />
|
||||
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg" />
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
|
||||
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||
Why Tuono? Just a badass name.
|
||||
|
||||
## Installation
|
||||
> Check out the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) to get started.
|
||||
|
||||
```
|
||||
cargo install tuono
|
||||
```
|
||||
## Introduction
|
||||
|
||||
## Create a new project
|
||||
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be fullstack right? (no)**
|
||||
|
||||
```
|
||||
tuono new
|
||||
```
|
||||
Tuono is a fullstack React framework with the server side written in Rust.
|
||||
Because of this Tuono is extremely fast and the requests are handled by multithreaded Rust server.
|
||||
React is still React - it is just superpowered.
|
||||
|
||||
## Development
|
||||
**Rust is an hard language then writing server side code is hard as well right? (no again)**
|
||||
|
||||
```
|
||||
tuono dev
|
||||
```
|
||||
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
|
||||
- Rust based server side rendering
|
||||
- FS routing
|
||||
- CSS modules
|
||||
- Hot Module Reload
|
||||
- Multi thread backend
|
||||
|
||||
- 🟦 Typescript
|
||||
- 🌐 Routing
|
||||
- 🔥 Hot Module Reload
|
||||
- 🍭 CSS modules
|
||||
- 🧬 Server Side Rendering
|
||||
- 🧵 Multi thread backend
|
||||
- ⚙️ Build optimizations
|
||||
- Custom APIs*
|
||||
- Image optimization*
|
||||
- Server streamed content*
|
||||
|
||||
> *Development in progress
|
||||
|
||||
## Getting started
|
||||
|
||||
As already mentioned above I strongly suggest you to take a look at the
|
||||
[tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md).
|
||||
|
||||
Tuono is basically a CLI that provides all the commands to handle the fullstack project.
|
||||
To download it you need [cargo](https://doc.rust-lang.org/cargo/) which is the [rust](https://www.rust-lang.org/)
|
||||
package manager.
|
||||
|
||||
To download and install it you just need to run `cargo install tuono`.
|
||||
|
||||
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` 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 to the `production` mode.
|
||||
|
||||
Now to execute it just run `cargo run --release`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- rust
|
||||
- cargo
|
||||
- node
|
||||
- npm/pnpm/yarn*
|
||||
|
||||
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
- .tuono
|
||||
- public/
|
||||
- src/
|
||||
| - routes/
|
||||
| | - api/
|
||||
├── package.json
|
||||
├── public
|
||||
├── src
|
||||
│ ├── routes
|
||||
│ └── styles
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Data is passed by the loading function
|
||||
const IndexPage = ({data, isLoading, isError}) => <h1>Index Page</h1>
|
||||
## Contributing
|
||||
Any help or suggestion will be appreciated and encouraged.
|
||||
|
||||
export const Route = createFileRoute({
|
||||
loader: (params) => fetchApi(params),
|
||||
component: IndexPage
|
||||
})
|
||||
```
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.0.7"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
|
||||
+19
-8
@@ -4,7 +4,8 @@ use std::process::Command;
|
||||
mod source_builder;
|
||||
use source_builder::{bundle_axum_source, create_client_entry_files};
|
||||
|
||||
use self::source_builder::check_tuono_folder;
|
||||
use crate::source_builder::{check_tuono_folder, Mode};
|
||||
|
||||
mod scaffold_project;
|
||||
mod watch;
|
||||
|
||||
@@ -15,7 +16,14 @@ enum Actions {
|
||||
/// Build the production assets
|
||||
Build,
|
||||
/// Scaffold a new project
|
||||
New { folder_name: Option<String> },
|
||||
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)]
|
||||
@@ -25,9 +33,9 @@ struct Args {
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn init_tuono_folder() -> std::io::Result<()> {
|
||||
fn init_tuono_folder(mode: Mode) -> std::io::Result<()> {
|
||||
check_tuono_folder()?;
|
||||
bundle_axum_source()?;
|
||||
bundle_axum_source(mode)?;
|
||||
create_client_entry_files()?;
|
||||
|
||||
Ok(())
|
||||
@@ -38,16 +46,19 @@ pub fn cli() -> std::io::Result<()> {
|
||||
|
||||
match args.action {
|
||||
Actions::Dev => {
|
||||
init_tuono_folder()?;
|
||||
init_tuono_folder(Mode::Dev)?;
|
||||
watch::watch().unwrap();
|
||||
}
|
||||
Actions::Build => {
|
||||
init_tuono_folder()?;
|
||||
init_tuono_folder(Mode::Prod)?;
|
||||
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
|
||||
let _ = &vite_build.output()?;
|
||||
}
|
||||
Actions::New { folder_name } => {
|
||||
scaffold_project::create_new_project(folder_name);
|
||||
Actions::New {
|
||||
folder_name,
|
||||
template,
|
||||
} => {
|
||||
scaffold_project::create_new_project(folder_name, template);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,11 @@ fn create_file(path: PathBuf, content: String) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_new_project(folder_name: Option<String>) {
|
||||
pub fn create_new_project(folder_name: Option<String>, template: Option<String>) {
|
||||
let folder = folder_name.unwrap_or(".".to_string());
|
||||
|
||||
if folder != "." {
|
||||
create_dir(&folder).unwrap();
|
||||
}
|
||||
// In case of missing select the tuono example
|
||||
let template = template.unwrap_or("tuono".to_string());
|
||||
|
||||
let client = blocking::Client::builder()
|
||||
.user_agent("")
|
||||
@@ -60,21 +59,25 @@ pub fn create_new_project(folder_name: Option<String>) {
|
||||
let new_project_files = res
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|GithubFile { path, .. }| {
|
||||
// TODO: Handle custom example download by CLI argument --template
|
||||
if path.starts_with("examples/tuono/") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
})
|
||||
.filter(|GithubFile { path, .. }| path.starts_with(&format!("examples/{template}/")))
|
||||
.collect::<Vec<&GithubFile>>();
|
||||
|
||||
if new_project_files.is_empty() {
|
||||
println!("Template not found: {template}");
|
||||
return;
|
||||
}
|
||||
|
||||
if folder != "." {
|
||||
create_dir(&folder).unwrap();
|
||||
}
|
||||
|
||||
let folder_name = PathBuf::from(&folder);
|
||||
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
||||
|
||||
let folder_path = current_dir.join(folder_name);
|
||||
|
||||
create_directories(&new_project_files, &folder_path).expect("Failed to create directories");
|
||||
create_directories(&new_project_files, &folder_path, &template)
|
||||
.expect("Failed to create directories");
|
||||
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
@@ -88,7 +91,7 @@ pub fn create_new_project(folder_name: Option<String>) {
|
||||
.text()
|
||||
.expect("Failed to parse the repo structure");
|
||||
|
||||
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let file_path = folder_path.join(&path);
|
||||
|
||||
@@ -101,13 +104,17 @@ pub fn create_new_project(folder_name: Option<String>) {
|
||||
outro(folder);
|
||||
}
|
||||
|
||||
fn create_directories(new_project_files: &Vec<&GithubFile>, folder_path: &Path) -> io::Result<()> {
|
||||
fn create_directories(
|
||||
new_project_files: &[&GithubFile],
|
||||
folder_path: &Path,
|
||||
template: &String,
|
||||
) -> io::Result<()> {
|
||||
for GithubFile {
|
||||
element_type, path, ..
|
||||
} in new_project_files.iter()
|
||||
{
|
||||
if let GithubFileType::Tree = element_type {
|
||||
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
|
||||
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||
|
||||
let dir_path = folder_path.join(&path);
|
||||
create_dir(&dir_path).unwrap();
|
||||
@@ -120,7 +127,7 @@ fn update_package_json_version(folder_path: &Path) -> io::Result<()> {
|
||||
let v = crate_version!();
|
||||
let package_json_path = folder_path.join(PathBuf::from("package.json"));
|
||||
let package_json = fs::read_to_string(&package_json_path)?;
|
||||
let package_json = package_json.replace("workspace:*", v);
|
||||
let package_json = package_json.replace("link:../../packages/tuono", v);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
@@ -158,7 +165,7 @@ fn outro(folder_name: String) {
|
||||
}
|
||||
|
||||
println!("\nInstall the dependencies:");
|
||||
println!("pnpm install");
|
||||
println!("npm install");
|
||||
|
||||
println!("\nRun the local environment:");
|
||||
println!("tuono dev");
|
||||
|
||||
@@ -8,16 +8,67 @@ use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod static_files;
|
||||
|
||||
const ROOT_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
#[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"
|
||||
}
|
||||
}
|
||||
|
||||
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { serverSideRendering } from 'tuono/ssr'
|
||||
|
||||
export const renderFn = serverSideRendering(routeTree)
|
||||
";
|
||||
|
||||
pub const CLIENT_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import 'vite/modulepreload-polyfill'
|
||||
import { hydrate } from 'tuono/hydration'
|
||||
import '../src/styles/global.css'
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
hydrate(routeTree)
|
||||
";
|
||||
|
||||
pub const AXUM_ENTRY_POINT: &str = r##"
|
||||
// File automatically generated
|
||||
// Do not manually change it
|
||||
|
||||
use tuono_lib::{tokio, Mode, Server, axum::Router, axum::routing::get};
|
||||
use reqwest::Client;
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let fetch = Client::new();
|
||||
|
||||
let router = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
.with_state(fetch);
|
||||
|
||||
Server::init(router, MODE).start().await
|
||||
}
|
||||
"##;
|
||||
|
||||
const ROOT_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Route {
|
||||
/// Every module import is the path with a _ instead of /
|
||||
@@ -59,7 +110,7 @@ impl Route {
|
||||
}
|
||||
|
||||
Route {
|
||||
module_import: module.as_str().to_string().replace('/', "_"),
|
||||
module_import: module.as_str().to_string().replace('/', "_").to_lowercase(),
|
||||
axum_route,
|
||||
}
|
||||
}
|
||||
@@ -67,17 +118,15 @@ impl Route {
|
||||
|
||||
struct SourceBuilder {
|
||||
route_map: HashMap<PathBuf, Route>,
|
||||
mode: Mode,
|
||||
base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SourceBuilder {
|
||||
pub fn new(mode: Mode) -> Self {
|
||||
pub fn new() -> Self {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
SourceBuilder {
|
||||
route_map: HashMap::new(),
|
||||
mode,
|
||||
base_path,
|
||||
}
|
||||
}
|
||||
@@ -147,16 +196,22 @@ mod {module_name};
|
||||
route_declarations
|
||||
}
|
||||
|
||||
pub fn bundle_axum_source() -> io::Result<()> {
|
||||
println!("Axum project bundling");
|
||||
|
||||
pub fn bundle_axum_source(mode: Mode) -> io::Result<()> {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
|
||||
source_builder.collect_routes();
|
||||
|
||||
let bundled_file = static_files::AXUM_ENTRY_POINT
|
||||
let bundled_file = generate_axum_source(&source_builder, mode);
|
||||
|
||||
create_main_file(&base_path, &bundled_file);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String {
|
||||
AXUM_ENTRY_POINT
|
||||
.replace(
|
||||
"// ROUTE_BUILDER\n",
|
||||
&create_routes_declaration(&source_builder.route_map),
|
||||
@@ -164,17 +219,13 @@ pub fn bundle_axum_source() -> io::Result<()> {
|
||||
.replace(
|
||||
"// MODULE_IMPORTS\n",
|
||||
&create_modules_declaration(&source_builder.route_map),
|
||||
);
|
||||
|
||||
create_main_file(&base_path, &bundled_file);
|
||||
|
||||
Ok(())
|
||||
)
|
||||
.replace("/*MODE*/", mode.as_str())
|
||||
}
|
||||
|
||||
pub fn check_tuono_folder() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
println!("exists");
|
||||
fs::create_dir(dev_folder)?;
|
||||
}
|
||||
|
||||
@@ -187,8 +238,8 @@ pub fn create_client_entry_files() -> io::Result<()> {
|
||||
let mut server_entry = fs::File::create(dev_folder.join("server-main.tsx"))?;
|
||||
let mut client_entry = fs::File::create(dev_folder.join("client-main.tsx"))?;
|
||||
|
||||
server_entry.write_all(static_files::SERVER_ENTRY_DATA.as_bytes())?;
|
||||
client_entry.write_all(static_files::CLIENT_ENTRY_DATA.as_bytes())?;
|
||||
server_entry.write_all(SERVER_ENTRY_DATA.as_bytes())?;
|
||||
client_entry.write_all(CLIENT_ENTRY_DATA.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -199,7 +250,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_dynamic_paths() {
|
||||
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),
|
||||
@@ -219,8 +270,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_routes() {
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
fn should_collect_routes() {
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
@@ -228,6 +279,7 @@ mod tests {
|
||||
"/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
|
||||
@@ -239,6 +291,7 @@ mod tests {
|
||||
("/about.rs", "about"),
|
||||
("/posts/index.rs", "posts_index"),
|
||||
("/posts/[post].rs", "posts_dyn_post"),
|
||||
("/posts/UPPERCASE.rs", "posts_uppercase"),
|
||||
];
|
||||
|
||||
results.into_iter().for_each(|(path, module_import)| {
|
||||
@@ -254,8 +307,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multi_level_axum_paths() {
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
||||
fn should_create_multi_level_axum_paths() {
|
||||
let mut source_builder = SourceBuilder::new();
|
||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||
|
||||
let routes = [
|
||||
@@ -289,4 +342,24 @@ mod tests {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_set_the_correct_mode() {
|
||||
let source_builder = SourceBuilder::new();
|
||||
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||
|
||||
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);
|
||||
|
||||
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { serverSideRendering } from 'tuono/ssr'
|
||||
|
||||
export const renderFn = serverSideRendering(routeTree)
|
||||
";
|
||||
|
||||
pub const CLIENT_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||
|
||||
import { hydrate } from 'tuono/hydration'
|
||||
import '../src/styles/global.css'
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
hydrate(routeTree)
|
||||
";
|
||||
|
||||
pub const AXUM_ENTRY_POINT: &str = r##"
|
||||
// File automatically generated
|
||||
// Do not manually change it
|
||||
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use axum::{routing::get, Router};
|
||||
use tower_http::services::ServeDir;
|
||||
use std::collections::HashMap;
|
||||
use tuono_lib::{ssr, Ssr};
|
||||
use reqwest::Client;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
Ssr::create_platform();
|
||||
|
||||
let fetch = Client::new();
|
||||
|
||||
let app = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
.fallback_service(ServeDir::new("public").fallback(get(catch_all)))
|
||||
.with_state(fetch);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
async fn catch_all(Path(params): Path<HashMap<String, String>>, request: Request) -> Html<String> {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = tuono_lib::Payload::new(&req, Box::new(""))
|
||||
.client_payload()
|
||||
.unwrap();
|
||||
|
||||
let result = ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)));
|
||||
|
||||
match result {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string()),
|
||||
}
|
||||
}
|
||||
"##;
|
||||
+44
-31
@@ -4,69 +4,82 @@ use watchexec_supervisor::command::{Command, Program};
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
use watchexec_supervisor::job::start_job;
|
||||
use watchexec_supervisor::job::{start_job, Job};
|
||||
|
||||
use crate::source_builder::bundle_axum_source;
|
||||
use crate::source_builder::{bundle_axum_source, Mode};
|
||||
|
||||
// What is the development pipeline?
|
||||
//
|
||||
// 1. Any file gets updates (rs/js/ts)
|
||||
// 2. Client side vite works separately
|
||||
// 3. Stop the dev server
|
||||
// 4. Build the ssr client bundle
|
||||
// 5. Restart the server
|
||||
//
|
||||
//
|
||||
// The current solution is not optimized
|
||||
// - We should avoid to bundle static lib (i.e. react) in the main bundle
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch() -> Result<()> {
|
||||
let (watch_client, _) = start_job(Arc::new(Command {
|
||||
fn watch_react_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "node_modules/.bin/tuono-dev-watch".into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}));
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
watch_client.start().await;
|
||||
|
||||
let (run_server, _) = start_job(Arc::new(Command {
|
||||
fn build_rust_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "cargo".into(),
|
||||
args: vec!["run".to_string()],
|
||||
args: vec!["run".to_string(), "-q".to_string()],
|
||||
},
|
||||
options: Default::default(),
|
||||
}));
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
let (build_ssr_bundle, _) = start_job(Arc::new(Command {
|
||||
fn build_react_ssr_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "node_modules/.bin/tuono-dev-ssr".into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}));
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch() -> Result<()> {
|
||||
println!("Starting development environment...");
|
||||
watch_react_src().start().await;
|
||||
|
||||
let run_server = build_rust_src();
|
||||
|
||||
let build_ssr_bundle = build_react_ssr_src();
|
||||
|
||||
build_ssr_bundle.start().await;
|
||||
|
||||
run_server.start().await;
|
||||
|
||||
build_ssr_bundle.to_wait().await;
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let mut should_reload = false;
|
||||
|
||||
for event in action.events.iter() {
|
||||
for path in event.paths() {
|
||||
if path.0.to_string_lossy().ends_with(".rs")
|
||||
// Either tsx and jsx
|
||||
|| path.0.to_string_lossy().ends_with("sx")
|
||||
{
|
||||
run_server.stop();
|
||||
println!("Reloading server...");
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source();
|
||||
run_server.start();
|
||||
should_reload = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload {
|
||||
println!("Reloading...");
|
||||
run_server.stop();
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
bundle_axum_source(Mode::Dev).expect("Failed to bunlde rust source");
|
||||
run_server.start();
|
||||
println!("Ready!");
|
||||
}
|
||||
|
||||
// if Ctrl-C is received, quit
|
||||
if action.signals().any(|sig| sig == Signal::Interrupt) {
|
||||
action.quit();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.0.7"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
@@ -18,10 +18,17 @@ name = "tuono_lib"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
ssr_rs = "0.5.2"
|
||||
axum = "0.7.5"
|
||||
ssr_rs = "0.5.5"
|
||||
axum = {version = "0.7.5", features = ["json"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
erased-serde = "0.4.5"
|
||||
serde_json = "1.0"
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.0.7"}
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.3.1"}
|
||||
once_cell = "1.19.0"
|
||||
lazy_static = "1.5.0"
|
||||
regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
|
||||
|
||||
@@ -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, headers, params);
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = Payload::new(&req, &"").client_payload().unwrap();
|
||||
|
||||
let result = 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()),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
mod catch_all;
|
||||
mod manifest;
|
||||
mod mode;
|
||||
mod payload;
|
||||
mod request;
|
||||
mod response;
|
||||
mod server;
|
||||
mod ssr;
|
||||
|
||||
pub mod ssr;
|
||||
|
||||
pub use mode::Mode;
|
||||
pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::Response;
|
||||
pub use ssr_rs::Ssr;
|
||||
pub use response::{Props, Response};
|
||||
pub use server::Server;
|
||||
pub use tuono_lib_macros::handler;
|
||||
|
||||
// Re-exports
|
||||
pub use axum;
|
||||
pub use tokio;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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)]
|
||||
pub struct BundleInfo {
|
||||
pub file: String,
|
||||
pub css: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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 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,294 @@
|
||||
use crate::manifest::MANIFEST;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use erased_serde::Serialize;
|
||||
use regex::Regex;
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
|
||||
use crate::request::Location;
|
||||
use crate::Request;
|
||||
use crate::request::{Location, Request};
|
||||
|
||||
fn has_dynamic_path(route: &str) -> bool {
|
||||
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||
regex.is_match(route)
|
||||
}
|
||||
|
||||
#[derive(SerdeSerialize)]
|
||||
/// This is the data shared to the client
|
||||
/// This is the payload sent to the client for hydration
|
||||
pub struct Payload<'a> {
|
||||
router: Location<'a>,
|
||||
props: Box<dyn Serialize>,
|
||||
router: Location,
|
||||
props: &'a dyn Serialize,
|
||||
mode: Mode,
|
||||
#[serde(rename(serialize = "jsBundles"))]
|
||||
js_bundles: Option<Vec<&'a String>>,
|
||||
#[serde(rename(serialize = "cssBundles"))]
|
||||
css_bundles: Option<Vec<&'a String>>,
|
||||
}
|
||||
|
||||
impl<'a> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: Box<dyn Serialize>) -> Payload<'a> {
|
||||
pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> {
|
||||
Payload {
|
||||
router: req.location(),
|
||||
props,
|
||||
mode: *GLOBAL_MODE.get().expect("Failed to load the current mode"),
|
||||
js_bundles: None,
|
||||
css_bundles: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client_payload(&self) -> Result<String, serde_json::Error> {
|
||||
pub fn client_payload(&mut self) -> Result<String, serde_json::Error> {
|
||||
if self.mode == Mode::Prod {
|
||||
self.add_bundle_sources();
|
||||
}
|
||||
serde_json::to_string(&self)
|
||||
}
|
||||
|
||||
/// This method adds the route specific bundles to the server
|
||||
/// side rendered HTML.
|
||||
///
|
||||
/// The same matching algorithm is implemented on the client side in
|
||||
/// this file (packages/tuono/src/router/components/Matches.ts).
|
||||
///
|
||||
/// Optimizations should occour on both.
|
||||
fn add_bundle_sources(&mut self) {
|
||||
// Manifest should always be loaded. The load happen before starting
|
||||
// the server.
|
||||
let manifest = MANIFEST.get().expect("Failed to load manifest");
|
||||
|
||||
// The main bundle should always exist.
|
||||
// The extension should be tsx even with JS only projects.
|
||||
let main_bundle = manifest
|
||||
.get("client-main")
|
||||
.expect("Failed to get client-main bundle");
|
||||
|
||||
let mut js_bundles_sources = vec![&main_bundle.file];
|
||||
let mut css_bundles_sources = main_bundle.css.iter().collect::<Vec<&String>>();
|
||||
|
||||
let pathname = &self.router.pathname();
|
||||
|
||||
let bundle_data = manifest.get(*pathname);
|
||||
|
||||
if let Some(data) = bundle_data {
|
||||
js_bundles_sources.push(&data.file);
|
||||
|
||||
data.css
|
||||
.iter()
|
||||
.for_each(|source| css_bundles_sources.push(source))
|
||||
} else {
|
||||
let dynamic_routes = manifest
|
||||
.keys()
|
||||
.filter(|path| has_dynamic_path(path))
|
||||
.collect::<Vec<&String>>();
|
||||
|
||||
if !dynamic_routes.is_empty() {
|
||||
let path_segments = pathname
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
for dyn_route in dynamic_routes.iter() {
|
||||
let dyn_route_segments = dyn_route
|
||||
.split('/')
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
let mut route_segments_collector: Vec<&str> = vec![];
|
||||
|
||||
for i in 0..dyn_route_segments.len() {
|
||||
if path_segments.len() == i {
|
||||
break;
|
||||
}
|
||||
if dyn_route_segments[i] == path_segments[i]
|
||||
|| has_dynamic_path(dyn_route_segments[i])
|
||||
{
|
||||
route_segments_collector.push(dyn_route_segments[i])
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if route_segments_collector.len() == path_segments.len() {
|
||||
let manifest_key = route_segments_collector.join("/");
|
||||
|
||||
let route_data = manifest.get(&format!("/{manifest_key}"));
|
||||
if let Some(data) = route_data {
|
||||
js_bundles_sources.push(&data.file);
|
||||
data.css
|
||||
.iter()
|
||||
.for_each(|source| css_bundles_sources.push(source))
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.js_bundles = Some(js_bundles_sources);
|
||||
self.css_bundles = Some(css_bundles_sources);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use axum::http::Uri;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::manifest::BundleInfo;
|
||||
|
||||
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'static> {
|
||||
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 location = Location::from(
|
||||
&uri.unwrap_or("http://localhost:3000/")
|
||||
.parse::<Uri>()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
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,24 +3,42 @@ use std::collections::HashMap;
|
||||
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
|
||||
/// Location must match client side interface
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Location {
|
||||
href: String,
|
||||
pathname: String,
|
||||
search: HashMap<String, String>,
|
||||
search_str: String,
|
||||
hash: String,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub fn pathname(&self) -> &String {
|
||||
&self.pathname
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Uri> for Location {
|
||||
fn from(uri: &Uri) -> Self {
|
||||
Location {
|
||||
href: uri.to_string(),
|
||||
pathname: uri.path().to_string(),
|
||||
// TODO: handler search map
|
||||
search: HashMap::new(),
|
||||
search_str: uri.query().unwrap_or("").to_string(),
|
||||
hash: "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request<'a> {
|
||||
uri: &'a Uri,
|
||||
headers: &'a HeaderMap,
|
||||
pub headers: &'a HeaderMap,
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Location must match client side interface
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Location<'a> {
|
||||
href: String,
|
||||
pathname: &'a str,
|
||||
search: HashMap<String, String>,
|
||||
search_str: &'a str,
|
||||
/// Server does not need it. Will be hanlder client side
|
||||
hash: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Request<'a> {
|
||||
pub fn new(
|
||||
uri: &'a Uri,
|
||||
@@ -34,14 +52,7 @@ impl<'a> Request<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Location<'a> {
|
||||
Location {
|
||||
href: self.uri.to_string(),
|
||||
pathname: &self.uri.path(),
|
||||
// TODO: hanler search map
|
||||
search: HashMap::new(),
|
||||
search_str: &self.uri.query().unwrap_or(""),
|
||||
hash: "",
|
||||
}
|
||||
pub fn location(&self) -> Location {
|
||||
Location::from(self.uri)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,98 @@
|
||||
use crate::Request;
|
||||
use crate::{ssr::Js, Payload};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse};
|
||||
use axum::Json;
|
||||
use erased_serde::Serialize;
|
||||
|
||||
pub struct Props {
|
||||
data: Box<dyn Serialize>,
|
||||
http_code: StatusCode,
|
||||
}
|
||||
|
||||
pub enum Response {
|
||||
Redirect(String),
|
||||
Props(Box<dyn Serialize>),
|
||||
Props(Props),
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResponseInfo {
|
||||
redirect_destination: Option<String>,
|
||||
}
|
||||
|
||||
impl JsonResponseInfo {
|
||||
fn new(redirect_destination: Option<String>) -> JsonResponseInfo {
|
||||
JsonResponseInfo {
|
||||
redirect_destination,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResponse<'a> {
|
||||
data: Option<&'a dyn Serialize>,
|
||||
info: JsonResponseInfo,
|
||||
}
|
||||
|
||||
impl<'a> JsonResponse<'a> {
|
||||
fn new(props: &'a dyn Serialize) -> Self {
|
||||
JsonResponse {
|
||||
data: Some(props),
|
||||
info: JsonResponseInfo::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_redirect(destination: String) -> Self {
|
||||
JsonResponse {
|
||||
data: None,
|
||||
info: JsonResponseInfo::new(Some(destination)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub fn new(data: impl Serialize + 'static) -> Self {
|
||||
Props {
|
||||
data: Box::new(data),
|
||||
http_code: StatusCode::OK,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self {
|
||||
Props {
|
||||
data: Box::new(data),
|
||||
http_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn render_to_string(&self, req: Request) -> AxumResponse {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
let payload = Payload::new(&req, data).client_payload().unwrap();
|
||||
|
||||
match Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) {
|
||||
Ok(html) => (*http_code, Html(html)).into_response(),
|
||||
Err(_) => {
|
||||
(*http_code, Html("500 Internal server error".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Redirect(to) => Redirect::permanent(to).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json(&self) -> impl IntoResponse {
|
||||
match self {
|
||||
Self::Props(Props { data, http_code }) => {
|
||||
(*http_code, Json(JsonResponse::new(data))).into_response()
|
||||
}
|
||||
Self::Redirect(destination) => (
|
||||
StatusCode::PERMANENT_REDIRECT,
|
||||
Json(JsonResponse::new_redirect(destination.to_string())),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
|
||||
const DEV_PUBLIC_DIR: &str = "public";
|
||||
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||
|
||||
pub struct Server {
|
||||
router: Router,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn init(router: Router, 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();
|
||||
|
||||
if self.mode == Mode::Dev {
|
||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||
} else {
|
||||
println!("\nProduction app ready at http://localhost:3000/");
|
||||
}
|
||||
|
||||
let public_dir = if self.mode == Mode::Dev {
|
||||
DEV_PUBLIC_DIR
|
||||
} else {
|
||||
PROD_PUBLIC_DIR
|
||||
};
|
||||
|
||||
let router = self
|
||||
.router
|
||||
.to_owned()
|
||||
.fallback_service(ServeDir::new(public_dir).fallback(get(catch_all)));
|
||||
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use lazy_static::lazy_static;
|
||||
use ssr_rs::Ssr;
|
||||
use std::cell::RefCell;
|
||||
use std::fs::read_to_string;
|
||||
use std::path::PathBuf;
|
||||
|
||||
lazy_static! {
|
||||
static ref BUNDLE_PATH: &'static str = {
|
||||
if GLOBAL_MODE.get().unwrap() == &Mode::Dev {
|
||||
return "./.tuono/server/dev-server.js";
|
||||
}
|
||||
"./out/server/prod-server.js"
|
||||
};
|
||||
}
|
||||
|
||||
pub struct Js;
|
||||
|
||||
impl Js {
|
||||
thread_local! {
|
||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||
// TODO: handle here dev/prod source
|
||||
Ssr::from(
|
||||
read_to_string("./.tuono/server/dev-server.js").unwrap(),
|
||||
""
|
||||
read_to_string(PathBuf::from(*BUNDLE_PATH)).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.0.7"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
homepage = "https://github.com/Valerioageno/tuono"
|
||||
repository = "https://github.com/Valerioageno/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
|
||||
@@ -8,59 +8,37 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let fn_name = item.clone().sig.ident;
|
||||
|
||||
quote! {
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use tuono_lib::axum::response::IntoResponse;
|
||||
use std::collections::HashMap;
|
||||
use axum::extract::{State, Path};
|
||||
use tuono_lib::axum::extract::{State, Path};
|
||||
use reqwest::Client;
|
||||
|
||||
#item
|
||||
|
||||
pub async fn route(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
) -> Html<String> {
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: tuono_lib::axum::extract::Request
|
||||
) -> impl IntoResponse {
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
|
||||
let local_response = #fn_name(req.clone(), client).await;
|
||||
|
||||
let res = match local_response {
|
||||
tuono_lib::Response::Props(val) => {
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = tuono_lib::Payload::new(&req, val).client_payload().unwrap();
|
||||
|
||||
tuono_lib::ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)))
|
||||
},
|
||||
/// TODO: handle here redirection and rewrite
|
||||
_ => Ok("500 Internal server error".to_string())
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(html) => Html(html),
|
||||
_ => Html("500 internal server error".to_string())
|
||||
}
|
||||
#fn_name(req.clone(), client).await.render_to_string(req)
|
||||
}
|
||||
|
||||
pub async fn api(
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: axum::extract::Request
|
||||
) -> axum::response::Response {
|
||||
let pathname = &request.uri();
|
||||
Path(params): Path<HashMap<String, String>>,
|
||||
State(client): State<Client>,
|
||||
request: tuono_lib::axum::extract::Request
|
||||
) -> impl IntoResponse{
|
||||
let pathname = &request.uri();
|
||||
let headers = &request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||
|
||||
let local_response = #fn_name(req.clone(), client).await;
|
||||
|
||||
let res = match local_response{
|
||||
tuono_lib::Response::Props(val) => return axum::Json(val).into_response(),
|
||||
_ => return axum::Json("").into_response()
|
||||
};
|
||||
#fn_name(req.clone(), client).await.json()
|
||||
}
|
||||
}
|
||||
.into()
|
||||
|
||||
@@ -7,5 +7,3 @@ mod handler;
|
||||
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
handler::handler_core(args, item)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
# Tuono tutorial
|
||||
|
||||
This tutorial is meant for giving you a sneak peek of the framework and is intended to evolve along the development - be sure to have installed the latest version.
|
||||
|
||||
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).
|
||||
|
||||
> 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
|
||||
|
||||
* [CLI Installation](#cli-installation)
|
||||
* [Project scaffold](#project-scaffold)
|
||||
* [Start the dev environment](#start-the-dev-environment)
|
||||
* [The “/” route](#the--route)
|
||||
* [Tutorial introduction](#tutorial-introduction)
|
||||
* [Fetch all the pokemons](#fetch-all-the-pokemons)
|
||||
* [Create a stand-alone component](#create-a-stand-alone-component)
|
||||
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||
* [Error handling](#error-handling)
|
||||
* [Handle redirections](#handle-redirections)
|
||||
* [Building for production](#building-for-production)
|
||||
* [Conclusion](#conclusion)
|
||||
|
||||
## CLI Installation
|
||||
|
||||
The tuono CLI is hosted on [crates.io](https://crates.io/crates/tuono); to download and install it just run on a terminal:
|
||||
|
||||
```bash
|
||||
$ cargo install tuono
|
||||
```
|
||||
|
||||
To check that is correctly installed run:
|
||||
|
||||
```bash
|
||||
$ tuono --version
|
||||
```
|
||||
|
||||
Run `tuono -h` to see all the available commands.
|
||||
|
||||
## Project scaffold
|
||||
|
||||
To setup a new fresh project you just need to run the following command:
|
||||
|
||||
```bash
|
||||
$ tuono new tuono-tutorial
|
||||
```
|
||||
|
||||
Get into the project folder and install the dependencies with:
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
Open it with your favourite code editor.
|
||||
|
||||
The project will have the following structure:
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── public
|
||||
├── src
|
||||
│ ├── routes
|
||||
│ └── styles
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**public/**: put here all the files you want to be public
|
||||
|
||||
**src/routes/**: All the files in this folder are considered routes. All the routes are server side rendered out of the box. To add server side capabilities just create a rust file with the same name as the route (i.e. `about.tsx` → `about.rs`).
|
||||
|
||||
**src/styles/**: In this folder there is the `global.css` file that stores all the global styles. For the rest of the project you can use CSS modules (⚠️ CSS modules on routes are forbidden).
|
||||
|
||||
## Start the dev environment
|
||||
|
||||
To start the development environment you just need to run the following command within the project folder:
|
||||
|
||||
```bash
|
||||
$ tuono dev
|
||||
```
|
||||
The first time might take a little bit because it will install all the rust’s dependencies. All the other execution will be pretty quick!
|
||||
|
||||
> 💡 The `tuono dev` development script is currently under strong optimization improvements. In case you face any error delete the cache `.tuono` folder and run it again!
|
||||
|
||||
Then open [`http://localhost:3000/`](http://localhost:3000/) on the browser.
|
||||
|
||||
## The “/” route
|
||||
|
||||
All the `index.tsx` files represent the folder root page (i.e. `src/routes/posts/index.tsx` is [`http://localhost:3000/posts`](http://localhost:3000/posts) as well as `src/routes/posts.tsx`).
|
||||
|
||||
The file `index.rs` represents the server side capabilities for the index route. On this file you can:
|
||||
|
||||
- Passing server side props
|
||||
- Changing http status code
|
||||
- Redirecting to a different route
|
||||
|
||||
## Tutorial introduction
|
||||
|
||||
Now that we have some knowledge about the project structure let’s start the real tutorial.
|
||||
|
||||
The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately.
|
||||
|
||||
> If you have already installed the tuono CLI and you prefer read the code instead of writing it yourself
|
||||
you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
|
||||
|
||||
## Fetch all the pokemons
|
||||
|
||||
To start let’s fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file.
|
||||
|
||||
Clear the `index.rs` file and paste:
|
||||
|
||||
```rust
|
||||
// src/routes/index.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemons {
|
||||
results: Vec<Pokemon>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Props::new("{}")),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Now the pokemons are correctly fetched and hydrated on the client side so we can actually use them. Clear the `index.tsx` file and paste:
|
||||
|
||||
```tsx
|
||||
// src/routes/index.tsx
|
||||
import type { TuonoProps } from "tuono";
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface IndexProps {
|
||||
results: Pokemon[]
|
||||
}
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
}: TuonoProps<IndexProps>): JSX.Element {
|
||||
if (!data?.results) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||
Crates
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/tuono" target="_blank">
|
||||
Npm
|
||||
</a>
|
||||
</header>
|
||||
<div className="title-wrap">
|
||||
<h1 className="title">
|
||||
TU<span>O</span>NO
|
||||
</h1>
|
||||
<div className="logo">
|
||||
<img src="rust.svg" className="rust" />
|
||||
<img src="react.svg" className="react" />
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
|
||||
{data.results.map((pokemon) => {
|
||||
return pokemon.name;
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Refresh now the browser! A bit ugly but all the pokemons are finally printed on screen!
|
||||
|
||||
## Create a stand-alone component
|
||||
|
||||
Let’s then create the button needed for displaying the list of pokemons.
|
||||
|
||||
Create the following file `src/components/PokemonLink.tsx` and fill the content with:
|
||||
|
||||
```tsx
|
||||
// src/components/PokemonLink.tsx
|
||||
import { Link } from "tuono";
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function PokemonLink({
|
||||
pokemon,
|
||||
id,
|
||||
}: {
|
||||
pokemon: Pokemon;
|
||||
id: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Link href={`/pokemons/${pokemon.name}`}>
|
||||
{pokemon.name}
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Now that the link is done let’s import it in the `index.tsx` file
|
||||
|
||||
```diff
|
||||
// src/routes/index.tsx
|
||||
|
||||
++ import PokemonLink from '../components/PokemonLink'
|
||||
|
||||
// ...
|
||||
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
|
||||
-- {pokemons.map((pokemon) => {
|
||||
-- return pokemon.name;
|
||||
++ {pokemons.map((pokemon, i) => {
|
||||
++ return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />;
|
||||
})}
|
||||
</ul>
|
||||
// ...
|
||||
```
|
||||
|
||||
Now the links work. Clicking on any of them we get redirected to the 404 page because we haven’t yet implemented the `pokemons/[pokemon]` page.
|
||||
As previously said CSS modules are enabled out of the box so let’s make those links a little bit nicer.
|
||||
|
||||
Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.module.css` and copy the following content into it:
|
||||
|
||||
```css
|
||||
/* src/components/PokemonLink.module.css */
|
||||
|
||||
.link {
|
||||
width: 100%;
|
||||
max-width: 216px;
|
||||
position: relative;
|
||||
background: white;
|
||||
margin-bottom: 10px;
|
||||
border: solid #f0f0f0 1px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
padding: 5px 5px 5px 15px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
transition: 0.2s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
|
||||
}
|
||||
|
||||
.link img {
|
||||
width: 70px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 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:
|
||||
|
||||
```diff
|
||||
// src/components/PokemonLink.tsx
|
||||
import { Link } from "tuono";
|
||||
import type { Pokemon } from "./../types/pokemon";
|
||||
++ import styles from './PokemonLink.module.css'
|
||||
|
||||
export default function PokemonLink({
|
||||
pokemon,
|
||||
id,
|
||||
}: {
|
||||
pokemon: Pokemon;
|
||||
id: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
-- <Link href={`/pokemons/${pokemon.name}`}>
|
||||
++ <Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
|
||||
{pokemon.name}
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Create the `/pokemons/[pokemon]` route
|
||||
|
||||
The homepage is ready. We have the full list of pokemons and they are all links. Great!
|
||||
|
||||
Now we want to make those links actually pointing to a real page. Let’s create the dynamic route.
|
||||
|
||||
Create the folder `routes/pokemons` and then create the two files `[pokemon].tsx` and `[pokemon].rs`.
|
||||
|
||||
These two will handle every requests that points to `http://localhost:3000/pokemons/bulbasaur..mew`.
|
||||
|
||||
Let’s first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
|
||||
|
||||
```rust
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
id: u16,
|
||||
weight: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemon>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Props::new("{}"))
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Then let’s work on the frontend. Paste into the `[pokemon].tsx` file the following code:
|
||||
|
||||
```tsx
|
||||
import { TuonoProps } from "tuono";
|
||||
import PokemonView from "../../components/PokemonView";
|
||||
|
||||
export default function Pokemon({ data }: TuonoProps): JSX.Element {
|
||||
return <PokemonView pokemon={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
The browser should complain that the component `PokemonView` does not exist. Let’s create it then!
|
||||
|
||||
```tsx
|
||||
// components/PokemonView.tsx
|
||||
import { Link } from "tuono";
|
||||
import styles from "./PokemonView.module.css";
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
id: string
|
||||
weight: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export default function PokemonView({
|
||||
pokemon,
|
||||
}: {
|
||||
pokemon?: Pokemon;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Link className={styles["back-btn"]} href="/">
|
||||
Back
|
||||
</Link>
|
||||
{pokemon?.name && (
|
||||
<div className={styles.pokemon}>
|
||||
<div>
|
||||
<h1 className={styles.name}>
|
||||
{pokemon.name}
|
||||
</h1>
|
||||
<dl className={styles.spec}>
|
||||
<dt className={styles.label}>Weight:</dt>
|
||||
<dd>{pokemon.weight}lbs</dd>
|
||||
</dl>
|
||||
<dl className={styles.spec}>
|
||||
<dt className={styles.label}>Height:</dt>
|
||||
<dd>{pokemon.height}ft</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemon.id}.png`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* components/PokemonView.module.css */
|
||||
.back-btn {
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
padding: 7px 15px;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
border: solid #f0f0f0 1px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
|
||||
}
|
||||
|
||||
.pokemon {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 50px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pokemon img {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.spec {
|
||||
display: flex;
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 700;
|
||||
}
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
With the current setup all the routes always return a `200 Success` http status no matter the response type.
|
||||
|
||||
In order to return a more meaningful status code to the browser the `Props` struct can be initialized with also the
|
||||
`Props::new_with_status()` method.
|
||||
|
||||
Let's see how it works!
|
||||
|
||||
```diff
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
id: u16,
|
||||
weight: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
++ if res.status() == StatusCode::NOT_FOUND {
|
||||
++ return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||
++ }
|
||||
|
||||
let data = res.json::<Pokemon>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
-- Err(_err) => Response::Props(Props::new(
|
||||
++ Err(_err) => Response::Props(Props::new_with_status(
|
||||
++ "{}",
|
||||
++ StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
// src/routes/index.rs
|
||||
++ use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemons {
|
||||
results: Vec<Pokemon>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
-- Err(_err) => Response::Props(Props::new(
|
||||
++ Err(_err) => Response::Props(Props::new_with_status(
|
||||
++ "{}", // Return empty JSON
|
||||
++ StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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::{Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn redirect_to_goat(_: Request<'_>, _: reqwest::Client) -> Response {
|
||||
// Of course the GOAT is mewtwo - feel free to select your favourite 😉
|
||||
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
Now let's create the button in the home page to actually point to it!
|
||||
|
||||
```diff
|
||||
// src/routes/index.tsx
|
||||
|
||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||
++ <PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||
{data.results.map((pokemon, i) => {
|
||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||
})}
|
||||
</ul>
|
||||
```
|
||||
|
||||
Now at [http://localhost:3000/](http:/localhost:3000/) you will find a new link at the beginning of the list.
|
||||
Click on it and see the application automatically redirecting you to your favourite pokemon's route!
|
||||
|
||||
## Building for production
|
||||
|
||||
The source now is ready to be released. Both server and client have been managed in a unoptimized way
|
||||
to easy the development experience. To build the project to the production state just run:
|
||||
|
||||
```shell
|
||||
$ tuono build
|
||||
```
|
||||
|
||||
This command just created the final assets within the `out` directory. To run then the prodiction server
|
||||
run:
|
||||
|
||||
```shell
|
||||
$ cargo run --release
|
||||
```
|
||||
|
||||
Check again [`http://localhost:3000/`](http://localhost:3000/) - This environment now has all the
|
||||
optimizations ready to unleash the power of a rust server that seamessly renders a React application!🚀
|
||||
|
||||
> Note: The `out` directory is not standalone. You can't rely just on it to run the production server.
|
||||
|
||||
## Conclusion
|
||||
|
||||
That’s it! You just created a multi thread full stack application with rust and react.
|
||||
|
||||
The project is still under heavy development and many features are not ready yet but
|
||||
I hope you got the taste of what is like working with rust and react in the same stack.
|
||||
|
||||
As I mentioned in the introduction I'd love to hear what you thought about the framework and the tutorial - feel free to reach me
|
||||
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs [@valerioageno](https://twitter.com/valerioageno).
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tuono examples
|
||||
|
||||
This folder includes all the official tuono starters.
|
||||
|
||||
To simply scaffold the base project run in your terminal:
|
||||
|
||||
```shell
|
||||
$ tuono new [NAME]
|
||||
```
|
||||
|
||||
You can install any example included in this folder by just using the `--template` flag:
|
||||
|
||||
```shell
|
||||
$ tuono new [NAME] --template [TEMPLATE]
|
||||
```
|
||||
|
||||
`[TEMPLATE]` is the folder name.
|
||||
|
||||
|
||||
@@ -8,11 +8,5 @@ name = "tuono"
|
||||
path = ".tuono/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = {version = "0.7.5", features = ["json"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||
serde_json = "1.0"
|
||||
reqwest = {version = "0.12.4", features = ["json"]}
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# TODO: Basic Tuono app
|
||||
# Tuono starter
|
||||
|
||||
This is the starter tuono project. To download it run in your terminal:
|
||||
|
||||
```shell
|
||||
$ tuono new [NAME]
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tuono": "workspace:*"
|
||||
"tuono": "link:../../packages/tuono"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Button(): JSX.Element {
|
||||
return <button>Button</button>
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import type { ReactNode } from 'react'
|
||||
interface RootRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<main className="main">{children}</main>
|
||||
</>
|
||||
)
|
||||
return <main className="main">{children}</main>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use tuono_lib::{Request, Response};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MyResponse<'a> {
|
||||
@@ -8,7 +8,7 @@ struct MyResponse<'a> {
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
|
||||
Response::Props(Box::new(MyResponse {
|
||||
Response::Props(Props::new(MyResponse {
|
||||
subtitle: "The react / rust fullstack framework",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TuonoProps } from 'tuono'
|
||||
|
||||
type IndexProps = {
|
||||
interface IndexProps {
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
isLoading,
|
||||
@@ -32,7 +33,12 @@ export default function IndexPage({
|
||||
</div>
|
||||
<div className="subtitle-wrap">
|
||||
<p className="subtitle">{data?.subtitle}</p>
|
||||
<a href="https://github.com/Valerioageno/tuono" target="_blank">
|
||||
<a
|
||||
href="https://github.com/Valerioageno/tuono"
|
||||
target="_blank"
|
||||
className="button"
|
||||
type="button"
|
||||
>
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -51,12 +51,32 @@ main {
|
||||
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;
|
||||
|
||||
@@ -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,14 @@
|
||||
[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"] }
|
||||
reqwest = {version = "0.12.4", features = ["json"]}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Tuono tutorial
|
||||
|
||||
This project is the outcome of the [tuono tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
|
||||
|
||||
If you want to directly install it you can run:
|
||||
|
||||
```bash
|
||||
$ tuono new my-project -t tutorial
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tuono-tutorial",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"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.4.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="82" height="73" viewBox="0 0 82 73" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M81.337 36.6169C81.337 31.245 74.5945 26.1541 64.2571 22.9971C66.6426 12.4847 65.5824 4.1211 60.9107 1.44343C59.8339 0.815331 58.5748 0.517811 57.1998 0.517811V4.20375C57.9619 4.20375 58.5748 4.35251 59.0884 4.6335C61.3414 5.92275 62.3188 10.8318 61.5568 17.1458C61.3745 18.6996 61.0763 20.3359 60.7119 22.0053C57.4649 21.212 53.9197 20.6004 50.1923 20.2037C47.9558 17.1459 45.6365 14.369 43.3007 11.9393C48.7013 6.93101 53.7706 4.18722 57.2164 4.18722V0.501282C52.6607 0.501282 46.6967 3.74094 40.6666 9.36076C34.6364 3.774 28.6726 0.567398 24.1168 0.567398V4.25333C27.5461 4.25333 32.6319 6.9806 38.0326 11.9558C35.7133 14.3855 33.394 17.1459 31.1907 20.2037C27.4467 20.6004 23.9015 21.212 20.6545 22.0219C20.2735 20.369 19.9918 18.7657 19.793 17.2285C19.0144 10.9145 19.9752 6.0054 22.2117 4.69962C22.7087 4.4021 23.3548 4.26987 24.1168 4.26987V0.583927C22.7252 0.583927 21.4662 0.881447 20.3728 1.50954C15.7177 4.18722 14.674 12.5343 17.0761 23.0136C6.77188 26.1871 0.0625 31.2615 0.0625 36.6169C0.0625 41.9887 6.805 47.0796 17.1424 50.2366C14.7568 60.749 15.8171 69.1126 20.4888 71.7903C21.5656 72.4184 22.8246 72.7159 24.2162 72.7159C28.772 72.7159 34.7359 69.4762 40.766 63.8564C46.7962 69.4432 52.76 72.6498 57.3158 72.6498C58.7073 72.6498 59.9664 72.3523 61.0598 71.7242C65.7149 69.0465 66.7586 60.6994 64.3565 50.2201C74.6276 47.0631 81.337 41.9722 81.337 36.6169ZM59.7676 25.5921C59.1547 27.7243 58.3926 29.9227 57.5311 32.121C56.8519 30.7987 56.1396 29.4764 55.361 28.1541C54.5989 26.8318 53.7872 25.5425 52.9754 24.2863C55.3278 24.6334 57.5974 25.0632 59.7676 25.5921ZM52.1802 43.1953C50.888 45.4267 49.5628 47.5424 48.1877 49.5094C45.7194 49.7243 43.2178 49.84 40.6997 49.84C38.1982 49.84 35.6967 49.7242 33.2449 49.5259C31.8699 47.559 30.528 45.4598 29.2358 43.2449C27.9768 41.0797 26.8337 38.8813 25.79 36.6664C26.8172 34.4516 27.9768 32.2367 29.2193 30.0714C30.5114 27.84 31.8368 25.7243 33.2118 23.7574C35.6801 23.5425 38.1817 23.4268 40.6997 23.4268C43.2013 23.4268 45.7028 23.5425 48.1546 23.7409C49.5296 25.7078 50.8715 27.807 52.1637 30.0218C53.4227 32.1871 54.5658 34.3855 55.6094 36.6003C54.5658 38.8152 53.4227 41.0301 52.1802 43.1953ZM57.5311 41.0466C58.4257 43.2615 59.1878 45.4763 59.8173 47.6251C57.6471 48.154 55.361 48.6003 52.992 48.9474C53.8037 47.6747 54.6155 46.3689 55.3775 45.0301C56.1396 43.7077 56.8519 42.3689 57.5311 41.0466ZM40.7329 58.6829C39.1922 57.0961 37.6515 55.3275 36.1274 53.3937C37.6184 53.4598 39.1425 53.5094 40.6832 53.5094C42.2404 53.5094 43.7811 53.4763 45.2886 53.3937C43.7976 55.3275 42.257 57.0961 40.7329 58.6829ZM28.4075 48.9474C26.0551 48.6003 23.7855 48.1705 21.6153 47.6416C22.2283 45.5094 22.9903 43.311 23.8518 41.1127C24.531 42.435 25.2434 43.7573 26.022 45.0796C26.8006 46.402 27.5958 47.6912 28.4075 48.9474ZM40.6501 14.5508C42.1907 16.1376 43.7314 17.9062 45.2555 19.8401C43.7645 19.7739 42.2404 19.7244 40.6997 19.7244C39.1425 19.7244 37.6018 19.7574 36.0943 19.8401C37.5853 17.9062 39.126 16.1376 40.6501 14.5508ZM28.391 24.2863C27.5792 25.559 26.7675 26.8648 26.0054 28.2037C25.2433 29.526 24.531 30.8483 23.8518 32.1706C22.9572 29.9557 22.1951 27.7409 21.5656 25.5921C23.7358 25.0797 26.022 24.6334 28.391 24.2863ZM13.3984 44.9805C7.53392 42.4846 3.74023 39.2119 3.74023 36.6169C3.74023 34.0218 7.53392 30.7326 13.3984 28.2533C14.8231 27.6417 16.3803 27.0962 17.9873 26.5838C18.9316 29.8235 20.1741 33.1954 21.7147 36.6499C20.1906 40.0879 18.9647 43.4433 18.037 46.6664C16.3969 46.154 14.8397 45.592 13.3984 44.9805ZM22.3111 68.6002C20.0581 67.311 19.0807 62.4019 19.8427 56.0879C20.0249 54.5341 20.3231 52.8978 20.6876 51.2284C23.9346 52.0218 27.4798 52.6333 31.2072 53.03C33.4437 56.0879 35.763 58.8647 38.0988 61.2945C32.6982 66.3027 27.6289 69.0465 24.1831 69.0465C23.4376 69.03 22.8081 68.8812 22.3111 68.6002ZM61.6065 56.0052C62.3851 62.3192 61.4242 67.2283 59.1878 68.5341C58.6908 68.8316 58.0447 68.9639 57.2827 68.9639C53.8534 68.9639 48.7675 66.2366 43.3669 61.2614C45.6862 58.8317 48.0055 56.0713 50.2088 53.0135C53.9528 52.6168 57.498 52.0052 60.745 51.1953C61.1261 52.8647 61.4242 54.468 61.6065 56.0052ZM67.9845 44.9805C66.5598 45.592 65.0026 46.1375 63.3956 46.6499C62.4513 43.4102 61.2089 40.0383 59.6682 36.5838C61.1923 33.1458 62.4182 29.7904 63.3459 26.5673C64.986 27.0797 66.5432 27.6417 68.0011 28.2533C73.8656 30.7491 77.6592 34.0218 77.6592 36.6169C77.6427 39.2119 73.849 42.5011 67.9845 44.9805Z" fill="#61DAFB"/>
|
||||
<path d="M40.6832 44.1706C44.8644 44.1706 48.254 40.7886 48.254 36.6169C48.254 32.4451 44.8644 29.0632 40.6832 29.0632C36.502 29.0632 33.1124 32.4451 33.1124 36.6169C33.1124 40.7886 36.502 44.1706 40.6832 44.1706Z" fill="#61DAFB"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,26 @@
|
||||
.link {
|
||||
width: 100%;
|
||||
max-width: 216px;
|
||||
position: relative;
|
||||
background: white;
|
||||
margin-bottom: 10px;
|
||||
border: solid #f0f0f0 1px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
padding: 5px 5px 5px 15px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
transition: 0.2s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
|
||||
}
|
||||
|
||||
.link img {
|
||||
width: 70px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Link } from 'tuono'
|
||||
import styles from './PokemonLink.module.css'
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function PokemonLink({
|
||||
pokemon,
|
||||
id,
|
||||
}: {
|
||||
pokemon: Pokemon
|
||||
id: number
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
|
||||
{pokemon.name}
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.back-btn {
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
padding: 7px 15px;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
border: solid #f0f0f0 1px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
|
||||
}
|
||||
|
||||
.pokemon {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 50px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pokemon img {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.spec {
|
||||
display: flex;
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from 'tuono'
|
||||
import styles from './PokemonView.module.css'
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
id: string
|
||||
weight: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export default function PokemonView({
|
||||
pokemon,
|
||||
}: {
|
||||
pokemon?: Pokemon
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Link className={styles['back-btn']} href="/">
|
||||
Back
|
||||
</Link>
|
||||
{pokemon?.name && (
|
||||
<div className={styles.pokemon}>
|
||||
<div>
|
||||
<h1 className={styles.name}>{pokemon.name}</h1>
|
||||
<dl className={styles.spec}>
|
||||
<dt className={styles.label}>Weight:</dt>
|
||||
<dd>{pokemon.weight}lbs</dd>
|
||||
</dl>
|
||||
<dl className={styles.spec}>
|
||||
<dt className={styles.label}>Height:</dt>
|
||||
<dd>{pokemon.height}ft</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<img
|
||||
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemon.id}.png`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface RootRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
|
||||
return <main className="main">{children}</main>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// src/routes/index.rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemons {
|
||||
results: Vec<Pokemon>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
return match fetch.get(ALL_POKEMON).send().await {
|
||||
Ok(res) => {
|
||||
let data = res.json::<Pokemons>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Props::new_with_status(
|
||||
"{}", // Return empty JSON
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// src/routes/index.tsx
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import PokemonLink from '../components/PokemonLink'
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface IndexProps {
|
||||
results: Pokemon[]
|
||||
}
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
}: TuonoProps<IndexProps>): JSX.Element {
|
||||
if (!data?.results) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<a href="https://crates.io/crates/tuono" target="_blank">
|
||||
Crates
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/tuono" target="_blank">
|
||||
Npm
|
||||
</a>
|
||||
</header>
|
||||
<div className="title-wrap">
|
||||
<h1 className="title">
|
||||
TU<span>O</span>NO
|
||||
</h1>
|
||||
<div className="logo">
|
||||
<img src="rust.svg" className="rust" />
|
||||
<img src="react.svg" className="react" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/routes/pokemons/GOAT.rs
|
||||
use tuono_lib::{Request, Response};
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn redirect_to_goat(_: Request<'_>, _: reqwest::Client) -> Response {
|
||||
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// src/routes/pokemons/[pokemon].rs
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
|
||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Pokemon {
|
||||
name: String,
|
||||
id: u16,
|
||||
weight: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
#[tuono_lib::handler]
|
||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||
let pokemon = req.params.get("pokemon").unwrap();
|
||||
|
||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||
Ok(res) => {
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||
}
|
||||
let data = res.json::<Pokemon>().await.unwrap();
|
||||
Response::Props(Props::new(data))
|
||||
}
|
||||
Err(_err) => Response::Props(Props::new_with_status(
|
||||
"{}",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import PokemonView from '../../components/PokemonView'
|
||||
|
||||
interface Pokemon {
|
||||
name: string
|
||||
id: string
|
||||
weight: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export default function PokemonPage({
|
||||
data,
|
||||
}: TuonoProps<Pokemon>): JSX.Element {
|
||||
return <PokemonView pokemon={data} />
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
+15
-10
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "src/index.js",
|
||||
"packageManager": "pnpm@8.12.1",
|
||||
"name": "workspace",
|
||||
"packageManager": "pnpm@9.1.1",
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"dev": "turbo dev --filter tuono",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"format": "turbo format",
|
||||
"test": "turbo test"
|
||||
"format:check": "turbo format:check",
|
||||
"types": "turbo types",
|
||||
"test": "turbo test",
|
||||
"test:watch": "turbo test:watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"type": "module",
|
||||
"author": "",
|
||||
"workspaces": [
|
||||
"tuono",
|
||||
"tuono-lazy-fn-vite-plugin"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tanstack/config": "^0.7.0",
|
||||
@@ -32,5 +34,8 @@
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"turbo": "^2.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
dist/
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
# tuono-lazy-fn-vite-plugin
|
||||
|
||||
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||
|
||||
This package specifically handles the transpiling of the `dynamic` function
|
||||
allowing custom componenents to be lazy loaded but also server side rendered.
|
||||
|
||||
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "tuono-lazy-fn-vite-plugin",
|
||||
"version": "0.3.1",
|
||||
"description": "Plugin for the tuono's lazy fn. 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",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/config": "^0.7.11",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"prettier": "^3.2.4",
|
||||
"vitest": "^1.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const TUONO_DYNAMIC_FN_ID = 'dynamic'
|
||||
export const REACT_LAZY_FN_ID = 'lazy'
|
||||
export const TUONO_MAIN_PACKAGE = 'tuono'
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import * as babel from '@babel/core'
|
||||
import type { PluginItem } from '@babel/core'
|
||||
|
||||
import {
|
||||
TUONO_MAIN_PACKAGE,
|
||||
TUONO_DYNAMIC_FN_ID,
|
||||
REACT_LAZY_FN_ID,
|
||||
} from './constants'
|
||||
|
||||
import * as t from '@babel/types'
|
||||
|
||||
import type {
|
||||
Identifier,
|
||||
ImportDeclaration,
|
||||
CallExpression,
|
||||
ArrowFunctionExpression,
|
||||
StringLiteral,
|
||||
} from '@babel/types'
|
||||
|
||||
/**
|
||||
* This plugin just removes the `dynamic` imported function from any tuono import
|
||||
*/
|
||||
const RemoveTuonoLazyImport: PluginItem = {
|
||||
name: 'remove-tuono-lazy-import-plugin',
|
||||
visitor: {
|
||||
ImportSpecifier: (path) => {
|
||||
if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) {
|
||||
if (
|
||||
(path.parentPath.node as ImportDeclaration).source.value ===
|
||||
TUONO_MAIN_PACKAGE
|
||||
) {
|
||||
path.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin adds: "Import { lazy } from 'react'"
|
||||
* and translate dynamic call into a React.lazy call
|
||||
*/
|
||||
const ImportReactLazy: PluginItem = {
|
||||
name: 'import-react-lazy-plugin',
|
||||
visitor: {
|
||||
// Add the import statement
|
||||
Program: (path: any) => {
|
||||
let isReactImported = false
|
||||
|
||||
path.node.body.forEach((val: any) => {
|
||||
if (val.type === 'ImportDeclaration' && val.source.value === 'react') {
|
||||
isReactImported = true
|
||||
// TODO: Handle also here case of already imported react
|
||||
// Right now works just for the main routes file
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!isReactImported) {
|
||||
const importDeclaration = t.importDeclaration(
|
||||
[
|
||||
t.importSpecifier(
|
||||
t.identifier(REACT_LAZY_FN_ID),
|
||||
t.identifier(REACT_LAZY_FN_ID),
|
||||
),
|
||||
],
|
||||
t.stringLiteral('react'),
|
||||
)
|
||||
path.unshiftContainer('body', importDeclaration)
|
||||
}
|
||||
},
|
||||
// Update lazy function name from `dynamic` to `lazy`
|
||||
CallExpression: (path: any) => {
|
||||
if (path.node.callee?.name === TUONO_DYNAMIC_FN_ID) {
|
||||
path.node.callee.name = REACT_LAZY_FN_ID
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* For the server side we need to statically import the lazy loaded components
|
||||
*/
|
||||
const TurnLazyIntoStaticImport: PluginItem = {
|
||||
name: 'turn-lazy-into-static-import-plugin',
|
||||
visitor: {
|
||||
VariableDeclaration: (path) => {
|
||||
path.node.declarations.forEach((el) => {
|
||||
const init = el.init as CallExpression
|
||||
if ((init.callee as Identifier).name === TUONO_DYNAMIC_FN_ID) {
|
||||
const importName = (el.id as Identifier).name
|
||||
const importPath = (
|
||||
(
|
||||
(init.arguments[0] as ArrowFunctionExpression)
|
||||
.body as CallExpression
|
||||
).arguments[0] as StringLiteral
|
||||
).value
|
||||
|
||||
if (importName && importPath) {
|
||||
const importDeclaration = t.importDeclaration(
|
||||
[t.importDefaultSpecifier(t.identifier(importName))],
|
||||
t.stringLiteral(importPath),
|
||||
)
|
||||
|
||||
path.replaceWith(importDeclaration)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function LazyLoadingPlugin(): Plugin {
|
||||
return {
|
||||
name: 'vite-plugin-tuono-lazy-loading',
|
||||
enforce: 'pre',
|
||||
transform(code, _id, opts): string | undefined | null {
|
||||
if (
|
||||
code.includes(TUONO_DYNAMIC_FN_ID) &&
|
||||
code.includes(TUONO_MAIN_PACKAGE)
|
||||
) {
|
||||
const res = babel.transformSync(code, {
|
||||
plugins: [
|
||||
['@babel/plugin-syntax-jsx', {}],
|
||||
['@babel/plugin-syntax-typescript', { isTSX: true }],
|
||||
[RemoveTuonoLazyImport],
|
||||
[!opts?.ssr ? ImportReactLazy : []],
|
||||
[opts?.ssr ? TurnLazyIntoStaticImport : []],
|
||||
],
|
||||
sourceMaps: true,
|
||||
})
|
||||
|
||||
return res?.code
|
||||
}
|
||||
return code
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { it, expect, describe } from 'vitest'
|
||||
import { LazyLoadingPlugin } from '../src'
|
||||
|
||||
const SOURCE_CODE = `
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
const IndexImport = dynamic(() => import('./../src/routes/index'))
|
||||
const PokemonspokemonImport = dynamic(
|
||||
() => import('./../src/routes/pokemons/[pokemon]'),
|
||||
)
|
||||
`
|
||||
|
||||
const CLIENT_RESULT = `import { lazy } from "react";
|
||||
import { createRoute } from 'tuono';
|
||||
const IndexImport = lazy(() => import('./../src/routes/index'));
|
||||
const PokemonspokemonImport = lazy(() => import('./../src/routes/pokemons/[pokemon]'));`
|
||||
|
||||
const SERVER_RESULT = `import { createRoute } from 'tuono';
|
||||
import IndexImport from "./../src/routes/index";
|
||||
import PokemonspokemonImport from "./../src/routes/pokemons/[pokemon]";`
|
||||
|
||||
describe('Transpile tuono source', () => {
|
||||
it('Into the client bundle', () => {
|
||||
const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id')
|
||||
expect(bundle).toBe(CLIENT_RESULT)
|
||||
})
|
||||
|
||||
it('Into the server bundle', () => {
|
||||
const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id', {
|
||||
ssr: true,
|
||||
})
|
||||
expect(bundle).toBe(SERVER_RESULT)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.0.7",
|
||||
"description": "",
|
||||
"version": "0.3.1",
|
||||
"description": "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 '**/*'",
|
||||
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||
"types": "tsc --noEmit",
|
||||
"test": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
@@ -87,6 +89,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"prettier": "^3.2.4",
|
||||
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||
"vite": "^5.2.11",
|
||||
"zustand": "4.4.7"
|
||||
},
|
||||
@@ -94,11 +97,20 @@
|
||||
"@testing-library/jest-dom": "^6.4.5",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/babel-traverse": "^6.25.10",
|
||||
"@types/babel__traverse": "^7.20.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"keywords": [
|
||||
"react",
|
||||
"typescript",
|
||||
"fullstack",
|
||||
"framework",
|
||||
"rust"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { build, createServer } from 'vite'
|
||||
import { build, createServer, InlineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { ViteFsRouter } from './tuono-vite-plugin'
|
||||
import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||
|
||||
const BASE_CONFIG = {
|
||||
silent: true,
|
||||
const BASE_CONFIG: InlineConfig = {
|
||||
root: '.tuono',
|
||||
logLevel: 'silent',
|
||||
publicDir: '../public',
|
||||
cacheDir: 'cache',
|
||||
envDir: '../',
|
||||
plugins: [react(), ViteFsRouter()],
|
||||
plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()],
|
||||
}
|
||||
|
||||
export function developmentSSRBundle() {
|
||||
;(async () => {
|
||||
console.log('Build SSR')
|
||||
await build({
|
||||
...BASE_CONFIG,
|
||||
build: {
|
||||
@@ -23,6 +23,8 @@ export function developmentSSRBundle() {
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: './.tuono/server-main.tsx',
|
||||
// Silent all logs
|
||||
onLog() {},
|
||||
output: {
|
||||
entryFileNames: 'dev-server.js',
|
||||
format: 'iife',
|
||||
@@ -39,7 +41,6 @@ export function developmentSSRBundle() {
|
||||
|
||||
export function developmentCSRWatch() {
|
||||
;(async () => {
|
||||
console.log('Watch files')
|
||||
const server = await createServer({
|
||||
...BASE_CONFIG,
|
||||
server: {
|
||||
@@ -62,13 +63,13 @@ export function buildProd() {
|
||||
;(async () => {
|
||||
await build({
|
||||
...BASE_CONFIG,
|
||||
manifest: true,
|
||||
build: {
|
||||
manifest: true,
|
||||
emptyOutDir: true,
|
||||
outDir: '../out/client',
|
||||
},
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: './.tuono/client-main.tsx',
|
||||
rollupOptions: {
|
||||
input: './.tuono/client-main.tsx',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -76,7 +77,7 @@ export function buildProd() {
|
||||
...BASE_CONFIG,
|
||||
build: {
|
||||
ssr: true,
|
||||
minify: false,
|
||||
minify: true,
|
||||
outDir: '../out/server',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
removeGroups,
|
||||
removeUnderscores,
|
||||
removeLayoutSegments,
|
||||
removeTrailingUnderscores,
|
||||
} from './utils'
|
||||
|
||||
import type { Config, RouteNode, RouteSubNode } from './types'
|
||||
@@ -134,12 +133,9 @@ export function hasParentRoute(
|
||||
|
||||
export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
if (!isFirst) {
|
||||
console.log('Generating routes...')
|
||||
isFirst = true
|
||||
} else if (skipMessage) {
|
||||
skipMessage = false
|
||||
} else {
|
||||
console.log('Regenerating routes')
|
||||
}
|
||||
|
||||
const checkLatest = (): boolean => {
|
||||
@@ -154,7 +150,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
const taskId = latestTask + 1
|
||||
latestTask = taskId
|
||||
|
||||
const start = Date.now()
|
||||
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
|
||||
await getRouteNodes(config)
|
||||
|
||||
@@ -190,33 +185,10 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
|
||||
)
|
||||
|
||||
const routeCode = fs.readFileSync(node.fullPath, 'utf-8')
|
||||
|
||||
const escapedRoutePath = removeTrailingUnderscores(
|
||||
node.routePath.replaceAll('$', '$$'),
|
||||
)
|
||||
|
||||
let replaced = routeCode
|
||||
|
||||
if (!routeCode) {
|
||||
replaced = [
|
||||
`import { createFileRoute } from '@tanstack/react-router'`,
|
||||
`export const Route = createFileRoute('${escapedRoutePath}')({
|
||||
component: () => <div>Hello ${escapedRoutePath}!</div>
|
||||
})`,
|
||||
].join('\n\n')
|
||||
|
||||
if (replaced !== routeCode) {
|
||||
console.log(`[emoticon] Updating ${node.fullPath}`)
|
||||
await fsp.writeFile(node.fullPath, replaced)
|
||||
}
|
||||
}
|
||||
|
||||
if (node.parent) {
|
||||
node.parent.children = node.parent.children ?? []
|
||||
node.parent.children.push(node)
|
||||
}
|
||||
console.log('pushed')
|
||||
routeNodes.push(node)
|
||||
}
|
||||
|
||||
@@ -258,7 +230,9 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
|
||||
const imports = [
|
||||
...sortedRouteNodes.map((node) => {
|
||||
return `import ${node.variableName}Import from './${replaceBackslash(
|
||||
return `const ${
|
||||
node.variableName
|
||||
}Import = dynamic(() => import('./${replaceBackslash(
|
||||
removeExt(
|
||||
path.relative(
|
||||
path.dirname(config.generatedRouteTree),
|
||||
@@ -266,7 +240,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
),
|
||||
false,
|
||||
),
|
||||
)}'`
|
||||
)}'))`
|
||||
}),
|
||||
].join('\n')
|
||||
|
||||
@@ -279,9 +253,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
const createRouteUpdates = [
|
||||
sortedRouteNodes
|
||||
.map((node) => {
|
||||
const loaderNode = routePiecesByPath[node.routePath]?.loader
|
||||
const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy
|
||||
|
||||
return [
|
||||
`const ${node.variableName}Route = ${node.variableName}.update({
|
||||
${[
|
||||
@@ -293,30 +264,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',')}
|
||||
} as any)`,
|
||||
// Verify if needed
|
||||
loaderNode
|
||||
? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash(
|
||||
removeExt(
|
||||
path.relative(
|
||||
path.dirname(config.generatedRouteTree),
|
||||
path.resolve(config.folderName, loaderNode.filePath),
|
||||
),
|
||||
false,
|
||||
),
|
||||
)}'), 'loader') })`
|
||||
: '',
|
||||
lazyComponentNode
|
||||
? `.lazy(() => import('./${replaceBackslash(
|
||||
removeExt(
|
||||
path.relative(
|
||||
path.dirname(config.generatedRouteTree),
|
||||
path.resolve(config.folderName, lazyComponentNode.filePath),
|
||||
),
|
||||
false,
|
||||
),
|
||||
)}').then((d) => d.Route))`
|
||||
: '',
|
||||
})`,
|
||||
].join('')
|
||||
})
|
||||
.join('\n\n'),
|
||||
@@ -324,7 +272,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
|
||||
const routeImports = [
|
||||
'// This file is auto-generated by Tuono',
|
||||
"import { createRoute } from 'tuono'",
|
||||
"import { createRoute, dynamic } from 'tuono'",
|
||||
[
|
||||
`import RootImport from './${replaceBackslash(
|
||||
path.relative(
|
||||
@@ -371,10 +319,4 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
routeConfigFileContent,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[emoticon] Processed ${routeNodes.length === 1 ? 'route' : 'routes'} in ${
|
||||
Date.now() - start
|
||||
}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export const splitFile = async ({
|
||||
compile,
|
||||
filename,
|
||||
}: SplitFileFnArgs): Promise<CompileOutput> => {
|
||||
console.log('Split file [splitFile fn]', code)
|
||||
return compile({
|
||||
code,
|
||||
filename,
|
||||
@@ -220,7 +219,6 @@ export const splitFile = async ({
|
||||
]),
|
||||
)
|
||||
} else {
|
||||
console.log(splitNode)
|
||||
throw new Error(
|
||||
`Unexpected splitNode type ${splitNode.type}`,
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ export const eliminateUnreferencedIdentifiers = (
|
||||
programPath.scope.crawl()
|
||||
|
||||
programPath.traverse({
|
||||
VariableDeclarator(path) {
|
||||
VariableDeclarator(path: any) {
|
||||
if (path.node.id.type === 'Identifier') {
|
||||
const local = path.get('id') as NodePath<BabelTypes.Identifier>
|
||||
if (shouldBeRemoved(local)) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SPLIT_PREFIX } from './constants'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
const DEBUG = true
|
||||
|
||||
let lock = false
|
||||
|
||||
@@ -20,10 +19,8 @@ export function RouterGenerator(): Plugin {
|
||||
|
||||
try {
|
||||
// TODO: generator function
|
||||
console.log('Generating [generate fn]')
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
@@ -34,7 +31,6 @@ export function RouterGenerator(): Plugin {
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
// TODO: generator function
|
||||
console.log('Generating [handleFile fn]')
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
@@ -57,7 +53,6 @@ export function RouterGenerator(): Plugin {
|
||||
|
||||
export function RouterCodeSplitter(): Plugin {
|
||||
const ROOT: string = process.cwd()
|
||||
console.log('ROOT', ROOT)
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router-code-splitter',
|
||||
@@ -75,36 +70,14 @@ export function RouterCodeSplitter(): Plugin {
|
||||
|
||||
const compile = makeCompile({ root: ROOT })
|
||||
|
||||
if (DEBUG) console.info('Route: ', id)
|
||||
|
||||
if (id.includes(SPLIT_PREFIX)) {
|
||||
console.log('Split')
|
||||
if (DEBUG) console.info('Splitting route: ', id)
|
||||
|
||||
const compiled = await splitFile({
|
||||
code,
|
||||
compile,
|
||||
filename: id,
|
||||
})
|
||||
|
||||
if (DEBUG) {
|
||||
console.info('')
|
||||
console.info('Split Output')
|
||||
console.info('')
|
||||
//console.info(compiled.code)
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
console.info('')
|
||||
}
|
||||
|
||||
return compiled
|
||||
} else {
|
||||
console.log('Non split')
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'vite/modulepreload-polyfill'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '../router'
|
||||
|
||||
export function hydrate(routeTree: any) {
|
||||
type RouteTree = any
|
||||
|
||||
export function hydrate(routeTree: RouteTree): void {
|
||||
// Create a new router instance
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
// Render the app
|
||||
// eslint-disable-next-line
|
||||
const rootElement = document.getElementById('__tuono')!
|
||||
|
||||
ReactDOM.hydrateRoot(
|
||||
hydrateRoot(
|
||||
rootElement,
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
@@ -4,8 +4,16 @@ import {
|
||||
createRouter,
|
||||
Link,
|
||||
RouterProvider,
|
||||
dynamic,
|
||||
} from './router'
|
||||
|
||||
export type { TuonoProps } from './types'
|
||||
|
||||
export { createRoute, createRootRoute, createRouter, Link, RouterProvider }
|
||||
export {
|
||||
createRoute,
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
Link,
|
||||
RouterProvider,
|
||||
dynamic,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useRouterStore } from '../hooks/useRouterStore'
|
||||
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
|
||||
|
||||
@@ -7,8 +8,17 @@ export default function Link(
|
||||
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
props.onClick?.(e)
|
||||
useRouterStore.setState({ location: { pathname: props.href || '' } })
|
||||
window.history.pushState('', '', props.href)
|
||||
useRouterStore.setState({
|
||||
// TODO: Refine store update
|
||||
location: {
|
||||
href: props.href || '',
|
||||
pathname: props.href || '',
|
||||
search: undefined,
|
||||
searchStr: '',
|
||||
hash: '',
|
||||
},
|
||||
})
|
||||
history.pushState(props.href, '', props.href)
|
||||
}
|
||||
return (
|
||||
<a {...props} onClick={handleTransition}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useRouter } from '../hooks/useRouter'
|
||||
import { useRouterStore } from '../hooks/useRouterStore'
|
||||
import type { Route } from '../route'
|
||||
@@ -11,6 +12,14 @@ interface MatchesProps {
|
||||
|
||||
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
|
||||
|
||||
/*
|
||||
* This function is also implemented on server side to match the bundle
|
||||
* file to load at the first rendering.
|
||||
*
|
||||
* File: crates/tuono_lib/src/payload.rs
|
||||
*
|
||||
* Optimizations should occour on both
|
||||
*/
|
||||
export function getRouteByPathname(pathname: string): Route | undefined {
|
||||
const { routesById } = useRouter()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useRouter } from '../hooks/useRouter'
|
||||
import { RouteMatch } from './RouteMatch'
|
||||
import Link from './Link'
|
||||
@@ -9,7 +10,7 @@ export default function NotFound(): JSX.Element {
|
||||
|
||||
// Check if exists a custom 404 error page
|
||||
if (custom404Route) {
|
||||
return <RouteMatch route={custom404Route} />
|
||||
return <RouteMatch route={custom404Route} serverSideProps={{}} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import type { Route } from '../route'
|
||||
import { useServerSideProps } from '../hooks/useServerSideProps'
|
||||
|
||||
@@ -19,12 +20,19 @@ export const RouteMatch = ({
|
||||
const { data, isLoading } = useServerSideProps(route, serverSideProps)
|
||||
|
||||
if (!route.isRoot) {
|
||||
return route.options.getParentRoute().component({
|
||||
children: route.options.component({ data, isLoading }),
|
||||
data,
|
||||
isLoading,
|
||||
})
|
||||
const Root = route.options.getParentRoute()
|
||||
return (
|
||||
<Root.component data={data} isLoading={isLoading}>
|
||||
<React.Suspense>
|
||||
<route.options.component data={data} isLoading={isLoading} />
|
||||
</React.Suspense>
|
||||
</Root.component>
|
||||
)
|
||||
}
|
||||
|
||||
return route.options.component({ data, isLoading })
|
||||
return (
|
||||
<React.Suspense>
|
||||
<route.options.component data={data} isLoading={isLoading} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import type { Router } from '../router'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const routerContext = React.createContext<Router>(null!)
|
||||
|
||||
const TUONO_CONTEXT = '__TUONO_CONTEXT__'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getRouterContext } from './RouterContext'
|
||||
import { Matches } from './Matches'
|
||||
import { useRouterStore } from '../hooks/useRouterStore'
|
||||
import React, { useLayoutEffect, type ReactNode } from 'react'
|
||||
import { useListenBrowserUrlUpdates } from '../hooks/useListenBrowserUrlUpdates'
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { initRouterStore } from '../hooks/useRouterStore'
|
||||
import type { ServerProps } from '../types'
|
||||
|
||||
type Router = any
|
||||
|
||||
@@ -10,6 +12,11 @@ interface RouterContextProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface RouterProviderProps {
|
||||
router: Router
|
||||
serverProps?: ServerProps
|
||||
}
|
||||
|
||||
function RouterContextProvider({
|
||||
router,
|
||||
children,
|
||||
@@ -21,7 +28,6 @@ function RouterContextProvider({
|
||||
...rest,
|
||||
context: {
|
||||
...router.options.context,
|
||||
...rest.context,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -38,46 +44,14 @@ function RouterContextProvider({
|
||||
)
|
||||
}
|
||||
|
||||
interface RouterProviderProps {
|
||||
router: Router
|
||||
serverProps?: ServerProps
|
||||
}
|
||||
|
||||
interface ServerProps {
|
||||
router: Location
|
||||
props: any
|
||||
}
|
||||
|
||||
const initRouterStore = (props?: ServerProps): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
updateLocation({
|
||||
pathname: props?.router.pathname || '',
|
||||
hash: '',
|
||||
href: '',
|
||||
searchStr: '',
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { pathname, hash, href, search } = window.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: new URLSearchParams(search),
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function RouterProvider({
|
||||
router,
|
||||
serverProps,
|
||||
}: RouterProviderProps): JSX.Element {
|
||||
initRouterStore(serverProps)
|
||||
|
||||
useListenBrowserUrlUpdates()
|
||||
|
||||
return (
|
||||
<RouterContextProvider router={router}>
|
||||
<Matches serverSideProps={serverProps?.props} />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* Helper function to lazy load any component.
|
||||
*
|
||||
* The function acts exactly like React.lazy function but also renders the component on the server.
|
||||
* If you want to just load the component client side use directly the react's lazy function.
|
||||
*
|
||||
* It can be wrapped within a React.Suspense component in order to handle its loading state.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const dynamic = (importFn: () => JSX.Element): JSX.Element => {
|
||||
/**
|
||||
*
|
||||
* This function is just a placeholder. The real work is done by the bundler.
|
||||
* The custom babel plugin will create two different bundles for the client and the server.
|
||||
*
|
||||
* The client will import the React's lazy function while the server will statically
|
||||
* import the file.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // User code
|
||||
* import { dynamic } from 'tuono'
|
||||
* const MyComponent = dynamic(() => import('./my-component'))
|
||||
*
|
||||
* // Client side generated code
|
||||
* import { lazy } from 'react'
|
||||
* const MyComponent = lazy(() => import('./my-component'))
|
||||
*
|
||||
* // Server side generated code
|
||||
* import MyComponent from './my-component'
|
||||
*
|
||||
* Check the `lazy-fn-vite-plugin` package for more
|
||||
*/
|
||||
return <></>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useRouterStore } from './useRouterStore'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/*
|
||||
* This hook is meant to handle just browser related location updates
|
||||
* like the back and forward buttons.
|
||||
*/
|
||||
export const useListenBrowserUrlUpdates = (): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
const updateLocationOnPopStateChange = ({ target }: any): void => {
|
||||
const { pathname, hash, href, search } = target.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: new URLSearchParams(search),
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
window.addEventListener('popstate', updateLocationOnPopStateChange)
|
||||
return (): void => {
|
||||
window.removeEventListener('popstate', updateLocationOnPopStateChange)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { useLayoutEffect } from 'react'
|
||||
|
||||
import type { ServerProps } from '../types'
|
||||
|
||||
export interface ParsedLocation {
|
||||
href: string
|
||||
@@ -20,6 +23,30 @@ interface RouterState {
|
||||
updateLocation: (loc: ParsedLocation) => void
|
||||
}
|
||||
|
||||
export const initRouterStore = (props?: ServerProps): void => {
|
||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
updateLocation({
|
||||
pathname: props?.router.pathname || '',
|
||||
hash: '',
|
||||
href: '',
|
||||
searchStr: '',
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { pathname, hash, href, search } = window.location
|
||||
updateLocation({
|
||||
pathname,
|
||||
hash,
|
||||
href,
|
||||
searchStr: search,
|
||||
search: new URLSearchParams(search),
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const useRouterStore = create<RouterState>()((set) => ({
|
||||
isLoading: false,
|
||||
isTransitioning: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { Route } from '../route'
|
||||
import { useRouterStore } from './useRouterStore'
|
||||
import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location'
|
||||
|
||||
const isServer = typeof document === 'undefined'
|
||||
|
||||
@@ -9,19 +10,41 @@ interface UseServerSidePropsReturn {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TUONO_SSR_PROPS__: any
|
||||
}
|
||||
}
|
||||
|
||||
interface TuonoApi {
|
||||
data?: any
|
||||
info: {
|
||||
redirect_destination?: string
|
||||
}
|
||||
}
|
||||
|
||||
const fetchClientSideData = async (): Promise<TuonoApi> => {
|
||||
const res = await fetch(`/__tuono/data${location.pathname}`)
|
||||
const data: TuonoApi = await res.json()
|
||||
return data
|
||||
}
|
||||
|
||||
/*
|
||||
* Use the props provided by the SSR and dehydrate the
|
||||
* props for client side usage.
|
||||
*
|
||||
* In case is a client fetch the remote data API
|
||||
*/
|
||||
export function useServerSideProps(
|
||||
export function useServerSideProps<T>(
|
||||
route: Route,
|
||||
// User defined props
|
||||
serverSideProps: any,
|
||||
serverSideProps: T,
|
||||
): UseServerSidePropsReturn {
|
||||
const isFirstRendering = useRef<boolean>(true)
|
||||
const location = useRouterStore((st) => st.location)
|
||||
const [location, updateLocation] = useRouterStore((st) => [
|
||||
st.location,
|
||||
st.updateLocation,
|
||||
])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(
|
||||
// Force loading if has handler
|
||||
route.options.hasHandler &&
|
||||
@@ -47,8 +70,22 @@ export function useServerSideProps(
|
||||
;(async (): Promise<void> => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/__tuono/data${location.pathname}`)
|
||||
setData(await res.json())
|
||||
const response = await fetchClientSideData()
|
||||
if (response.info.redirect_destination) {
|
||||
const parsedLocation = fromUrlToParsedLocation(
|
||||
response.info.redirect_destination,
|
||||
)
|
||||
|
||||
history.pushState(
|
||||
parsedLocation.pathname,
|
||||
'',
|
||||
parsedLocation.pathname,
|
||||
)
|
||||
|
||||
updateLocation(parsedLocation)
|
||||
return
|
||||
}
|
||||
setData(response.data)
|
||||
} catch (error) {
|
||||
throw Error('Failed loading Server Side Data', { cause: error })
|
||||
} finally {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__TUONO_SSR__PROPS__: any
|
||||
}
|
||||
}
|
||||
|
||||
export { RouterProvider } from './components/RouterProvider'
|
||||
export { default as Link } from './components/Link'
|
||||
export { createRouter } from './router'
|
||||
export { createRoute, createRootRoute, getRouteApi } from './route'
|
||||
export { createRoute, createRootRoute } from './route'
|
||||
export { dynamic } from './dynamic'
|
||||
|
||||
@@ -16,15 +16,15 @@ export const rootRouteId = '__root__'
|
||||
|
||||
export class Route {
|
||||
parentRoute!: any
|
||||
id: number
|
||||
id?: string
|
||||
fullPath!: string
|
||||
path: string
|
||||
path?: string
|
||||
options: any
|
||||
|
||||
children?: Route[]
|
||||
router: RouterType
|
||||
isRoot: boolean
|
||||
originalIndex: number
|
||||
originalIndex?: number
|
||||
component: () => JSX.Element
|
||||
|
||||
constructor(options: RouteOptions) {
|
||||
@@ -40,7 +40,6 @@ export class Route {
|
||||
|
||||
const isRoot = !this.options?.path && !this.options?.id
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
this.parentRoute = this.options?.getParentRoute?.()
|
||||
|
||||
if (isRoot) {
|
||||
@@ -75,11 +74,9 @@ export class Route {
|
||||
const fullPath =
|
||||
id === rootRouteId ? '/' : joinPaths([this.parentRoute.fullPath, path])
|
||||
|
||||
this.path = path as TPath
|
||||
this.id = id as TId
|
||||
// this.customId = customId as TCustomId
|
||||
this.fullPath = fullPath as TFullPath
|
||||
this.to = fullPath as TrimPathRight<TFullPath>
|
||||
this.path = path
|
||||
this.id = id
|
||||
this.fullPath = fullPath
|
||||
}
|
||||
|
||||
addChildren(routes: Route[]): Route {
|
||||
@@ -92,28 +89,8 @@ export class Route {
|
||||
this.isRoot = options.isRoot || !options.getParentRoute
|
||||
return this
|
||||
}
|
||||
|
||||
useRouteContext = () => {}
|
||||
|
||||
useParams = () => {}
|
||||
}
|
||||
|
||||
export function createRootRoute(options?: RouteOptions): Route {
|
||||
export function createRootRoute(options: RouteOptions): Route {
|
||||
return new Route({ ...options, isRoot: true })
|
||||
}
|
||||
|
||||
export function getRouteApi(id: string): RouteApi {
|
||||
return new RouteApi(id)
|
||||
}
|
||||
|
||||
class RouteApi {
|
||||
id: string
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
useParams = () => {}
|
||||
|
||||
useRouteContext = () => {}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ type RouteTree = any
|
||||
interface CreateRouter {
|
||||
routeTree: RouteTree
|
||||
basePath?: string
|
||||
options: RouteOptions
|
||||
options?: RouteOptions
|
||||
}
|
||||
|
||||
interface RouteOptions {
|
||||
@@ -14,9 +14,10 @@ interface RouteOptions {
|
||||
hasHandler?: boolean
|
||||
routeTree?: RouteTree
|
||||
}
|
||||
|
||||
export type RouterType = any
|
||||
|
||||
export function createRouter(options: CreateRouterArgs): Router {
|
||||
export function createRouter(options: CreateRouter): Router {
|
||||
return new Router(options)
|
||||
}
|
||||
|
||||
@@ -49,32 +50,18 @@ export class Router {
|
||||
|
||||
this.#updateBasePath(newOptions.basePath)
|
||||
|
||||
// NOTE: next iteration
|
||||
this.#historyUpdate()
|
||||
|
||||
// NOTE: next iteration
|
||||
this.#storeUpdate()
|
||||
|
||||
if (this.options.routeTree !== this.routeTree) {
|
||||
this.routeTree = this.options.routeTree
|
||||
this.#buildRouteTree()
|
||||
}
|
||||
}
|
||||
|
||||
#historyUpdate = (): void => {
|
||||
// TODO: update history
|
||||
}
|
||||
|
||||
#storeUpdate = (): void => {
|
||||
// TODO: update store
|
||||
}
|
||||
|
||||
#buildRouteTree = (): void => {
|
||||
const recurseRoutes = (childRoutes: Route[]): void => {
|
||||
childRoutes.forEach((route: Route, i: number) => {
|
||||
route.init(i)
|
||||
|
||||
this.routesById[route.id] = route
|
||||
this.routesById[route.id || ''] = route
|
||||
|
||||
if (!route.isRoot && route.options.path) {
|
||||
const trimmedFullPath = trimPathRight(route.fullPath)
|
||||
|
||||
@@ -2,3 +2,8 @@ export interface Segment {
|
||||
type: 'pathname' | 'param' | 'wildcard'
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ServerProps {
|
||||
router: Location
|
||||
props: any
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ParsedLocation } from '../hooks/useRouterStore'
|
||||
|
||||
// TODO: improve the whole react/rust URL parsing logic
|
||||
export function fromUrlToParsedLocation(href: string): ParsedLocation {
|
||||
/*
|
||||
* This function works on both server and client.
|
||||
* For this reason we can't rely on the browser's URL api
|
||||
*/
|
||||
return {
|
||||
href,
|
||||
pathname: href,
|
||||
search: undefined,
|
||||
searchStr: '',
|
||||
hash: '',
|
||||
}
|
||||
}
|
||||
@@ -4,32 +4,53 @@ import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
||||
import { RouterProvider } from '../router'
|
||||
import { createRouter } from '../router'
|
||||
|
||||
export function serverSideRendering(routeTree: any) {
|
||||
type RouteTree = any
|
||||
type Mode = 'Dev' | 'Prod'
|
||||
|
||||
const VITE_DEV_AND_HMR = `<script type="module">
|
||||
import RefreshRuntime from 'http://localhost:3001/@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="http://localhost:3001/@vite/client"></script>
|
||||
<script type="module" src="http://localhost:3001/client-main.tsx"></script>`
|
||||
|
||||
function generateCssLinks(cssBundles: string[], mode: Mode): string {
|
||||
if (mode === 'Dev') return ''
|
||||
return cssBundles.reduce((acc, value) => {
|
||||
return acc + `<link rel="stylesheet" type="text/css" href="/${value}" />\n`
|
||||
}, '')
|
||||
}
|
||||
|
||||
function generateJsScripts(jsBundles: string[], mode: Mode): string {
|
||||
if (mode === 'Dev') return ''
|
||||
return jsBundles.reduce((acc, value) => {
|
||||
return acc + `<script type="module" src="/${value}"></script>\n`
|
||||
}, '')
|
||||
}
|
||||
|
||||
export function serverSideRendering(routeTree: RouteTree) {
|
||||
return function render(payload: string | undefined): string {
|
||||
const props = payload ? JSON.parse(payload) : {}
|
||||
|
||||
const mode = props.mode as Mode
|
||||
const jsBundles = props.jsBundles as string[]
|
||||
const cssBundles = props.cssBundles as string[]
|
||||
const router = createRouter({ routeTree }) // Render the app
|
||||
|
||||
const app = renderToString(
|
||||
<RouterProvider router={router} serverProps={props} />,
|
||||
)
|
||||
|
||||
const developmentScript = `<script type="module">
|
||||
import RefreshRuntime from 'http://localhost:3001/@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="http://localhost:3001/@vite/client"></script>
|
||||
<script type="module" src="http://localhost:3001/client-main.tsx"></script>`
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playground</title>
|
||||
${generateCssLinks(cssBundles, mode)}
|
||||
</head>
|
||||
<body>
|
||||
<div id="__tuono">${app}</div>
|
||||
@@ -40,7 +61,8 @@ export function serverSideRendering(routeTree: any) {
|
||||
}}
|
||||
/>,
|
||||
)}
|
||||
${developmentScript}
|
||||
${generateJsScripts(jsBundles, mode)}
|
||||
${mode === 'Dev' ? VITE_DEV_AND_HMR : ''}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
"include": ["src", "tests", "vite.config.ts"],
|
||||
"exclude": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
+24
-11
@@ -1,15 +1,28 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"lint": {},
|
||||
"format": {},
|
||||
"test": {},
|
||||
"build": {
|
||||
"outputs": ["dist/**"],
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
"tasks": {
|
||||
"lint": {},
|
||||
"format": {},
|
||||
"format:check": {},
|
||||
"types": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test": {},
|
||||
"test:watch": {},
|
||||
"build": {
|
||||
"outputs": [
|
||||
"dist/**"
|
||||
],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user