mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-27 13:52:47 -07:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07351587f3 | |||
| 3027b828a8 | |||
| eef2cd26e0 | |||
| 8190915e3a | |||
| dd157d6ef9 | |||
| 58f4ecb260 | |||
| ab441d1fa7 | |||
| 7538031261 | |||
| 18f66ff79b | |||
| b48f7244b7 | |||
| 22844a37fa | |||
| bc444929ed | |||
| 0813c14e15 | |||
| 5331cc0c8c | |||
| ba89a7e388 | |||
| 90a4d20436 | |||
| d938e82998 | |||
| fc15c01c90 | |||
| ffdf0f91ac | |||
| fa6b2f4c89 | |||
| 245b5c535b | |||
| 2bb0bdb2b0 | |||
| e368cc5e23 | |||
| eb4966e88e | |||
| 64c4c1174c | |||
| 315225184d | |||
| 39fcc33600 | |||
| 1adf380798 | |||
| ac20a83e7e | |||
| b17521063a | |||
| f449c4537e | |||
| 3c413c38bd | |||
| 750e4b6f22 | |||
| e6e455e966 | |||
| 54de22d8c4 | |||
| 40005c4e02 | |||
| 32a76a2560 | |||
| f3cf9b05ae | |||
| c946c43462 | |||
| 2ecfb0d5c0 | |||
| 6b02c40ad4 | |||
| 3334bddeb2 | |||
| 0335c8c1ad | |||
| 0fa83fb1a2 | |||
| b24466fb11 | |||
| c0b4fa8717 | |||
| 16b8125206 | |||
| 8ce1292960 | |||
| dbc205a09c | |||
| 0e5fcd4b57 | |||
| 42b7a22a29 | |||
| 13983a5362 | |||
| 557f412e27 | |||
| f8f21b69b6 | |||
| 7b656ee961 | |||
| c044ab45a7 | |||
| eda328f861 | |||
| f75fce87cf | |||
| 7c0999a5fe | |||
| a7b5e92c46 | |||
| 4d91bc0a72 | |||
| 3b3f601ff1 | |||
| abb12e7b3d | |||
| 66c3d91dfa | |||
| 53ec3ae280 | |||
| f293a1dee8 | |||
| a786f11d28 | |||
| 604581c64a | |||
| ae6ece7415 | |||
| b0a7557d83 | |||
| fde01421dc | |||
| 497532e6fe | |||
| 724ac83429 | |||
| c7ca9ca8b4 | |||
| 7934a7cbab | |||
| 35b1de5d22 | |||
| 2cfad3de12 | |||
| 78a742c4c8 | |||
| 57f0c9fa20 | |||
| 8dde08892c | |||
| da5fac0af2 | |||
| 12aa12c94d | |||
| 6dbdafa888 | |||
| 97082840f8 | |||
| d562aa87a3 | |||
| 2301083edc | |||
| 1c45b29ed1 | |||
| bfd0041c6a | |||
| 9f14689ca0 | |||
| b32b192f70 | |||
| 486a254a1e | |||
| 67de777fd1 | |||
| 8fcebae4a9 | |||
| 77c902a7a5 | |||
| da194524ee | |||
| 81e7af6f5e | |||
| fd2357cedc | |||
| 9db6f02701 | |||
| 9b6f8098b6 | |||
| a322ce2fad | |||
| 73b7a8f056 | |||
| 0e6546d4d0 |
@@ -45,17 +45,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs: [e2e]
|
||||
env:
|
||||
FAILURE: ${{ contains(join(needs.*.result, ','), 'failure') }}
|
||||
CANCELLED: ${{ contains(join(needs.*.result, ','), 'cancelled') }}
|
||||
needs:
|
||||
- e2e
|
||||
steps:
|
||||
- name: Check for failure or cancelled jobs result
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Failure: $FAILURE - Cancelled: $CANCELLED"
|
||||
if [ "$FAILURE" = "false" ] && [ "$CANCELLED" = "false" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Examples CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
example_list:
|
||||
name: Get examples names
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
|
||||
outputs:
|
||||
names: ${{ steps.find_folder.outputs.names }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- id: find_folder
|
||||
working-directory: examples
|
||||
# produces the list of folder inside examples without leading `./` and trailing `/`:
|
||||
# ```text
|
||||
# tuono-tutorial
|
||||
# with-tailwind
|
||||
# tuono-app
|
||||
# with-mdx
|
||||
# ```
|
||||
#
|
||||
# after, using `jq` the output is converted into a JSON array:
|
||||
# ```json
|
||||
# ["tuono-tutorial","with-tailwind","tuono-app","with-mdx"]
|
||||
# ```
|
||||
#
|
||||
# and added to `$GITHUB_OUTPUT` env variable:
|
||||
run: |
|
||||
NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
|
||||
echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check_example:
|
||||
needs:
|
||||
- example_list
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
example_name: ${{ fromJson(needs.example_list.outputs.names) }}
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
# to be added
|
||||
# - 'windows-latest'
|
||||
|
||||
# Only on this repo some examples build including plugins fails
|
||||
# However, if the project is downloaded via `tuono new`` it will work
|
||||
# @see https://github.com/tuono-labs/tuono/pull/605#pullrequestreview-2656262230
|
||||
# needs further investigations
|
||||
exclude:
|
||||
- example_name: with-tailwind
|
||||
|
||||
name: 'Check `${{ matrix.example_name }}` build on ${{ matrix.os }}'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install NodeJS Dependencies
|
||||
uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Setup rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Build Rust
|
||||
run: cargo build
|
||||
|
||||
- name: Build typescript
|
||||
run: pnpm run build
|
||||
|
||||
- name: Check "build"
|
||||
working-directory: ./examples/${{ matrix.example_name }}
|
||||
run: ../../target/debug/tuono build
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm run format
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
ci_ok:
|
||||
name: Examples CI OK
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs:
|
||||
- example_list
|
||||
- check_example
|
||||
steps:
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
@@ -35,17 +35,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs: [format]
|
||||
env:
|
||||
FAILURE: ${{ contains(join(needs.*.result, ','), 'failure') }}
|
||||
CANCELLED: ${{ contains(join(needs.*.result, ','), 'cancelled') }}
|
||||
needs:
|
||||
- format
|
||||
steps:
|
||||
- name: Check for failure or cancelled jobs result
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Failure: $FAILURE - Cancelled: $CANCELLED"
|
||||
if [ "$FAILURE" = "false" ] && [ "$CANCELLED" = "false" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
|
||||
@@ -70,17 +70,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs: [build_and_test, lint_and_fmt]
|
||||
env:
|
||||
FAILURE: ${{ contains(join(needs.*.result, ','), 'failure') }}
|
||||
CANCELLED: ${{ contains(join(needs.*.result, ','), 'cancelled') }}
|
||||
needs:
|
||||
- build_and_test
|
||||
- lint_and_fmt
|
||||
steps:
|
||||
- name: Check for failure or cancelled jobs result
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Failure: $FAILURE - Cancelled: $CANCELLED"
|
||||
if [ "$FAILURE" = "false" ] && [ "$CANCELLED" = "false" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
|
||||
@@ -64,17 +64,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
if: always()
|
||||
needs: [build_and_test, lint_and_fmt]
|
||||
env:
|
||||
FAILURE: ${{ contains(join(needs.*.result, ','), 'failure') }}
|
||||
CANCELLED: ${{ contains(join(needs.*.result, ','), 'cancelled') }}
|
||||
needs:
|
||||
- build_and_test
|
||||
- lint_and_fmt
|
||||
steps:
|
||||
- name: Check for failure or cancelled jobs result
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Failure: $FAILURE - Cancelled: $CANCELLED"
|
||||
if [ "$FAILURE" = "false" ] && [ "$CANCELLED" = "false" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
- name: Exit with error if some jobs are not successful
|
||||
run: exit 1
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) }}
|
||||
|
||||
@@ -2,6 +2,7 @@ pnpm-lock.yaml
|
||||
|
||||
dist
|
||||
.tuono
|
||||
out
|
||||
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
|
||||
+1
-8
@@ -1,9 +1,2 @@
|
||||
* @Valerioageno @marcalexiei
|
||||
|
||||
# Rust
|
||||
/crates/ @Valerioageno
|
||||
/Cargo.toml @Valerioageno
|
||||
|
||||
# Misc
|
||||
/.github/ @marcalexiei
|
||||
* @Valerioageno
|
||||
|
||||
|
||||
+13
-7
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.17.10"
|
||||
edition = "2021"
|
||||
version = "0.19.6"
|
||||
edition = "2024"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
@@ -10,7 +10,7 @@ repository = "https://github.com/tuono-labs/tuono"
|
||||
readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = ["src/*.rs", "Cargo.toml"]
|
||||
include = ["src/**/*.rs", "templates/*", "Cargo.toml"]
|
||||
|
||||
[lib]
|
||||
name = "tuono"
|
||||
@@ -18,27 +18,33 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.4", features = ["derive", "cargo"] }
|
||||
watchexec = "5.0.0"
|
||||
syn = { version = "2.0.100", features = ["full"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
|
||||
miette = "7.2.0"
|
||||
|
||||
colored = "2.1.0"
|
||||
once_cell = "1.19.0"
|
||||
watchexec = "5.0.0"
|
||||
watchexec-signals = "4.0.0"
|
||||
watchexec-events = "4.0.0"
|
||||
watchexec-supervisor = "3.0.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
watchexec-supervisor = "3.0.0"
|
||||
glob = "0.3.1"
|
||||
regex = "1.10.4"
|
||||
reqwest = { version = "0.12.4", features = ["blocking", "json"] }
|
||||
serde_json = "1.0"
|
||||
fs_extra = "1.3.0"
|
||||
http = "1.1.0"
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.17.10"}
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.19.6"}
|
||||
spinners = "4.1.1"
|
||||
console = "0.15.10"
|
||||
convert_case = "0.8.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6.2"
|
||||
tempfile = "3.14.0"
|
||||
assert_cmd = "2.0.16"
|
||||
serial_test = "0.10.0"
|
||||
serial_test = "3.0.0"
|
||||
|
||||
|
||||
+29
-27
@@ -1,13 +1,13 @@
|
||||
use crate::mode::Mode;
|
||||
use glob::glob;
|
||||
use glob::GlobError;
|
||||
use crate::route::Route;
|
||||
use glob::{GlobError, glob};
|
||||
use http::Method;
|
||||
use std::collections::hash_set::HashSet;
|
||||
use std::collections::{hash_map::Entry, HashMap};
|
||||
use std::collections::{HashMap, hash_map::Entry};
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::io::BufReader;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
@@ -16,8 +16,6 @@ use std::process::Stdio;
|
||||
use tracing::error;
|
||||
use tuono_internal::config::Config;
|
||||
|
||||
use crate::route::Route;
|
||||
|
||||
const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"];
|
||||
const IGNORE_FILES: [&str; 1] = ["__layout"];
|
||||
|
||||
@@ -69,7 +67,7 @@ impl App {
|
||||
app
|
||||
}
|
||||
|
||||
fn collect_routes(&mut self) {
|
||||
pub fn collect_routes(&mut self) {
|
||||
glob(
|
||||
self.base_path
|
||||
.join("src/routes/**/*.*")
|
||||
@@ -154,9 +152,9 @@ impl App {
|
||||
if let Err(_e) = rust_listener {
|
||||
eprintln!("Error: Failed to bind to port {}", config.server.port);
|
||||
eprintln!(
|
||||
"Please ensure that port {} is not already in use by another process or application.",
|
||||
config.server.port
|
||||
);
|
||||
"Please ensure that port {} is not already in use by another process or application.",
|
||||
config.server.port
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -168,9 +166,9 @@ impl App {
|
||||
if let Err(_e) = vite_listener {
|
||||
eprintln!("Error: Failed to bind to port {}", vite_port);
|
||||
eprintln!(
|
||||
"Please ensure that port {} is not already in use by another process or application.",
|
||||
vite_port
|
||||
);
|
||||
"Please ensure that port {} is not already in use by another process or application.",
|
||||
vite_port
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -204,7 +202,7 @@ impl App {
|
||||
.expect("Failed to run the rust server")
|
||||
}
|
||||
|
||||
pub fn build_tuono_config(&mut self) -> Result<std::process::Output, std::io::Error> {
|
||||
pub fn build_tuono_config(&mut self) -> io::Result<std::process::Output> {
|
||||
if !Path::new(BUILD_TUONO_CONFIG).exists() {
|
||||
eprintln!("Failed to find the build script. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
@@ -220,7 +218,9 @@ impl App {
|
||||
Ok(config) => self.config = Some(config),
|
||||
Err(error) => {
|
||||
match error.kind() {
|
||||
io::ErrorKind::NotFound => eprintln!("Failed to read config. Please run `npm install` to generate automatically."),
|
||||
io::ErrorKind::NotFound => eprintln!(
|
||||
"Failed to read config. Please run `npm install` to generate automatically."
|
||||
),
|
||||
_ => {
|
||||
error!("Failed to read config with the following error:");
|
||||
error!("{}", error);
|
||||
@@ -454,19 +454,21 @@ mod tests {
|
||||
.into_iter()
|
||||
.for_each(|(path, expected_has_server_handler)| {
|
||||
if expected_has_server_handler {
|
||||
assert!(app
|
||||
.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.is_some())
|
||||
assert!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.is_some()
|
||||
)
|
||||
} else {
|
||||
assert!(app
|
||||
.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.is_none())
|
||||
assert!(
|
||||
app.route_map
|
||||
.get(path)
|
||||
.expect("Failed to get route path")
|
||||
.axum_info
|
||||
.is_none()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+11
-40
@@ -1,15 +1,9 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing::{span, Level};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing::{Level, span};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::build;
|
||||
use crate::commands::{build, dev, new};
|
||||
use crate::mode::Mode;
|
||||
use crate::scaffold_project;
|
||||
use crate::source_builder::{bundle_axum_source, check_tuono_folder, create_client_entry_files};
|
||||
use crate::watch;
|
||||
use crate::source_builder::SourceBuilder;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Actions {
|
||||
@@ -46,29 +40,7 @@ struct Args {
|
||||
action: Actions,
|
||||
}
|
||||
|
||||
fn check_for_tuono_config() {
|
||||
if !PathBuf::from("tuono.config.ts").exists() {
|
||||
eprintln!("Cannot find tuono.config.ts - is this a tuono project?");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tuono_folder(mode: Mode) -> std::io::Result<App> {
|
||||
check_for_tuono_config();
|
||||
check_tuono_folder()?;
|
||||
let app = bundle_axum_source(mode)?;
|
||||
create_client_entry_files()?;
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
pub fn app() -> std::io::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
// Not need for the time since the code is synchronous
|
||||
.without_time()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
match args.action {
|
||||
@@ -77,23 +49,22 @@ pub fn app() -> std::io::Result<()> {
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let mut app = init_tuono_folder(Mode::Dev)?;
|
||||
let mut source_builder = SourceBuilder::new(Mode::Dev)?;
|
||||
|
||||
app.build_tuono_config()
|
||||
.expect("Failed to build tuono.config.ts");
|
||||
source_builder.base_build()?;
|
||||
|
||||
app.check_server_availability(Mode::Dev);
|
||||
source_builder.app.check_server_availability(Mode::Dev);
|
||||
|
||||
watch::watch().unwrap();
|
||||
dev::watch(source_builder).unwrap();
|
||||
}
|
||||
Actions::Build { ssg, no_js_emit } => {
|
||||
let span = span!(Level::TRACE, "BUILD");
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let app = init_tuono_folder(Mode::Prod)?;
|
||||
|
||||
build::build(app, ssg, no_js_emit);
|
||||
let mut source_builder = SourceBuilder::new(Mode::Prod)?;
|
||||
source_builder.base_build()?;
|
||||
build::build(source_builder.app, ssg, no_js_emit);
|
||||
}
|
||||
Actions::New {
|
||||
folder_name,
|
||||
@@ -104,7 +75,7 @@ pub fn app() -> std::io::Result<()> {
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
scaffold_project::create_new_project(folder_name, template, head);
|
||||
new::create_new_project(folder_name, template, head);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use fs_extra::dir::{copy, CopyOptions};
|
||||
use fs_extra::dir::{CopyOptions, copy};
|
||||
use spinners::{Spinner, Spinners};
|
||||
use std::path::PathBuf;
|
||||
use std::thread::sleep;
|
||||
@@ -0,0 +1,201 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::error;
|
||||
use tuono_internal::tuono_println;
|
||||
use watchexec_events::Tag;
|
||||
use watchexec_events::filekind::FileEventKind;
|
||||
|
||||
use crate::process_manager::{ProcessId, ProcessManager};
|
||||
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
|
||||
use crate::source_builder::SourceBuilder;
|
||||
use console::Term;
|
||||
use spinners::{Spinner, Spinners};
|
||||
|
||||
fn is_css_module(file_name: Cow<str>) -> bool {
|
||||
static RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^.*\.module\.(css|scss|sass|less|styl|stylus)$").unwrap());
|
||||
RE.is_match(&file_name)
|
||||
}
|
||||
|
||||
fn ssr_reload_needed(path: &Path) -> bool {
|
||||
let file_name_starts_with_env = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().starts_with(".env"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let file_path = path.to_string_lossy();
|
||||
|
||||
file_name_starts_with_env
|
||||
|| file_path.ends_with("sx")
|
||||
|| file_path.ends_with("mdx")
|
||||
// When a CSS module is modified
|
||||
// also the class names get modified.
|
||||
|| is_css_module(file_path)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::await_holding_lock,
|
||||
reason = "At this point there is no other thread waiting for the lock"
|
||||
)]
|
||||
async unsafe fn start_all_processes(process_manager: Arc<Mutex<ProcessManager>>) {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.start_dev_processes().await
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_existing_env_files() -> Vec<String> {
|
||||
if let Ok(dir) = fs::read_dir("./") {
|
||||
dir.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_name().to_string_lossy().starts_with(".env"))
|
||||
.map(|entry| entry.path().to_string_lossy().into_owned())
|
||||
.collect::<Vec<String>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch(source_builder: SourceBuilder) -> Result<()> {
|
||||
let source_builder = RwLock::new(source_builder);
|
||||
let term = Term::stdout();
|
||||
let mut sp = Spinner::new(Spinners::Dots, "Starting dev server...".into());
|
||||
|
||||
let process_manager = Arc::new(Mutex::new(ProcessManager::new()));
|
||||
|
||||
let env_files = detect_existing_env_files();
|
||||
|
||||
unsafe {
|
||||
// It is safe to call this function because here
|
||||
// only one thread is running and the lock is not
|
||||
// needed by any other thread.
|
||||
start_all_processes(process_manager.clone()).await;
|
||||
}
|
||||
|
||||
// Remove the spinner
|
||||
sp.stop();
|
||||
_ = term.clear_line();
|
||||
|
||||
// Server address log for production
|
||||
// is done on the server process.
|
||||
if let Ok(builder) = source_builder.read() {
|
||||
if let Ok(pm) = process_manager.lock() {
|
||||
pm.log_server_address(builder.app.config.clone().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let process_manager = process_manager.clone();
|
||||
// if Ctrl-C is received, quit
|
||||
if action.signals().any(|sig| sig == Signal::Interrupt) {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.abort_all();
|
||||
}
|
||||
|
||||
action.quit_gracefully(Signal::Quit, Duration::from_secs(30));
|
||||
return action;
|
||||
}
|
||||
|
||||
// The best way to trigger the processes is to use these
|
||||
// flags.
|
||||
// This because the same event can be triggered multiple times
|
||||
// and we don't want to restart the process multiple times for the same
|
||||
// purpose.
|
||||
let mut should_reload_ssr_bundle = false;
|
||||
let mut should_reload_rust_server = false;
|
||||
let mut should_refresh_axum_source = false;
|
||||
|
||||
// Using HashSet to avoid duplicates
|
||||
let mut paths_to_refresh_types: HashSet<PathBuf> = HashSet::new();
|
||||
let mut removed_files_from_types: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
for event in action.events.iter() {
|
||||
for event_type in event.tags.iter() {
|
||||
if let Tag::FileEventKind(kind) = event_type {
|
||||
match kind {
|
||||
FileEventKind::Remove(_) => event.paths().for_each(|(path, _)| {
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
removed_files_from_types.insert(path.to_path_buf());
|
||||
should_refresh_axum_source = true;
|
||||
}
|
||||
}),
|
||||
FileEventKind::Modify(_) => event.paths().for_each(|(path, _)| {
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
should_reload_rust_server = true;
|
||||
paths_to_refresh_types.insert(path.to_path_buf());
|
||||
}
|
||||
if ssr_reload_needed(path) {
|
||||
should_reload_ssr_bundle = true;
|
||||
}
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !paths_to_refresh_types.is_empty() {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
for path in paths_to_refresh_types {
|
||||
// There is no need to check here if the `Type` trait is
|
||||
// derived since it will be checked later by the TypeJar struct.
|
||||
builder.refresh_typescript_file(path)
|
||||
}
|
||||
if builder.generate_typescript_file().is_err() {
|
||||
error!("Failed to generate typescript file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if !removed_files_from_types.is_empty() {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
for path in removed_files_from_types {
|
||||
builder.remove_typescript_file(path);
|
||||
}
|
||||
if builder.generate_typescript_file().is_err() {
|
||||
error!("Failed to generate typescript file");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload_rust_server || should_refresh_axum_source {
|
||||
tuono_println!("Reloading...");
|
||||
if should_refresh_axum_source {
|
||||
if let Ok(mut builder) = source_builder.write() {
|
||||
builder.app.collect_routes();
|
||||
_ = builder.refresh_axum_source();
|
||||
}
|
||||
}
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.restart_process(ProcessId::RunRustDevServer);
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload_ssr_bundle {
|
||||
if let Ok(mut pm) = process_manager.lock() {
|
||||
pm.restart_process(ProcessId::BuildReactSSRSrc);
|
||||
}
|
||||
}
|
||||
|
||||
action
|
||||
})?;
|
||||
|
||||
// watch the current directory and all types of .env file
|
||||
let mut paths_to_watch = vec!["./src".to_string()];
|
||||
paths_to_watch.extend(env_files);
|
||||
|
||||
wx.config.pathset(paths_to_watch);
|
||||
|
||||
let _ = wx.main().await.into_diagnostic()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod dev;
|
||||
pub mod new;
|
||||
@@ -3,9 +3,11 @@ use reqwest::blocking;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs::{self, create_dir, File, OpenOptions};
|
||||
use std::fs::{self, File, OpenOptions, create_dir};
|
||||
use std::io::{self, prelude::*};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::trace;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
enum GithubFileType {
|
||||
@@ -100,14 +102,18 @@ pub fn create_new_project(
|
||||
|
||||
if new_project_files.is_empty() {
|
||||
eprintln!("Error: Template '{template}' not found");
|
||||
println!("Hint: you can view the available templates at https://github.com/tuono-labs/tuono/tree/main/examples");
|
||||
println!(
|
||||
"Hint: you can view the available templates at https://github.com/tuono-labs/tuono/tree/main/examples"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if folder != "." {
|
||||
if Path::new(&folder).exists() {
|
||||
eprintln!("Error: Directory '{folder}' already exists");
|
||||
println!("Hint: you can scaffold a tuono project within an existing folder with 'cd {folder} && tuono new .'");
|
||||
println!(
|
||||
"Hint: you can scaffold a tuono project within an existing folder with 'cd {folder} && tuono new .'"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
create_dir(&folder).unwrap();
|
||||
@@ -152,6 +158,9 @@ pub fn create_new_project(
|
||||
|
||||
update_package_json_version(&folder_path).expect("Failed to update package.json version");
|
||||
update_cargo_toml_version(&folder_path).expect("Failed to update Cargo.toml version");
|
||||
|
||||
init_new_git_repo(&folder_path);
|
||||
|
||||
outro(folder);
|
||||
}
|
||||
|
||||
@@ -261,6 +270,16 @@ fn update_cargo_toml_version(folder_path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_new_git_repo(folder_path: &Path) {
|
||||
if let Ok(output) = Command::new("git").arg("init").arg(folder_path).output() {
|
||||
if !output.status.success() {
|
||||
trace!("Failed to initialise a new git repo")
|
||||
}
|
||||
} else {
|
||||
trace!("Failed to initialise a new git repo")
|
||||
}
|
||||
}
|
||||
|
||||
fn outro(folder_name: String) {
|
||||
println!("Success! 🎉");
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
//! You can find the full documentation at [tuono.dev](https://tuono.dev/)
|
||||
|
||||
mod app;
|
||||
mod build;
|
||||
pub mod cli;
|
||||
mod commands;
|
||||
mod mode;
|
||||
mod process_manager;
|
||||
mod route;
|
||||
mod scaffold_project;
|
||||
mod source_builder;
|
||||
mod watch;
|
||||
mod symbols;
|
||||
mod typescript;
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
use tracing::error;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tuono::cli::app;
|
||||
|
||||
fn main() {
|
||||
app().expect("Failed to start the CLI")
|
||||
tracing_subscriber::fmt()
|
||||
// Time not needed since the execution is synchronous
|
||||
.without_time()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
if let Err(e) = app() {
|
||||
// Generic error.
|
||||
// Recoverable errors should be managed locally
|
||||
error!("Failed to run the tuono CLI: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use clap::crate_version;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
use tracing::trace;
|
||||
use tuono_internal::config::Config;
|
||||
use tuono_internal::tuono_println;
|
||||
use watchexec_supervisor::command::{Command, Program};
|
||||
use watchexec_supervisor::job::{Job, start_job};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_WATCH_BIN_SRC: &str = "node_modules\\.bin\\tuono-dev-watch.cmd";
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_SSR_BIN_SRC: &str = "node_modules\\.bin\\tuono-dev-ssr.cmd";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_WATCH_BIN_SRC: &str = "node_modules/.bin/tuono-dev-watch";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_SSR_BIN_SRC: &str = "node_modules/.bin/tuono-dev-ssr";
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||
pub enum ProcessId {
|
||||
WatchReactSrc,
|
||||
RunRustDevServer,
|
||||
BuildRustSrc,
|
||||
BuildReactSSRSrc,
|
||||
}
|
||||
|
||||
// This struct manages all the processes spawned by the dev server
|
||||
// That are not the dev server itself
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessManager {
|
||||
processes: HashMap<ProcessId, (Job, JoinHandle<()>)>,
|
||||
}
|
||||
|
||||
impl ProcessManager {
|
||||
pub fn new() -> Self {
|
||||
trace!("Creating process manager");
|
||||
if !Path::new(DEV_SSR_BIN_SRC).exists() {
|
||||
eprintln!("Failed to find script to run dev watch. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let mut processes = HashMap::new();
|
||||
processes.insert(
|
||||
ProcessId::WatchReactSrc,
|
||||
start_supervisor_job(DEV_WATCH_BIN_SRC, Vec::new()),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
ProcessId::RunRustDevServer,
|
||||
start_supervisor_job("cargo", vec!["run", "-q"]),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
ProcessId::BuildRustSrc,
|
||||
start_supervisor_job("cargo", vec!["build", "-q"]),
|
||||
);
|
||||
|
||||
processes.insert(
|
||||
ProcessId::BuildReactSSRSrc,
|
||||
start_supervisor_job(DEV_SSR_BIN_SRC, Vec::new()),
|
||||
);
|
||||
|
||||
Self { processes }
|
||||
}
|
||||
|
||||
pub fn start_process(&mut self, id: ProcessId) {
|
||||
trace!("start process {:?}", id);
|
||||
if let Some((job, _)) = self.processes.get(&id) {
|
||||
job.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Start all the processes needed for the dev server
|
||||
pub async fn start_dev_processes(&mut self) {
|
||||
trace!("Starting dev processes");
|
||||
self.start_process(ProcessId::WatchReactSrc);
|
||||
self.start_process(ProcessId::BuildRustSrc);
|
||||
self.start_process(ProcessId::BuildReactSSRSrc);
|
||||
|
||||
self.wait_for_process(ProcessId::BuildRustSrc).await;
|
||||
self.wait_for_process(ProcessId::BuildReactSSRSrc).await;
|
||||
|
||||
// This needs to start after having built the rust and react sources
|
||||
self.start_process(ProcessId::RunRustDevServer);
|
||||
}
|
||||
|
||||
pub fn log_server_address(&self, config: Config) {
|
||||
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
||||
// Format the server address as a valid URL so that it becomes clickable in the CLI
|
||||
// @see https://github.com/tuono-labs/tuono/issues/460
|
||||
let server_base_url = format!("http://{}", server_address);
|
||||
|
||||
println!();
|
||||
tuono_println!("⚡ Tuono v{}", crate_version!());
|
||||
|
||||
tuono_println!("Development server at: {}\n", server_base_url.blue().bold());
|
||||
if let Some(origin) = config.server.origin {
|
||||
tuono_println!("Origin: {}\n", origin.blue().bold());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restart_process(&mut self, id: ProcessId) {
|
||||
trace!("Restarting process {:?}", id);
|
||||
if let Some((job, _)) = self.processes.get(&id) {
|
||||
job.stop();
|
||||
job.start();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_process(&mut self, id: ProcessId) {
|
||||
trace!("Waiting for process {:?}", id);
|
||||
if let Some((job, _)) = self.processes.get(&id) {
|
||||
job.to_wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abort_all(&mut self) {
|
||||
trace!("Aborting all processes");
|
||||
for (_, (job, handle)) in self.processes.iter() {
|
||||
job.stop();
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_supervisor_job(prog: &str, args: Vec<&str>) -> (Job, JoinHandle<()>) {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: prog.into(),
|
||||
args: args.into_iter().map(|arg| arg.to_string()).collect(),
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use fs_extra::dir::create_all;
|
||||
use http::Method;
|
||||
use regex::Regex;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::Url;
|
||||
use reqwest::blocking::Client;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
@@ -28,7 +28,11 @@ impl AxumInfo {
|
||||
let mut module = route.path.chars();
|
||||
module.next();
|
||||
|
||||
let axum_route = route.path.replace("/index", "");
|
||||
let axum_route = if route.path.ends_with("/index") {
|
||||
route.path.replace("/index", "")
|
||||
} else {
|
||||
route.path.clone()
|
||||
};
|
||||
|
||||
let module_import = module
|
||||
.as_str()
|
||||
@@ -176,7 +180,7 @@ impl Route {
|
||||
return Err(format!(
|
||||
"Failed to get the parent directory {:?}",
|
||||
file_path
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,7 +216,7 @@ impl Route {
|
||||
return Err(format!(
|
||||
"Failed to get the parent directory {:?}",
|
||||
data_file_path
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,7 +255,7 @@ impl Route {
|
||||
return Err(format!(
|
||||
"Failed to create the JSON file: {:?}",
|
||||
data_file_path
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,6 +306,18 @@ mod tests {
|
||||
.for_each(|route| assert_eq!(has_dynamic_path(route.0), route.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_allow_index_in_route_name() {
|
||||
let index_route = AxumInfo::new(&Route::new("/index".to_string()));
|
||||
let index_page_route = AxumInfo::new(&Route::new("/index-page".to_string()));
|
||||
|
||||
assert_eq!(index_route.axum_route, "/");
|
||||
assert_eq!(index_route.module_import, "index");
|
||||
|
||||
assert_eq!(index_page_route.axum_route, "/index-page");
|
||||
assert_eq!(index_page_route.module_import, "index_hyphen_page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_correctly_create_the_axum_infos() {
|
||||
let info = AxumInfo::new(&Route::new("/index".to_string()));
|
||||
|
||||
+262
-176
@@ -1,208 +1,253 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::crate_version;
|
||||
use tracing::error;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::mode::Mode;
|
||||
use crate::route::AxumInfo;
|
||||
use crate::route::Route;
|
||||
use crate::typescript::TypesJar;
|
||||
|
||||
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'
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FALLBACK_HTML: &str = include_str!("../templates/fallback.html");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const SERVER_ENTRY_DATA: &str = include_str!("../templates/server.ts");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const CLIENT_ENTRY_DATA: &str = include_str!("../templates/client.ts");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const AXUM_ENTRY_POINT: &str = include_str!("../templates/server.rs");
|
||||
|
||||
export const renderFn = serverSideRendering(routeTree)
|
||||
";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const MAIN_FILE_PATH: &str = "./.tuono/main.rs";
|
||||
|
||||
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, tuono_internal_init_v8_platform};
|
||||
// AXUM_GET_ROUTE_HANDLER
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
//MAIN_FILE_IMPORT//
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tuono_internal_init_v8_platform();
|
||||
println!("\n ⚡ Tuono v/*VERSION*/");
|
||||
|
||||
//MAIN_FILE_DEFINITION//
|
||||
|
||||
let router = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
//MAIN_FILE_USAGE//;
|
||||
|
||||
Server::init(router, MODE).await.start().await
|
||||
}
|
||||
"##;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FALLBACK_HTML_PATH: &str = "./.tuono/index.html";
|
||||
|
||||
const ROUTE_FOLDER: &str = "src/routes";
|
||||
const DEV_FOLDER: &str = ".tuono";
|
||||
|
||||
fn create_main_file(base_path: &Path, bundled_file: &String) {
|
||||
let mut data_file =
|
||||
fs::File::create(base_path.join(".tuono/main.rs")).expect("creation failed");
|
||||
#[cfg(target_os = "windows")]
|
||||
const FALLBACK_HTML: &str = include_str!("..\\templates\\fallback.html");
|
||||
#[cfg(target_os = "windows")]
|
||||
const SERVER_ENTRY_DATA: &str = include_str!("..\\templates\\server.ts");
|
||||
#[cfg(target_os = "windows")]
|
||||
const CLIENT_ENTRY_DATA: &str = include_str!("..\\templates\\client.ts");
|
||||
#[cfg(target_os = "windows")]
|
||||
const AXUM_ENTRY_POINT: &str = include_str!("..\\templates\\server.rs");
|
||||
|
||||
data_file
|
||||
.write_all(bundled_file.as_bytes())
|
||||
.expect("write failed");
|
||||
#[cfg(target_os = "windows")]
|
||||
const MAIN_FILE_PATH: &str = ".\\.tuono\\main.rs";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const FALLBACK_HTML_PATH: &str = ".\\.tuono\\index.html";
|
||||
|
||||
// Use this function to instruct the users on how to
|
||||
// fix their setup to make tuono work
|
||||
fn recoverable_error(message: &str) -> ! {
|
||||
error!("{}", message);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn create_routes_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
// Struct to build the source code
|
||||
// on both "dev" and "build" commands
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SourceBuilder {
|
||||
pub app: App,
|
||||
mode: Mode,
|
||||
base_path: PathBuf,
|
||||
types_jar: TypesJar,
|
||||
}
|
||||
|
||||
for (_, route) in routes.iter() {
|
||||
let Route { axum_info, .. } = &route;
|
||||
impl SourceBuilder {
|
||||
pub fn new(mode: Mode) -> io::Result<Self> {
|
||||
if !PathBuf::from("tuono.config.ts").exists() {
|
||||
recoverable_error("Cannot find tuono.config.ts - is this a tuono project?");
|
||||
}
|
||||
|
||||
if axum_info.is_some() {
|
||||
let AxumInfo {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = axum_info.as_ref().unwrap();
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
fs::create_dir(dev_folder)?;
|
||||
}
|
||||
|
||||
if !route.is_api() {
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", get({module_import}::tuono_internal_route))"#
|
||||
));
|
||||
let app = App::new();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}", get({module_import}::tuono_internal_api))"#
|
||||
));
|
||||
} else {
|
||||
for method in route.api_data.as_ref().unwrap().methods.clone() {
|
||||
let method = method.to_string().to_lowercase();
|
||||
let base_path = std::env::current_dir()?;
|
||||
|
||||
Ok(Self {
|
||||
app,
|
||||
mode,
|
||||
types_jar: TypesJar::from(&base_path),
|
||||
base_path,
|
||||
})
|
||||
}
|
||||
|
||||
// Build the source code needed for both build and dev
|
||||
pub fn base_build(&mut self) -> io::Result<()> {
|
||||
let mode = self.mode.clone();
|
||||
|
||||
self.refresh_axum_source()?;
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?;
|
||||
self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?;
|
||||
|
||||
self.types_jar.generate_typescript_file(&self.base_path)?;
|
||||
|
||||
if mode == Mode::Dev {
|
||||
self.app.build_tuono_config()?;
|
||||
let fallback_html = self.build_html_fallback();
|
||||
self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_axum_source(&self) -> String {
|
||||
let Self { app, mode, .. } = &self;
|
||||
|
||||
let src = AXUM_ENTRY_POINT
|
||||
.replace("\r", "")
|
||||
.replace("// ROUTE_BUILDER\n", &self.create_routes_declaration())
|
||||
.replace("// MODULE_IMPORTS\n", &self.create_modules_declaration())
|
||||
.replace("/*VERSION*/", crate_version!())
|
||||
.replace("/*MODE*/", mode.as_str())
|
||||
.replace(
|
||||
"//MAIN_FILE_IMPORT//",
|
||||
if app.has_app_state {
|
||||
r#"#[path="../src/app.rs"]
|
||||
mod tuono_main_state;
|
||||
"#
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"//MAIN_FILE_DEFINITION//",
|
||||
if app.has_app_state {
|
||||
"let user_custom_state = tuono_main_state::main();"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"//MAIN_FILE_USAGE//",
|
||||
if app.has_app_state {
|
||||
".with_state(user_custom_state)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
|
||||
let mut import_http_handler = String::new();
|
||||
|
||||
let used_http_methods = app.get_used_http_methods();
|
||||
|
||||
for method in used_http_methods.into_iter() {
|
||||
let method = method.to_string().to_lowercase();
|
||||
import_http_handler.push_str(&format!("use tuono_lib::axum::routing::{method};\n"))
|
||||
}
|
||||
|
||||
src.replace("// AXUM_GET_ROUTE_HANDLER", &import_http_handler)
|
||||
}
|
||||
|
||||
pub fn refresh_axum_source(&self) -> io::Result<()> {
|
||||
let axum_source = self.generate_axum_source();
|
||||
|
||||
self.create_file(PathBuf::from(MAIN_FILE_PATH), &axum_source)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_file(&self, path: PathBuf, content: &str) -> io::Result<()> {
|
||||
let mut data_file = fs::File::create(self.base_path.join(path))?;
|
||||
|
||||
data_file.write_all(content.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn refresh_typescript_file(&mut self, path: PathBuf) {
|
||||
self.types_jar.refresh_file(path);
|
||||
}
|
||||
|
||||
pub fn remove_typescript_file(&mut self, path: PathBuf) {
|
||||
self.types_jar.remove_file(path);
|
||||
}
|
||||
|
||||
pub fn generate_typescript_file(&mut self) -> io::Result<()> {
|
||||
self.types_jar.generate_typescript_file(&self.base_path)
|
||||
}
|
||||
|
||||
fn create_routes_declaration(&self) -> String {
|
||||
let routes = &self.app.route_map;
|
||||
let mut route_declarations = String::from("// ROUTE_BUILDER\n");
|
||||
|
||||
for (_, route) in routes.iter() {
|
||||
let Route { axum_info, .. } = &route;
|
||||
|
||||
if axum_info.is_some() {
|
||||
let AxumInfo {
|
||||
axum_route,
|
||||
module_import,
|
||||
} = axum_info.as_ref().unwrap();
|
||||
|
||||
if !route.is_api() {
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", {method}({module_import}::{method}_tuono_internal_api))"#
|
||||
r#".route("{axum_route}", get({module_import}::tuono_internal_route))"#
|
||||
));
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("/__tuono/data{axum_route}", get({module_import}::tuono_internal_api))"#
|
||||
));
|
||||
} else {
|
||||
for method in route.api_data.as_ref().unwrap().methods.clone() {
|
||||
let method = method.to_string().to_lowercase();
|
||||
route_declarations.push_str(&format!(
|
||||
r#".route("{axum_route}", {method}({module_import}::{method}_tuono_internal_api))"#
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
fn create_modules_declaration(&self) -> String {
|
||||
let routes = &self.app.route_map;
|
||||
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||
|
||||
fn create_modules_declaration(routes: &HashMap<String, Route>) -> String {
|
||||
let mut route_declarations = String::from("// MODULE_IMPORTS\n");
|
||||
for (path, route) in routes.iter() {
|
||||
if route.axum_info.is_some() {
|
||||
let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap();
|
||||
|
||||
for (path, route) in routes.iter() {
|
||||
if route.axum_info.is_some() {
|
||||
let AxumInfo { module_import, .. } = route.axum_info.as_ref().unwrap();
|
||||
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROUTE_FOLDER}{path}.rs"]
|
||||
route_declarations.push_str(&format!(
|
||||
r#"#[path="../{ROUTE_FOLDER}{path}.rs"]
|
||||
mod {module_import};
|
||||
"#
|
||||
))
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
fn build_html_fallback(&self) -> String {
|
||||
if let Some(config) = &self.app.config.as_ref() {
|
||||
if let Some(origin) = &config.server.origin {
|
||||
FALLBACK_HTML.replace("[BASE_URL]", origin)
|
||||
} else {
|
||||
let url = format!("http://{}:{}", config.server.host, config.server.port);
|
||||
FALLBACK_HTML.replace("[BASE_URL]", url.as_str())
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
route_declarations
|
||||
}
|
||||
|
||||
pub fn bundle_axum_source(mode: Mode) -> io::Result<App> {
|
||||
let base_path = std::env::current_dir().unwrap();
|
||||
|
||||
let app = App::new();
|
||||
let bundled_file = generate_axum_source(&app, mode);
|
||||
create_main_file(&base_path, &bundled_file);
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
fn generate_axum_source(app: &App, mode: Mode) -> String {
|
||||
let src = AXUM_ENTRY_POINT
|
||||
.replace(
|
||||
"// ROUTE_BUILDER\n",
|
||||
&create_routes_declaration(&app.route_map),
|
||||
)
|
||||
.replace(
|
||||
"// MODULE_IMPORTS\n",
|
||||
&create_modules_declaration(&app.route_map),
|
||||
)
|
||||
.replace("/*VERSION*/", crate_version!())
|
||||
.replace("/*MODE*/", mode.as_str())
|
||||
.replace(
|
||||
"//MAIN_FILE_IMPORT//",
|
||||
if app.has_app_state {
|
||||
r#"#[path="../src/app.rs"]
|
||||
mod tuono_main_state;
|
||||
"#
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"//MAIN_FILE_DEFINITION//",
|
||||
if app.has_app_state {
|
||||
"let user_custom_state = tuono_main_state::main();"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
"//MAIN_FILE_USAGE//",
|
||||
if app.has_app_state {
|
||||
".with_state(user_custom_state)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
|
||||
let mut import_http_handler = String::new();
|
||||
|
||||
let used_http_methods = app.get_used_http_methods();
|
||||
|
||||
for method in used_http_methods.into_iter() {
|
||||
let method = method.to_string().to_lowercase();
|
||||
import_http_handler.push_str(&format!("use tuono_lib::axum::routing::{method};\n"))
|
||||
}
|
||||
|
||||
src.replace("// AXUM_GET_ROUTE_HANDLER", &import_http_handler)
|
||||
}
|
||||
|
||||
pub fn check_tuono_folder() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
if !&dev_folder.is_dir() {
|
||||
fs::create_dir(dev_folder)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_client_entry_files() -> io::Result<()> {
|
||||
let dev_folder = Path::new(DEV_FOLDER);
|
||||
|
||||
let mut server_entry = fs::File::create(dev_folder.join("server-main.tsx"))?;
|
||||
let mut client_entry = fs::File::create(dev_folder.join("client-main.tsx"))?;
|
||||
|
||||
server_entry.write_all(SERVER_ENTRY_DATA.as_bytes())?;
|
||||
client_entry.write_all(CLIENT_ENTRY_DATA.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -212,36 +257,77 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn should_set_the_correct_mode() {
|
||||
let source_builder = App::new();
|
||||
let dev_bundle = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
let prod_bundle = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Prod,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
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_not_load_the_axum_get_function() {
|
||||
let source_builder = App::new();
|
||||
let dev_bundle = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
}
|
||||
.generate_axum_source();
|
||||
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_load_the_axum_get_function() {
|
||||
let mut source_builder = App::new();
|
||||
let mut source_builder = SourceBuilder {
|
||||
app: App::new(),
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
};
|
||||
|
||||
let mut route = Route::new(String::from("index.tsx"));
|
||||
route.update_axum_info();
|
||||
|
||||
source_builder
|
||||
.app
|
||||
.route_map
|
||||
.insert(String::from("index.rs"), route);
|
||||
|
||||
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||
let dev_bundle = source_builder.generate_axum_source();
|
||||
|
||||
assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_fallback_html_with_default_config() {
|
||||
let mut app = App::new();
|
||||
app.config = Some(Default::default());
|
||||
|
||||
let source_builder = SourceBuilder {
|
||||
app,
|
||||
mode: Mode::Dev,
|
||||
base_path: PathBuf::new(),
|
||||
types_jar: TypesJar::default(),
|
||||
};
|
||||
|
||||
let fallback_html = source_builder.build_html_fallback();
|
||||
|
||||
assert!(fallback_html.contains("http://localhost:3000/vite-server/@react-refresh"));
|
||||
assert!(fallback_html.contains("http://localhost:3000/vite-server/@vite/client"));
|
||||
assert!(fallback_html.contains("http://localhost:3000/vite-server/client-main.tsx"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use syn::Ident;
|
||||
|
||||
pub struct Symbol(&'static str);
|
||||
|
||||
// The trait name that allow automatic struct/enum/type conversion
|
||||
// to typescript
|
||||
pub const TYPE_TRAIT: Symbol = Symbol("Type");
|
||||
|
||||
impl PartialEq<Symbol> for Ident {
|
||||
fn eq(&self, word: &Symbol) -> bool {
|
||||
self == word.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Symbol> for &Ident {
|
||||
fn eq(&self, word: &Symbol) -> bool {
|
||||
*self == word.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Symbol {
|
||||
type Target = &'static str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::utils::has_derive_type;
|
||||
use crate::typescript::parser::{parse_enum, parse_struct};
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use tracing::trace;
|
||||
|
||||
/// Represents all the valid typescript types found in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileTypes {
|
||||
/// Rust file when the type was found
|
||||
pub file_path: PathBuf,
|
||||
/// All the types found in the file
|
||||
/// ready to be printed in the typescript file
|
||||
pub types_as_string: String,
|
||||
/// The types found in the file.
|
||||
/// Used to check that the types are not duplicated across files.
|
||||
pub types: Vec<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<(PathBuf, String)> for FileTypes {
|
||||
type Error = Box<dyn Error>;
|
||||
|
||||
fn try_from((file_path, file_str): (PathBuf, String)) -> Result<Self, Self::Error> {
|
||||
trace!("Parsing file: {:?}", &file_path);
|
||||
let file = syn::parse_file(&file_str)?;
|
||||
|
||||
let mut types_as_string = String::new();
|
||||
let mut types = Vec::new();
|
||||
|
||||
for item in file.items {
|
||||
match item {
|
||||
syn::Item::Struct(element) => {
|
||||
if !has_derive_type(&element.attrs) {
|
||||
continue;
|
||||
}
|
||||
trace!("Found struct in file: {:?}", &file_path);
|
||||
let (struct_name, typescript_definition) = parse_struct(&element);
|
||||
types_as_string.push_str(&typescript_definition);
|
||||
types.push(struct_name);
|
||||
}
|
||||
syn::Item::Enum(element) => {
|
||||
if !has_derive_type(&element.attrs) {
|
||||
continue;
|
||||
}
|
||||
trace!("Found enum in file: {:?}", &file_path);
|
||||
|
||||
let (enum_name, typescript_definition) = parse_enum(&element);
|
||||
types_as_string.push_str(&typescript_definition);
|
||||
types.push(enum_name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if types_as_string.is_empty() {
|
||||
return Err("No types found in the file".into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
file_path,
|
||||
types_as_string,
|
||||
types,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_creates_type_from_pathbuf_and_string() {
|
||||
let file_path = PathBuf::from("src/types.rs");
|
||||
let file_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
field1: String,
|
||||
field2: i32,
|
||||
}
|
||||
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
"#
|
||||
.to_string();
|
||||
|
||||
let ttype = FileTypes::try_from((file_path.clone(), file_str)).unwrap();
|
||||
|
||||
assert_eq!(ttype.file_path, file_path);
|
||||
assert_eq!(
|
||||
ttype.types_as_string,
|
||||
"export interface MyStruct {\n field1: string;\n field2: number;\n}\nexport type MyEnum = \"Variant1\" | \"Variant2\";\n"
|
||||
);
|
||||
assert_eq!(ttype.types, vec!["MyStruct", "MyEnum"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod file_types;
|
||||
pub mod parser;
|
||||
|
||||
mod types_jar;
|
||||
pub mod utils;
|
||||
|
||||
pub use file_types::*;
|
||||
pub use types_jar::*;
|
||||
@@ -0,0 +1,6 @@
|
||||
mod parse_enum;
|
||||
mod parse_struct;
|
||||
pub mod utils;
|
||||
|
||||
pub use parse_enum::*;
|
||||
pub use parse_struct::*;
|
||||
@@ -0,0 +1,240 @@
|
||||
use crate::typescript::parser::utils::{
|
||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
||||
rust_to_typescript_type, should_skip_element,
|
||||
};
|
||||
|
||||
/// Parse a rust enum and returns a tuple of the enum name and the
|
||||
/// enum compiled to a typescript type
|
||||
pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) {
|
||||
let enum_name = element.ident.to_string();
|
||||
|
||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
||||
let mut enum_variants: Vec<String> = Vec::new();
|
||||
|
||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
||||
|
||||
for variant in &element.variants {
|
||||
if should_skip_element(&variant.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parsed_variant = rename_option.transform(variant.ident.to_string());
|
||||
|
||||
match &variant.fields {
|
||||
syn::Fields::Named(field) => {
|
||||
parsed_variant = format!("{{\"{}\": {{ ", parsed_variant);
|
||||
let mut variant_fields: Vec<String> = Vec::new();
|
||||
|
||||
for field in &field.named {
|
||||
if should_skip_element(&field.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let field_name = rename_option.transform(get_field_name(field));
|
||||
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
variant_fields.push(format!("{field_name}: {field_type}"));
|
||||
}
|
||||
enum_variants.push(format!(
|
||||
"{parsed_variant}{} }}}}",
|
||||
variant_fields.join(", ")
|
||||
));
|
||||
}
|
||||
syn::Fields::Unnamed(field) => {
|
||||
let mut variant_fields: Vec<String> = Vec::new();
|
||||
for field in &field.unnamed {
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
variant_fields.push(field_type);
|
||||
}
|
||||
enum_variants.push(format!(
|
||||
"{{\"{parsed_variant}\": [{}]}}",
|
||||
variant_fields.join(", ")
|
||||
));
|
||||
}
|
||||
syn::Fields::Unit => {
|
||||
enum_variants.push(format!("\"{parsed_variant}\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let enum_type = format!(
|
||||
"export type {enum_name}{generics} = {};\n",
|
||||
enum_variants.join(" | ")
|
||||
);
|
||||
(enum_name, enum_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_a_simple_enum() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
Variant3
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Variant1\" | \"Variant2\" | \"Variant3\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_apply_rename_all_modifier() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
Name,
|
||||
UserAge
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_skips_a_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
Name,
|
||||
UserAge,
|
||||
#[serde(skip)]
|
||||
SkipMe
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"id\" | \"name\" | \"userage\";\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_struct_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User {
|
||||
name: String,
|
||||
age: u32,
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": { name: string, age: number }};\n"
|
||||
);
|
||||
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User {
|
||||
name: String,
|
||||
#[serde(skip)]
|
||||
age: u32,
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": { name: string }};\n"
|
||||
);
|
||||
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Request {
|
||||
body: Bytes
|
||||
},
|
||||
Response{
|
||||
payload: Bytes
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = {\"Request\": { body: Bytes }} | {\"Response\": { payload: Bytes }};\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_generic_type() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum<T> {
|
||||
Id,
|
||||
User {
|
||||
name: T,
|
||||
age: u32
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (enum_name, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(enum_name, "MyEnum");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum<T> = \"Id\" | {\"User\": { name: T, age: number }};\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_tuple_variant() {
|
||||
let enum_str = r#"
|
||||
#[derive(Type)]
|
||||
enum MyEnum {
|
||||
Id,
|
||||
User(String, u32),
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_enum = syn::parse_str::<syn::ItemEnum>(&enum_str).unwrap();
|
||||
let (_, typescript_definition) = parse_enum(&parsed_enum);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export type MyEnum = \"Id\" | {\"User\": [string, number]};\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::typescript::parser::utils::{
|
||||
RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute,
|
||||
rust_to_typescript_type,
|
||||
};
|
||||
|
||||
use super::utils::should_skip_element;
|
||||
|
||||
/// Parse a rust struct and returns a tuple of the struct name and the
|
||||
/// struct compiled to a typescript interface
|
||||
pub fn parse_struct(element: &syn::ItemStruct) -> (String, String) {
|
||||
let struct_name = element.ident.to_string();
|
||||
let generics = parse_generics_to_typescript_string(element.generics.clone().params);
|
||||
|
||||
let mut fields_as_string = format!("export interface {struct_name}{generics} {{\n");
|
||||
|
||||
let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all");
|
||||
|
||||
for field in &element.fields {
|
||||
if should_skip_element(&field.attrs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let field_name = get_field_name(field);
|
||||
|
||||
let field_type = rust_to_typescript_type(&field.ty);
|
||||
|
||||
let field_name = rename_option.transform(field_name);
|
||||
|
||||
fields_as_string.push_str(&format!(" {field_name}: {field_type};\n"));
|
||||
}
|
||||
|
||||
fields_as_string.push_str("}\n");
|
||||
|
||||
(struct_name, fields_as_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_struct() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct<'a, T, R>{
|
||||
field_1: &str,
|
||||
field2: i32,
|
||||
field3: Option<String>,
|
||||
field4: Vec<i32>,
|
||||
record: HashMap<&'a str, i32>,
|
||||
user: User,
|
||||
generic: T,
|
||||
generic2: HashMap<&mut str, R>,
|
||||
btree: BTreeMap<String, i32>,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct<T, R> {\n field_1: string;\n field2: number;\n field3: string | null;\n field4: number[];\n record: Record<string, number>;\n user: User;\n generic: T;\n generic2: Record<string, R>;\n btree: Record<string, number>;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_tuple_fields() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
tuple: (i32, i32, String, User),
|
||||
|
||||
}"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n tuple: [number, number, string, User];\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parses_struct_with_no_generics() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
field_1: &str,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_1: string;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_turn_the_keys_camel_case() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n fieldOne: string;\n fieldTwo: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_turn_the_keys_pascal_case() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (struct_name, typescript_definition) = parse_struct(&parsed_struct);
|
||||
|
||||
assert_eq!(struct_name, "MyStruct");
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n FieldOne: string;\n FieldTwo: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_retrieve_the_serde_rename_option() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let rename_option: RenameSerdeOptions =
|
||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
||||
|
||||
assert_eq!(rename_option, RenameSerdeOptions::PascalCase);
|
||||
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MyStruct {
|
||||
field_one: &str,
|
||||
}"#;
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let rename_option: RenameSerdeOptions =
|
||||
parse_serde_attribute(&parsed_struct.attrs, "rename_all");
|
||||
assert_eq!(rename_option, RenameSerdeOptions::CamelCase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_identifies_skip_fields() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
#[serde(skip)]
|
||||
field_one: &str,
|
||||
field_two: i32,
|
||||
#[serde(skip_serializing)]
|
||||
field_three: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_two: number;\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_override_a_field_name() {
|
||||
let struct_str = r#"
|
||||
#[derive(Type)]
|
||||
struct MyStruct {
|
||||
#[serde(rename = "field_one")]
|
||||
field_two: i32,
|
||||
}
|
||||
"#;
|
||||
|
||||
let parsed_struct = syn::parse_str::<syn::ItemStruct>(struct_str).unwrap();
|
||||
let (_, typescript_definition) = parse_struct(&parsed_struct);
|
||||
assert_eq!(
|
||||
typescript_definition,
|
||||
"export interface MyStruct {\n field_one: number;\n}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use convert_case::{Case, Casing};
|
||||
use std::str::FromStr;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{GenericArgument, GenericParam, PathArguments};
|
||||
|
||||
fn type_to_typescript(type_name: &str) -> &str {
|
||||
match type_name {
|
||||
"i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
|
||||
| "isize" | "usize" => "number",
|
||||
"str" | "String" | "char" => "string",
|
||||
"bool" => "boolean",
|
||||
_ => type_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_field_name(field: &syn::Field) -> String {
|
||||
let field_name: String = parse_serde_attribute(&field.attrs, "rename");
|
||||
|
||||
if !field_name.is_empty() {
|
||||
return field_name;
|
||||
}
|
||||
|
||||
if let Some(field) = field.ident.as_ref() {
|
||||
field.to_string()
|
||||
} else {
|
||||
String::from("unknown")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the struct generics and return them collected into a "<...>" string.
|
||||
/// If no generics are present, return an empty string.
|
||||
pub fn parse_generics_to_typescript_string(generics: Punctuated<GenericParam, Comma>) -> String {
|
||||
let generics = generics
|
||||
.iter()
|
||||
.map(|param| {
|
||||
if let syn::GenericParam::Type(type_param) = param {
|
||||
type_param.ident.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
if !generics.is_empty() {
|
||||
return format!("<{}>", generics.join(", "));
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Parse any "assign" `#[serde(... = "...")]` attribute and return the value of the specified
|
||||
/// attribute.
|
||||
pub fn parse_serde_attribute<T>(attrs: &[syn::Attribute], attribute_name: &str) -> T
|
||||
where
|
||||
T: Default + FromStr,
|
||||
{
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("serde") {
|
||||
if let Ok(meta) = attr.parse_args::<syn::Expr>() {
|
||||
match meta {
|
||||
syn::Expr::Assign(assign) => {
|
||||
if let syn::Expr::Path(path) = *assign.left {
|
||||
if !path.path.is_ident(attribute_name) {
|
||||
return T::default();
|
||||
}
|
||||
}
|
||||
if let syn::Expr::Lit(syn::ExprLit {
|
||||
lit: syn::Lit::Str(lit_str),
|
||||
..
|
||||
}) = *assign.right
|
||||
{
|
||||
return T::from_str(&lit_str.value()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
_ => return T::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T::default()
|
||||
}
|
||||
|
||||
/// Check if the element should be skipped based on the presence of
|
||||
/// `#[serde(skip)]` or `#[serde(skip_serializing)]` attributes.
|
||||
pub fn should_skip_element(attrs: &[syn::Attribute]) -> bool {
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("serde") {
|
||||
if let Ok(meta) = attr.parse_args::<syn::Ident>() {
|
||||
if meta == "skip" || meta == "skip_serializing" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn rust_to_typescript_type(ty: &syn::Type) -> String {
|
||||
match ty {
|
||||
syn::Type::Tuple(tuple) => {
|
||||
let inner_types: Vec<String> =
|
||||
tuple.elems.iter().map(rust_to_typescript_type).collect();
|
||||
format!("[{}]", inner_types.join(", "))
|
||||
}
|
||||
syn::Type::Path(type_path) => {
|
||||
if let Some(last_segment) = type_path.path.segments.last() {
|
||||
let outer_type = last_segment.ident.to_string();
|
||||
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
|
||||
let inner_types: Vec<String> = args
|
||||
.args
|
||||
.iter()
|
||||
.filter_map(|arg| {
|
||||
if let GenericArgument::Type(inner_type) = arg {
|
||||
match inner_type {
|
||||
syn::Type::Path(inner_type_path) => {
|
||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
||||
}
|
||||
syn::Type::Reference(reference) => {
|
||||
if let syn::Type::Path(inner_type_path) = &*reference.elem {
|
||||
Some(inner_type_path.path.segments[0].ident.to_string())
|
||||
} else {
|
||||
Some("unknown".to_string())
|
||||
}
|
||||
}
|
||||
_ => Some("unknown".to_string()),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
match outer_type.as_str() {
|
||||
"Option" => {
|
||||
format!("{} | null", type_to_typescript(&inner_types[0]))
|
||||
}
|
||||
"Vec" => {
|
||||
format!("{}[]", type_to_typescript(&inner_types[0]))
|
||||
}
|
||||
"HashMap" | "BTreeMap" => {
|
||||
format!(
|
||||
"Record<{}, {}>",
|
||||
type_to_typescript(&inner_types[0]),
|
||||
type_to_typescript(&inner_types[1])
|
||||
)
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
type_to_typescript(&outer_type).to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
syn::Type::Reference(reference) => {
|
||||
// Ignore lifetimes and treat references as their base type
|
||||
if let syn::Type::Path(type_path) = &*reference.elem {
|
||||
if let Some(base_type) = type_path.path.segments.last() {
|
||||
type_to_typescript(&base_type.ident.to_string()).to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// This enum matches serde's RenameRule enum
|
||||
#[derive(Debug, Eq, PartialEq, Default)]
|
||||
pub enum RenameSerdeOptions {
|
||||
#[default]
|
||||
None,
|
||||
LowerCase,
|
||||
UpperCase,
|
||||
PascalCase,
|
||||
CamelCase,
|
||||
SnakeCase,
|
||||
ScreamingSnakeCase,
|
||||
KebabCase,
|
||||
ScreamingKebabCase,
|
||||
}
|
||||
|
||||
impl FromStr for RenameSerdeOptions {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
match input {
|
||||
"lowercase" => Ok(Self::LowerCase),
|
||||
"UPPERCASE" => Ok(Self::UpperCase),
|
||||
"PascalCase" => Ok(Self::PascalCase),
|
||||
"camelCase" => Ok(Self::CamelCase),
|
||||
"snake_case" => Ok(Self::SnakeCase),
|
||||
"SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase),
|
||||
"kebab-case" => Ok(Self::KebabCase),
|
||||
"SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameSerdeOptions {
|
||||
pub fn transform(&self, input: String) -> String {
|
||||
match self {
|
||||
Self::LowerCase => input.to_lowercase(),
|
||||
Self::UpperCase => input.to_uppercase(),
|
||||
Self::CamelCase => input.to_case(Case::Camel),
|
||||
Self::PascalCase => input.to_case(Case::Pascal),
|
||||
_ => input,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use crate::symbols::TYPE_TRAIT;
|
||||
use crate::typescript::FileTypes;
|
||||
use glob::glob;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::read_to_string;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{error, trace};
|
||||
use tuono_internal::tuono_println;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TypesJar {
|
||||
types: Vec<FileTypes>,
|
||||
should_generate_typescript_file: bool,
|
||||
}
|
||||
|
||||
impl TypesJar {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
types: Vec::new(),
|
||||
should_generate_typescript_file: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypesJar {
|
||||
/// Removes all types from the jar that are
|
||||
/// present in the provided `file_path`.
|
||||
/// This function is triggered when a file is deleted
|
||||
pub fn remove_file(&mut self, file_path: PathBuf) {
|
||||
self.should_generate_typescript_file = true;
|
||||
self.types.retain(|ttype| ttype.file_path != file_path);
|
||||
}
|
||||
|
||||
pub fn refresh_file(&mut self, path: PathBuf) {
|
||||
if let Ok(file_str) = read_to_string(&path) {
|
||||
if file_str.contains(*TYPE_TRAIT) {
|
||||
if let Ok(ttype) = FileTypes::try_from((path.clone(), file_str)) {
|
||||
if Some(&ttype) == self.types.iter().find(|t| t.file_path == path) {
|
||||
// The new file exactly matches the old one
|
||||
trace!("File already exists in jar: {:?}", path);
|
||||
return;
|
||||
}
|
||||
trace!("Refreshing: {:?} type", ttype.types);
|
||||
|
||||
self.should_generate_typescript_file = true;
|
||||
self.remove_file(path);
|
||||
self.types.push(ttype);
|
||||
} else {
|
||||
error!("Failed to parse file: {:?}", path);
|
||||
}
|
||||
} else {
|
||||
// Check if the file exist. In case it is it means that the user
|
||||
// removed the "Type" derived trait. Hence we have to remove it from
|
||||
// the jar
|
||||
self.remove_file(path.clone());
|
||||
}
|
||||
} else {
|
||||
error!("Failed to read file: {:?}", path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_duplicate_types(&self) -> HashMap<String, (PathBuf, PathBuf)> {
|
||||
trace!("Checking for duplicated types");
|
||||
let mut duplicates: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
|
||||
let mut paths: Vec<&PathBuf> = Vec::new();
|
||||
let mut types: Vec<&Vec<String>> = Vec::new();
|
||||
|
||||
for file_types in self.types.iter() {
|
||||
paths.push(&file_types.file_path);
|
||||
types.push(&file_types.types);
|
||||
}
|
||||
|
||||
for i in 0..types.len() {
|
||||
for j in (i + 1)..types.len() {
|
||||
let types_i = types[i];
|
||||
let types_j = types[j];
|
||||
|
||||
for type_i in types_i {
|
||||
if types_j.contains(type_i) {
|
||||
duplicates.insert(type_i.clone(), (paths[j].clone(), paths[i].clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duplicates
|
||||
}
|
||||
|
||||
fn log_duplicated_types(&self) {
|
||||
let duplicates = self.check_duplicate_types();
|
||||
let base_path = env::current_dir().unwrap_or_default();
|
||||
let base_path_str = base_path.to_string_lossy();
|
||||
|
||||
for (type_name, file_paths) in duplicates.iter() {
|
||||
let first_file_path = file_paths
|
||||
.0
|
||||
.to_string_lossy()
|
||||
.replace(&base_path_str.to_string(), "");
|
||||
let second_file_path = file_paths
|
||||
.1
|
||||
.to_string_lossy()
|
||||
.replace(&base_path_str.to_string(), "");
|
||||
|
||||
tuono_println!("Duplicate \"{}\" type found in files:\n", type_name);
|
||||
tuono_println!("- {}", first_file_path);
|
||||
tuono_println!("- {}\n", second_file_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the string containing all the typescript types
|
||||
/// found in the jar.
|
||||
fn generate_typescript(&self) -> String {
|
||||
self.log_duplicated_types();
|
||||
let mut typescript = String::from("declare module \"tuono/types\" {\n");
|
||||
for ttype in &self.types {
|
||||
typescript.push_str(&format!(
|
||||
"// START [{}]\n",
|
||||
ttype.file_path.to_string_lossy()
|
||||
));
|
||||
typescript.push_str(&ttype.types_as_string);
|
||||
typescript.push_str(&format!("// END [{}]\n", ttype.file_path.to_string_lossy()));
|
||||
}
|
||||
typescript.push_str("}\n");
|
||||
typescript
|
||||
}
|
||||
|
||||
pub fn generate_typescript_file(&mut self, base_path: &Path) -> std::io::Result<()> {
|
||||
if !self.should_generate_typescript_file {
|
||||
trace!("No need to create typescript module file");
|
||||
return Ok(());
|
||||
}
|
||||
self.should_generate_typescript_file = false;
|
||||
trace!("Creating typescript module file");
|
||||
let typescript = self.generate_typescript();
|
||||
let typescript_file_path = base_path.join(".tuono").join("types.ts");
|
||||
std::fs::write(typescript_file_path, typescript)?;
|
||||
|
||||
trace!("Typescript module file created");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PathBuf> for TypesJar {
|
||||
/// Fill the TypesJar with all the Rust files found within
|
||||
/// the provided `base_path`.
|
||||
fn from(base_path: &PathBuf) -> Self {
|
||||
let mut jar = Self::new();
|
||||
|
||||
if let Some(path) = base_path.join("src/**/*.rs").to_str() {
|
||||
if let Ok(files) = glob(path) {
|
||||
files.for_each(|path| {
|
||||
let file_path = path.unwrap_or_default();
|
||||
if let Ok(file_str) = read_to_string(&file_path) {
|
||||
if !file_str.contains(*TYPE_TRAIT) {
|
||||
return;
|
||||
}
|
||||
if let Ok(ttype) = FileTypes::try_from((file_path.clone(), file_str)) {
|
||||
jar.types.push(ttype);
|
||||
} else {
|
||||
error!("Failed to parse file: {:?}", file_path);
|
||||
}
|
||||
} else {
|
||||
error!("Failed to read file: {:?}", file_path);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
error!("Failed to read glob pattern: {:?}", path);
|
||||
}
|
||||
}
|
||||
jar
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_correctly_finds_duplicate_types() {
|
||||
let file_type1 = FileTypes {
|
||||
file_path: PathBuf::from("src/types1.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![
|
||||
String::from("Type1"),
|
||||
String::from("Type2"),
|
||||
String::from("Type3"),
|
||||
],
|
||||
};
|
||||
let file_type2 = FileTypes {
|
||||
file_path: PathBuf::from("src/types2.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![
|
||||
String::from("Type1"),
|
||||
String::from("Type2"),
|
||||
String::from("Type4"),
|
||||
],
|
||||
};
|
||||
|
||||
let file_type3 = FileTypes {
|
||||
file_path: PathBuf::from("src/types2.rs"),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![String::from("Type3")],
|
||||
};
|
||||
|
||||
let mut jar = TypesJar::new();
|
||||
jar.types.push(file_type1);
|
||||
jar.types.push(file_type2);
|
||||
jar.types.push(file_type3);
|
||||
|
||||
let result = jar.check_duplicate_types();
|
||||
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type1".to_string())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type2".to_string())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type3".to_string())
|
||||
);
|
||||
|
||||
assert!(
|
||||
!result
|
||||
.keys()
|
||||
.collect::<Vec<&String>>()
|
||||
.contains(&&"Type4".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_removes_types() {
|
||||
let mut jar = TypesJar::new();
|
||||
let file_path = PathBuf::from("src/types.rs");
|
||||
let file_type = FileTypes {
|
||||
file_path: file_path.clone(),
|
||||
types_as_string: String::from("type1"),
|
||||
types: vec![String::from("Type1")],
|
||||
};
|
||||
assert!(jar.should_generate_typescript_file);
|
||||
//Force file generation to false
|
||||
jar.should_generate_typescript_file = false;
|
||||
|
||||
jar.types.push(file_type);
|
||||
jar.remove_file(file_path.clone());
|
||||
|
||||
assert!(jar.should_generate_typescript_file);
|
||||
assert_eq!(jar.types.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::symbols::TYPE_TRAIT;
|
||||
use syn::{Attribute, Meta};
|
||||
|
||||
pub fn has_derive_type(attrs: &[Attribute]) -> bool {
|
||||
for attr in attrs {
|
||||
if let Meta::List(meta_list) = &attr.meta {
|
||||
if meta_list.path.is_ident("derive") {
|
||||
for nested_meta in meta_list
|
||||
.parse_args_with(
|
||||
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Meta::Path(path) = nested_meta {
|
||||
if path.is_ident(&TYPE_TRAIT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use syn::{ItemEnum, ItemStruct, ItemType, parse_quote};
|
||||
|
||||
#[test]
|
||||
fn it_correctly_checks_if_derive_type_is_present() {
|
||||
let test_struct: ItemStruct = parse_quote! {
|
||||
#[derive(Type)]
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_struct.attrs));
|
||||
|
||||
let test_type: ItemType = parse_quote! {
|
||||
#[derive(Type)]
|
||||
type MyType = i32;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_type.attrs));
|
||||
|
||||
let test_enum: ItemEnum = parse_quote! {
|
||||
#[derive(Type)]
|
||||
enum MyEnunType {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&test_enum.attrs));
|
||||
|
||||
let test_struct_without_type: ItemStruct = parse_quote! {
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(!has_derive_type(&test_struct_without_type.attrs));
|
||||
|
||||
let multi_derived_trait: ItemStruct = parse_quote! {
|
||||
#[derive(Type, Serialize, Debug)]
|
||||
struct MyStruct;
|
||||
};
|
||||
|
||||
assert!(has_derive_type(&multi_derived_trait.attrs));
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use watchexec_supervisor::command::{Command, Program};
|
||||
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
use watchexec::Watchexec;
|
||||
use watchexec_signals::Signal;
|
||||
use watchexec_supervisor::job::{start_job, Job};
|
||||
|
||||
use crate::mode::Mode;
|
||||
use crate::source_builder::bundle_axum_source;
|
||||
use console::Term;
|
||||
use spinners::{Spinner, Spinners};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_WATCH_BIN_SRC: &str = "node_modules\\.bin\\tuono-dev-watch.cmd";
|
||||
#[cfg(target_os = "windows")]
|
||||
const DEV_SSR_BIN_SRC: &str = "node_modules\\.bin\\tuono-dev-ssr.cmd";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_WATCH_BIN_SRC: &str = "node_modules/.bin/tuono-dev-watch";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const DEV_SSR_BIN_SRC: &str = "node_modules/.bin/tuono-dev-ssr";
|
||||
|
||||
fn watch_react_src() -> Job {
|
||||
if !Path::new(DEV_SSR_BIN_SRC).exists() {
|
||||
eprintln!("Failed to find script to run dev watch. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
}
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: DEV_WATCH_BIN_SRC.into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
fn run_rust_dev_server() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "cargo".into(),
|
||||
args: vec!["run".to_string(), "-q".to_string()],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
fn build_rust_src() -> Job {
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: "cargo".into(),
|
||||
args: vec!["build".to_string(), "-q".to_string()],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
fn build_react_ssr_src() -> Job {
|
||||
if !Path::new(DEV_SSR_BIN_SRC).exists() {
|
||||
eprintln!("Failed to find script to run dev ssr. Please run `npm install`");
|
||||
std::process::exit(1);
|
||||
}
|
||||
start_job(Arc::new(Command {
|
||||
program: Program::Exec {
|
||||
prog: DEV_SSR_BIN_SRC.into(),
|
||||
args: vec![],
|
||||
},
|
||||
options: Default::default(),
|
||||
}))
|
||||
.0
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn watch() -> Result<()> {
|
||||
let term = Term::stdout();
|
||||
let mut sp = Spinner::new(Spinners::Dots, "Starting dev server...".into());
|
||||
|
||||
watch_react_src().start().await;
|
||||
|
||||
let rust_server = run_rust_dev_server();
|
||||
let build_rust_src = build_rust_src();
|
||||
|
||||
let build_ssr_bundle = build_react_ssr_src();
|
||||
|
||||
build_ssr_bundle.start().await;
|
||||
build_rust_src.start().await;
|
||||
|
||||
// Wait vite and rust builds
|
||||
build_ssr_bundle.to_wait().await;
|
||||
build_rust_src.to_wait().await;
|
||||
|
||||
rust_server.start().await;
|
||||
|
||||
// Remove the spinner
|
||||
sp.stop();
|
||||
term.clear_line().unwrap();
|
||||
|
||||
let wx = Watchexec::new(move |mut action| {
|
||||
let mut should_reload_ssr_bundle = false;
|
||||
let mut should_reload_rust_server = false;
|
||||
|
||||
for event in action.events.iter() {
|
||||
for path in event.paths() {
|
||||
let file_path = path.0.to_string_lossy();
|
||||
if file_path.ends_with(".rs") {
|
||||
should_reload_rust_server = true
|
||||
}
|
||||
|
||||
// Either tsx, jsx and mdx
|
||||
if file_path.ends_with("sx") || file_path.ends_with("mdx") {
|
||||
should_reload_ssr_bundle = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_reload_rust_server {
|
||||
println!(" Reloading...");
|
||||
rust_server.stop();
|
||||
bundle_axum_source(Mode::Dev).expect("Failed to bundle rust source");
|
||||
rust_server.start();
|
||||
}
|
||||
|
||||
if should_reload_ssr_bundle {
|
||||
build_ssr_bundle.stop();
|
||||
build_ssr_bundle.start();
|
||||
}
|
||||
|
||||
// if Ctrl-C is received, quit
|
||||
if action.signals().any(|sig| sig == Signal::Interrupt) {
|
||||
action.quit();
|
||||
}
|
||||
|
||||
action
|
||||
})?;
|
||||
|
||||
// watch the current directory
|
||||
wx.config.pathset(["./src"]);
|
||||
|
||||
let _ = wx.main().await.into_diagnostic()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import 'vite/modulepreload-polyfill'
|
||||
import { hydrate } from 'tuono/hydration'
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
hydrate(routeTree)
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
window['__TUONO_SERVER_PAYLOAD__'] = [SERVER_PAYLOAD]
|
||||
</script>
|
||||
<script type="module" async>
|
||||
import RefreshRuntime from '[BASE_URL]/vite-server/@react-refresh'
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => { }
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" async src="[BASE_URL]/vite-server/@vite/client"></script>
|
||||
<script type="module" async src="[BASE_URL]/vite-server/client-main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
// File automatically generated
|
||||
// Do not manually change it
|
||||
|
||||
use tuono_lib::{tokio, Mode, Server, axum::Router, tuono_internal_init_v8_platform};
|
||||
// AXUM_GET_ROUTE_HANDLER
|
||||
|
||||
const MODE: Mode = /*MODE*/;
|
||||
|
||||
// MODULE_IMPORTS
|
||||
|
||||
//MAIN_FILE_IMPORT//
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tuono_internal_init_v8_platform();
|
||||
|
||||
if MODE == Mode::Prod {
|
||||
println!("\n ⚡ Tuono v/*VERSION*/");
|
||||
}
|
||||
|
||||
//MAIN_FILE_DEFINITION//
|
||||
|
||||
let router = Router::new()
|
||||
// ROUTE_BUILDER
|
||||
//MAIN_FILE_USAGE//;
|
||||
|
||||
Server::init(router, MODE).await.start().await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// File automatically generated by tuono
|
||||
// Do not manually update this file
|
||||
import { serverSideRendering } from 'tuono/ssr'
|
||||
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const renderFn = serverSideRendering(routeTree)
|
||||
@@ -1,6 +1,7 @@
|
||||
use assert_cmd::Command;
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use tracing::Level;
|
||||
|
||||
mod utils;
|
||||
use utils::temp_tuono_project::TempTuonoProject;
|
||||
@@ -8,6 +9,10 @@ use utils::temp_tuono_project::TempTuonoProject;
|
||||
const POST_API_FILE: &str = r"#[tuono_lib::api(POST)]";
|
||||
const GET_API_FILE: &str = r"#[tuono_lib::api(GET)]";
|
||||
|
||||
fn tracing_message(level: Level, module: &str, message: &str) -> String {
|
||||
format!("\x1b[31m{level}\x1b[0m \x1b[2mtuono::{module}\x1b[0m\x1b[2m:\x1b[0m {message}\n")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const BUILD_TUONO_CONFIG: &str = ".\\node_modules\\.bin\\tuono-build-config.cmd";
|
||||
|
||||
@@ -95,8 +100,11 @@ fn it_successfully_create_multiple_api_for_the_same_file() {
|
||||
assert!(temp_main_rs_content.contains(
|
||||
r#".route("/api/health_check", post(api_health_check::post_tuono_internal_api))"#
|
||||
));
|
||||
assert!(temp_main_rs_content
|
||||
.contains(r#".route("/api/health_check", get(api_health_check::get_tuono_internal_api))"#));
|
||||
assert!(
|
||||
temp_main_rs_content.contains(
|
||||
r#".route("/api/health_check", get(api_health_check::get_tuono_internal_api))"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -185,7 +193,11 @@ fn dev_fails_with_no_config() {
|
||||
.arg("dev")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr("Cannot find tuono.config.ts - is this a tuono project?\n");
|
||||
.stdout(tracing_message(
|
||||
Level::ERROR,
|
||||
"source_builder",
|
||||
"Cannot find tuono.config.ts - is this a tuono project?",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -198,5 +210,9 @@ fn build_fails_with_no_config() {
|
||||
.arg("dev")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr("Cannot find tuono.config.ts - is this a tuono project?\n");
|
||||
.stdout(tracing_message(
|
||||
Level::ERROR,
|
||||
"source_builder",
|
||||
"Cannot find tuono.config.ts - is this a tuono project?",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TempTuonoProject {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "tuono_internal"
|
||||
version = "0.17.10"
|
||||
edition = "2021"
|
||||
version = "0.19.6"
|
||||
edition = "2024"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
@@ -11,7 +11,7 @@ readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"src/**/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
|
||||
@@ -24,4 +24,4 @@ serde_json = "1.0.137"
|
||||
fs_extra = "1.3.0"
|
||||
tempfile = "3.14.0"
|
||||
assert_cmd = "2.0.16"
|
||||
serial_test = "0.10.0"
|
||||
serial_test = "3.0.0"
|
||||
|
||||
@@ -10,7 +10,17 @@ pub struct ServerConfig {
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
ServerConfig {
|
||||
host: "localhost".to_string(),
|
||||
origin: None,
|
||||
port: 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
}
|
||||
@@ -23,3 +33,17 @@ impl Config {
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = Config::default();
|
||||
|
||||
assert_eq!(config.server.host, "localhost".to_string());
|
||||
assert_eq!(config.server.origin, None);
|
||||
assert_eq!(config.server.port, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod config;
|
||||
pub mod tuono_println;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#[macro_export]
|
||||
/// Log a message in the terminal using the custom tuono formatter.
|
||||
/// The messages printed with this macro should inform or
|
||||
/// guide the user.
|
||||
///
|
||||
/// The debug/error messages should be printed using the `tracing` crate
|
||||
macro_rules! tuono_println {
|
||||
($($arg:tt)*) => {{
|
||||
println!(" {}", format!($($arg)*));
|
||||
}};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TempTuonoProject {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.17.10"
|
||||
edition = "2021"
|
||||
version = "0.19.6"
|
||||
edition = "2024"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
@@ -11,13 +11,13 @@ readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"src/**/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
|
||||
|
||||
[dependencies]
|
||||
ssr_rs = "0.8.1"
|
||||
ssr_rs = "0.8.3"
|
||||
axum = {version = "0.8.1", features = ["json", "ws"]}
|
||||
axum-extra = {version = "0.10.0", features = ["cookie"]}
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
@@ -30,10 +30,10 @@ once_cell = "1.19.0"
|
||||
regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
tower-http = {version = "0.6.0", features = ["fs"]}
|
||||
colored = "2.1.0"
|
||||
colored = "3.0.0"
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.17.10"}
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.17.10"}
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.6"}
|
||||
tuono_internal = {path = "../tuono_internal", version = "0.19.6"}
|
||||
# Match the same version used by axum
|
||||
tokio-tungstenite = "0.26.0"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
@@ -45,4 +45,4 @@ tower = "0.5.1"
|
||||
[dev-dependencies]
|
||||
fs_extra = "1.3.0"
|
||||
tempfile = "3.14.0"
|
||||
serial_test = "0.10.0"
|
||||
serial_test = "3.0.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{ssr::Js, Payload};
|
||||
use crate::{Payload, ssr::Js};
|
||||
use axum::extract::{Path, Request};
|
||||
use axum::response::Html;
|
||||
use std::collections::HashMap;
|
||||
@@ -10,7 +10,7 @@ pub async fn catch_all(
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
let req = crate::Request::new(pathname.to_owned(), headers.to_owned(), params, None);
|
||||
|
||||
// TODO: remove unwrap
|
||||
let payload = Payload::new(&req, &"").client_payload().unwrap();
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
use crate::mode::Mode;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
/// Read the env variables from the .env files
|
||||
/// and set them in the OS env
|
||||
///
|
||||
/// This function is unsafe because it modifies the OS env variables (which needs
|
||||
/// to be done in a single-threaded context).
|
||||
pub unsafe fn load_env_vars(mode: Mode) {
|
||||
let mut env_files = vec![String::from(".env"), String::from(".env.local")];
|
||||
|
||||
let mode_name = match mode {
|
||||
Mode::Dev => "development",
|
||||
Mode::Prod => "production",
|
||||
};
|
||||
|
||||
env_files.push(format!(".env.{}", mode_name));
|
||||
env_files.push(String::from(".env.local"));
|
||||
env_files.push(format!(".env.{}.local", mode_name));
|
||||
|
||||
let system_env_names: HashSet<String> = env::vars().map(|(k, _)| k).collect();
|
||||
|
||||
for env_file in env_files {
|
||||
if let Ok(contents) = fs::read_to_string(env_file) {
|
||||
for line in contents.lines() {
|
||||
if let Some((key, mut value)) = line.split_once('=') {
|
||||
if value.starts_with('"') && value.ends_with('"') {
|
||||
value = &value[1..value.len() - 1];
|
||||
}
|
||||
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim().to_string();
|
||||
|
||||
if system_env_names.contains(&key) {
|
||||
continue; // Skip if key exists in system env
|
||||
}
|
||||
|
||||
unsafe {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mode::Mode;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
struct MockEnv {
|
||||
files: Vec<String>,
|
||||
vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl MockEnv {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Vec::new(),
|
||||
vars: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_system_var(&mut self, k: &str, v: &str) {
|
||||
self.vars.insert(k.to_string(), v.to_string());
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_env_file(&mut self, file_name: &str, contents: &str) {
|
||||
self.files.push(file_name.to_string());
|
||||
fs::write(file_name, contents).expect("Failed to write test .env file");
|
||||
}
|
||||
|
||||
pub fn capture_keys(&mut self, keys: &[&str]) {
|
||||
for key in keys {
|
||||
if let Ok(val) = env::var(key) {
|
||||
self.vars.insert(key.to_string(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockEnv {
|
||||
fn drop(&mut self) {
|
||||
for file in self.files.iter() {
|
||||
let _ = fs::remove_file(file.as_str());
|
||||
}
|
||||
for key in self.vars.keys() {
|
||||
unsafe {
|
||||
env::remove_var(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_system_env_var_precedence() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.add_system_var("TEST_KEY", "system_value");
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=file_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "system_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_specific_env_var_precedence_dev() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "development_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_specific_env_var_precedence_prod() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Prod);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "production_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_local_env_var_precedence() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.local", "TEST_KEY=local_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "local_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_local_env_var_precedence_dev() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
|
||||
mock_env.setup_env_file(".env.development.local", "TEST_KEY=local_dev_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "local_dev_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_mode_local_env_var_precedence_prod() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
|
||||
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
|
||||
mock_env.setup_env_file(".env.production.local", "TEST_KEY=local_prod_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Prod);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "local_prod_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_ignores_files_from_other_mode() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
|
||||
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Prod);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "production_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_empty_env_file() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
assert!(env::var("NON_EXISTENT_KEY").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_malformed_env_entries() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "INVALID_LINE\nMISSING_EQUALS_SIGN");
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["INVALID_LINE", "MISSING_EQUALS_SIGN"]);
|
||||
|
||||
assert!(env::var("INVALID_LINE").is_err());
|
||||
assert!(env::var("MISSING_EQUALS_SIGN").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_quoted_values_parsing() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", r#"TEST_KEY="quoted_value""#);
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["TEST_KEY"]);
|
||||
|
||||
assert_eq!(env::var("TEST_KEY").unwrap(), "quoted_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_non_existent_env_file() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["NON_EXISTENT_KEY"]);
|
||||
|
||||
assert!(env::var("NON_EXISTENT_KEY").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_multiple_env_vars() {
|
||||
let mut mock_env = MockEnv::new();
|
||||
|
||||
mock_env.setup_env_file(".env", "KEY1=value1\nKEY2=value2");
|
||||
|
||||
unsafe {
|
||||
load_env_vars(Mode::Dev);
|
||||
}
|
||||
|
||||
mock_env.capture_keys(&["KEY1", "KEY2"]);
|
||||
|
||||
assert_eq!(env::var("KEY1").unwrap(), "value1");
|
||||
assert_eq!(env::var("KEY2").unwrap(), "value2");
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@
|
||||
|
||||
mod catch_all;
|
||||
mod config;
|
||||
mod logger;
|
||||
mod env;
|
||||
mod manifest;
|
||||
mod mode;
|
||||
mod payload;
|
||||
mod request;
|
||||
mod response;
|
||||
mod server;
|
||||
mod services;
|
||||
mod ssr;
|
||||
mod vite_reverse_proxy;
|
||||
mod vite_websocket_proxy;
|
||||
@@ -20,8 +21,8 @@ pub use mode::Mode;
|
||||
pub use payload::Payload;
|
||||
pub use request::Request;
|
||||
pub use response::{Props, Response};
|
||||
pub use server::{tuono_internal_init_v8_platform, Server};
|
||||
pub use tuono_lib_macros::{api, handler};
|
||||
pub use server::{Server, tuono_internal_init_v8_platform};
|
||||
pub use tuono_lib_macros::{Type, api, handler};
|
||||
|
||||
// Re-exports
|
||||
pub use axum;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use crate::manifest::MANIFEST;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use erased_serde::Serialize;
|
||||
use regex::Regex;
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
@@ -107,7 +107,7 @@ impl<'a> Payload<'a> {
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
let mut route_segments_collector: Vec<&str> = vec![];
|
||||
let mut route_segments_collector: Vec<&str> = Vec::new();
|
||||
|
||||
for i in 0..dyn_route_segments.len() {
|
||||
if dyn_route_segments[i].starts_with("[...") {
|
||||
|
||||
+106
-24
@@ -1,8 +1,8 @@
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
|
||||
/// Location must match client side interface
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Location {
|
||||
@@ -37,14 +37,21 @@ pub struct Request {
|
||||
pub uri: Uri,
|
||||
pub headers: HeaderMap,
|
||||
pub params: HashMap<String, String>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn new(uri: Uri, headers: HeaderMap, params: HashMap<String, String>) -> Request {
|
||||
pub fn new(
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
params: HashMap<String, String>,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Request {
|
||||
Request {
|
||||
uri,
|
||||
headers,
|
||||
params,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,27 +60,50 @@ impl Request {
|
||||
}
|
||||
|
||||
pub fn body<'de, T: Deserialize<'de>>(&'de self) -> Result<T, BodyParseError> {
|
||||
if let Some(body) = self.headers.get("body") {
|
||||
if let Ok(body) = body.to_str() {
|
||||
let body = serde_json::from_str::<T>(body)?;
|
||||
return Ok(body);
|
||||
}
|
||||
return Err(BodyParseError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Failed to read body",
|
||||
)));
|
||||
if let Some(body) = &self.body {
|
||||
let body = serde_json::from_slice::<T>(body)?;
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
Err(BodyParseError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"No body found",
|
||||
"Failed to read body",
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn form_data<T>(&self) -> Result<T, BodyParseError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let content_type = self
|
||||
.headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if !content_type.contains("application/x-www-form-urlencoded") {
|
||||
return Err(BodyParseError::ContentType(
|
||||
"Invalid content type, expected application/x-www-form-urlencoded".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let body = self.body.as_ref().ok_or_else(|| {
|
||||
BodyParseError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Missing request body",
|
||||
))
|
||||
})?;
|
||||
|
||||
serde_urlencoded::from_bytes::<T>(body).map_err(BodyParseError::UrlEncoded)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BodyParseError {
|
||||
Io(std::io::Error),
|
||||
Serde(serde_json::Error),
|
||||
UrlEncoded(serde_urlencoded::de::Error),
|
||||
ContentType(String),
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for BodyParseError {
|
||||
@@ -93,17 +123,19 @@ mod tests {
|
||||
field2: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FormData {
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_correctly_parse_the_body() {
|
||||
let mut request = Request::new(
|
||||
let request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
);
|
||||
|
||||
request.headers.insert(
|
||||
"body",
|
||||
r#"{"field1": true, "field2": "hello"}"#.parse().unwrap(),
|
||||
Some(r#"{"field1": true, "field2": "hello"}"#.as_bytes().to_vec()),
|
||||
);
|
||||
|
||||
let body: FakeBody = request.body().expect("Failed to parse body");
|
||||
@@ -113,11 +145,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_trigger_an_error_when_no_body_is_found() {
|
||||
fn it_should_trigger_an_error_when_body_is_invalid() {
|
||||
let request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
Some(r#"{"field1": true"#.as_bytes().to_vec()),
|
||||
);
|
||||
|
||||
let body: Result<FakeBody, BodyParseError> = request.body();
|
||||
@@ -126,19 +159,68 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_should_trigger_an_error_when_body_is_invalid() {
|
||||
fn it_correctly_parses_form_data() {
|
||||
let mut request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
request.headers.insert(
|
||||
"content-type",
|
||||
"application/x-www-form-urlencoded".parse().unwrap(),
|
||||
);
|
||||
|
||||
request.body = Some("name=John+Doe&email=john%40example.com".as_bytes().to_vec());
|
||||
|
||||
let form_data: Result<FormData, BodyParseError> = request.form_data();
|
||||
|
||||
assert!(form_data.is_ok());
|
||||
let data = form_data.unwrap();
|
||||
assert_eq!(data.name, "John Doe");
|
||||
assert_eq!(data.email, Some("john@example.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_rejects_wrong_form_content_type() {
|
||||
let mut request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
request
|
||||
.headers
|
||||
.insert("body", r#"{"field1": true"#.parse().unwrap());
|
||||
.insert("content-type", "application/json".parse().unwrap());
|
||||
|
||||
let body: Result<FakeBody, BodyParseError> = request.body();
|
||||
request.headers.insert(
|
||||
"body",
|
||||
"name=John+Doe&email=john%40example.com".parse().unwrap(),
|
||||
);
|
||||
|
||||
assert!(body.is_err());
|
||||
let form_data: Result<FormData, BodyParseError> = request.form_data();
|
||||
|
||||
assert!(form_data.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_handles_missing_form_body() {
|
||||
let mut request = Request::new(
|
||||
Uri::from_static("http://localhost:3000"),
|
||||
HeaderMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
request.headers.insert(
|
||||
"content-type",
|
||||
"application/x-www-form-urlencoded".parse().unwrap(),
|
||||
);
|
||||
|
||||
let form_data: Result<FormData, BodyParseError> = request.form_data();
|
||||
|
||||
assert!(form_data.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::Request;
|
||||
use crate::{ssr::Js, Payload};
|
||||
use crate::{Payload, ssr::Js};
|
||||
use axum::Json;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Redirect};
|
||||
use axum::Json;
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use erased_serde::Serialize;
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Props {
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse {
|
||||
pub fn render_to_string(&self, req: Request) -> impl IntoResponse + use<> {
|
||||
match self {
|
||||
Self::Props(Props {
|
||||
data,
|
||||
@@ -106,7 +106,7 @@ impl Response {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json(&self) -> impl IntoResponse {
|
||||
pub fn json(&self) -> impl IntoResponse + use<> {
|
||||
match self {
|
||||
Self::Props(Props {
|
||||
data,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use crate::manifest::load_manifest;
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use axum::routing::{get, Router};
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use axum::routing::{Router, get};
|
||||
use colored::Colorize;
|
||||
use ssr_rs::Ssr;
|
||||
use tower_http::services::ServeDir;
|
||||
use tuono_internal::config::Config;
|
||||
use tuono_internal::tuono_println;
|
||||
|
||||
use crate::env::load_env_vars;
|
||||
use crate::{
|
||||
catch_all::catch_all, logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy,
|
||||
catch_all::catch_all, services::logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy,
|
||||
vite_websocket_proxy::vite_websocket_proxy,
|
||||
};
|
||||
|
||||
@@ -30,23 +32,19 @@ pub struct Server {
|
||||
|
||||
impl Server {
|
||||
fn display_start_message(&self) {
|
||||
/*
|
||||
* Format the server address as a valid URL so that it becomes clickable in the CLI
|
||||
* @see https://github.com/tuono-labs/tuono/issues/460
|
||||
*/
|
||||
// Format the server address as a valid URL so that it becomes clickable in the CLI
|
||||
// @see https://github.com/tuono-labs/tuono/issues/460
|
||||
let server_base_url = format!("http://{}", self.address);
|
||||
|
||||
if self.mode == Mode::Dev {
|
||||
println!(" Ready at: {}\n", server_base_url.blue().bold());
|
||||
// In order to avoid multiple logs on `tuono dev`
|
||||
// the server address prompt for tuono dev is made on the CLI process
|
||||
if self.mode == Mode::Prod {
|
||||
tuono_println!("Production server at: {}\n", server_base_url.blue().bold());
|
||||
if let Some(origin) = &self.origin {
|
||||
tuono_println!("Origin: {}\n", origin.blue().bold());
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" Production server at: {}\n",
|
||||
server_base_url.blue().bold()
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(origin) = &self.origin {
|
||||
println!(" Origin: {}\n", origin.blue().bold());
|
||||
tuono_println!("Ready\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +60,14 @@ impl Server {
|
||||
|
||||
let server_address = format!("{}:{}", config.server.host, config.server.port);
|
||||
|
||||
unsafe {
|
||||
// This function is unsafe because it modifies the OS env variables
|
||||
// which is not thread-safe.
|
||||
// However, we are using it in a controlled environment which hasn't
|
||||
// spawned any threads yet.
|
||||
load_env_vars(mode);
|
||||
}
|
||||
|
||||
Server {
|
||||
router,
|
||||
mode,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use colored::Colorize;
|
||||
use http::{method::Method, Request, Response};
|
||||
use http::{Request, Response, method::Method};
|
||||
use pin_project::pin_project;
|
||||
use std::fmt::Debug;
|
||||
use std::future::Future;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod logger;
|
||||
+22
-11
@@ -1,4 +1,4 @@
|
||||
use crate::mode::{Mode, GLOBAL_MODE};
|
||||
use crate::mode::{GLOBAL_MODE, Mode};
|
||||
use ssr_rs::{Ssr, SsrError};
|
||||
use std::cell::RefCell;
|
||||
use std::fs::read_to_string;
|
||||
@@ -36,10 +36,10 @@ struct ProdJs;
|
||||
impl ProdJs {
|
||||
thread_local! {
|
||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), ""
|
||||
).unwrap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,22 @@ struct DevJs;
|
||||
|
||||
impl DevJs {
|
||||
pub fn render_to_string(params: Option<&str>) -> Result<String, SsrError> {
|
||||
Ssr::from(
|
||||
read_to_string(PathBuf::from(DEV_BUNDLE_PATH)).expect("Server bundle not found"),
|
||||
"",
|
||||
)
|
||||
.unwrap()
|
||||
.render_to_string(params)
|
||||
let bundle_path = read_to_string(PathBuf::from(DEV_BUNDLE_PATH));
|
||||
|
||||
if let Ok(source) = bundle_path {
|
||||
let ssr = Ssr::from(source, "");
|
||||
if let Ok(mut ssr) = ssr {
|
||||
ssr.render_to_string(params)
|
||||
} else {
|
||||
let fallback_html = read_to_string(PathBuf::from("./.tuono/index.html"))
|
||||
.unwrap_or("Fallback HTML not loaded".to_string());
|
||||
Ok(fallback_html.replace("[SERVER_PAYLOAD]", params.unwrap_or("")))
|
||||
}
|
||||
} else {
|
||||
let fallback_html = read_to_string(PathBuf::from("./.tuono/index.html"))
|
||||
.unwrap_or("Fallback HTML not loaded".to_string());
|
||||
|
||||
Ok(fallback_html.replace("[SERVER_PAYLOAD]", params.unwrap_or("")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use crate::config::GLOBAL_CONFIG;
|
||||
use axum::body::Body;
|
||||
use axum::extract::Path;
|
||||
use axum::extract::{Path, Query};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use reqwest::Client;
|
||||
|
||||
pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
|
||||
pub async fn vite_reverse_proxy(
|
||||
Path(path): Path<String>,
|
||||
query: Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let client = Client::new();
|
||||
|
||||
let config = GLOBAL_CONFIG
|
||||
@@ -19,7 +23,24 @@ pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
|
||||
config.server.port + 1
|
||||
);
|
||||
|
||||
match client.get(format!("{vite_url}/{path}")).send().await {
|
||||
let query_string = query
|
||||
.0
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let query_string = if query_string.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{}", query_string)
|
||||
};
|
||||
|
||||
match client
|
||||
.get(format!("{vite_url}/{path}{query_string}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
let mut response_builder = Response::builder().status(res.status().as_u16());
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use axum::response::IntoResponse;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::{Error, Message};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::ClientRequestBuilder;
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
|
||||
const VITE_WS_PROTOCOL: &str = "vite-hmr";
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod utils;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::utils::mock_server::MockTuonoServer;
|
||||
use serial_test::serial;
|
||||
|
||||
@@ -71,11 +73,13 @@ async fn index_html_route() {
|
||||
|
||||
// TODO: This should return a 404 status code
|
||||
assert!(response.status().is_success());
|
||||
assert!(response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.starts_with("<!DOCTYPE html>"));
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.starts_with("<!DOCTYPE html>")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -147,3 +151,75 @@ async fn it_reads_the_path_parameter() {
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "url_parameter");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_reads_an_env_var() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.get(format!("{server_url}/env"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "foobar");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_parses_the_http_body() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let response = client
|
||||
.post(format!("{server_url}/api/post"))
|
||||
.body(r#"{"data":"payload"}"#)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "payload");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn it_parses_the_form_encoded_url() {
|
||||
let app = MockTuonoServer::spawn().await;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let server_url = format!("http://{}:{}", &app.address, &app.port);
|
||||
|
||||
let mut form_params = HashMap::new();
|
||||
form_params.insert("data", "payload");
|
||||
|
||||
let response = client
|
||||
.post(format!("{server_url}/api/form_data"))
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert_eq!(response.text().await.unwrap(), "payload");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
use std::env;
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[tuono_lib::api(GET)]
|
||||
pub async fn test_env(_req: Request) -> String {
|
||||
env::var("MY_TEST_KEY").unwrap_or("error".parse().unwrap())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde::Deserialize;
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::api(POST)]
|
||||
async fn form_data(req: Request) -> String {
|
||||
let form = req.form_data::<Payload>().unwrap();
|
||||
form.data
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use tuono_lib::axum::http::StatusCode;
|
||||
use tuono_lib::Request;
|
||||
use tuono_lib::axum::http::StatusCode;
|
||||
|
||||
#[tuono_lib::api(GET)]
|
||||
async fn health_check(_req: Request) -> StatusCode {
|
||||
|
||||
@@ -3,13 +3,16 @@ use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::{env, fs};
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use tuono_lib::axum::routing::get;
|
||||
use tuono_lib::{axum::Router, tuono_internal_init_v8_platform, Mode, Server};
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tuono_lib::axum::routing::{get, post};
|
||||
use tuono_lib::{Mode, Server, axum::Router, tuono_internal_init_v8_platform};
|
||||
|
||||
use crate::utils::catch_all::get_tuono_internal_api as catch_all;
|
||||
use crate::utils::dynamic_parameter::get_tuono_internal_api as dynamic_parameter;
|
||||
use crate::utils::env::get_tuono_internal_api as test_env;
|
||||
use crate::utils::form_data::post_tuono_internal_api as form_data_api;
|
||||
use crate::utils::health_check::get_tuono_internal_api as health_check;
|
||||
use crate::utils::post_api::post_tuono_internal_api as post_api;
|
||||
use crate::utils::route as html_route;
|
||||
use crate::utils::route::tuono_internal_api as route_api;
|
||||
|
||||
@@ -74,13 +77,18 @@ impl MockTuonoServer {
|
||||
r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#,
|
||||
);
|
||||
|
||||
add_file_with_content("./.env", r#"MY_TEST_KEY="foobar""#);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/", get(html_route::tuono_internal_route))
|
||||
.route("/tuono/data", get(html_route::tuono_internal_api))
|
||||
.route("/health_check", get(health_check))
|
||||
.route("/route-api", get(route_api))
|
||||
.route("/catch_all/{*catch_all}", get(catch_all))
|
||||
.route("/dynamic/{parameter}", get(dynamic_parameter));
|
||||
.route("/dynamic/{parameter}", get(dynamic_parameter))
|
||||
.route("/api/post", post(post_api))
|
||||
.route("/api/form_data", post(form_data_api))
|
||||
.route("/env", get(test_env));
|
||||
|
||||
let server = Server::init(router, Mode::Prod).await;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod catch_all;
|
||||
pub mod dynamic_parameter;
|
||||
pub mod env;
|
||||
pub mod form_data;
|
||||
pub mod health_check;
|
||||
pub mod mock_server;
|
||||
pub mod post_api;
|
||||
pub mod route;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde::Deserialize;
|
||||
use tuono_lib::Request;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[tuono_lib::api(POST)]
|
||||
async fn health_check(req: Request) -> String {
|
||||
req.body::<Payload>().unwrap().data
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.17.10"
|
||||
edition = "2021"
|
||||
version = "0.19.6"
|
||||
edition = "2024"
|
||||
description = "Superfast React fullstack framework"
|
||||
homepage = "https://tuono.dev"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
@@ -10,7 +10,7 @@ readme = "../../README.md"
|
||||
license-file = "../../LICENSE.md"
|
||||
categories = ["web-programming"]
|
||||
include = [
|
||||
"src/*.rs",
|
||||
"src/**/*.rs",
|
||||
"Cargo.toml"
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use proc_macro::{Span, TokenStream};
|
||||
use quote::quote;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{parse_macro_input, FnArg, Ident, ItemFn, Pat};
|
||||
use syn::{FnArg, Ident, ItemFn, Pat, parse_macro_input};
|
||||
|
||||
pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let item = parse_macro_input!(item as ItemFn);
|
||||
@@ -48,6 +48,28 @@ pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let application_state_extractor = crate_application_state_extractor(argument_names.clone());
|
||||
let application_state_import = import_main_application_state(argument_names.clone());
|
||||
|
||||
let modified_request = if http_method == "post"
|
||||
|| http_method == "put"
|
||||
|| http_method == "patch"
|
||||
{
|
||||
quote! {
|
||||
let (parts, body) = request.into_parts();
|
||||
let path = parts.uri.clone();
|
||||
let headers = parts.headers.clone();
|
||||
|
||||
let body = tuono_lib::axum::body::to_bytes(body, usize::MAX).await.unwrap_or(Vec::new().into()).to_vec();
|
||||
|
||||
let req = tuono_lib::Request::new(path, headers, params, Some(body));
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(request.uri().to_owned(), request.headers().to_owned(), params, None);
|
||||
}
|
||||
};
|
||||
|
||||
quote! {
|
||||
#application_state_import
|
||||
|
||||
@@ -55,12 +77,9 @@ pub fn api_core(attrs: TokenStream, item: TokenStream) -> TokenStream {
|
||||
|
||||
pub async fn #api_fn_name(#axum_arguments)#return_type {
|
||||
|
||||
#application_state_extractor
|
||||
#application_state_extractor
|
||||
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
#modified_request
|
||||
|
||||
#fn_name(req.clone(), #argument_names).await
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{parse_macro_input, FnArg, ItemFn, Pat};
|
||||
use syn::{FnArg, ItemFn, Pat, parse_macro_input};
|
||||
|
||||
pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let item = parse_macro_input!(item as ItemFn);
|
||||
@@ -54,7 +54,7 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params, None);
|
||||
|
||||
#fn_name(req.clone(), #argument_names).await.render_to_string(req)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let pathname = request.uri();
|
||||
let headers = request.headers();
|
||||
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
|
||||
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params, None);
|
||||
|
||||
#fn_name(req.clone(), #argument_names).await.json()
|
||||
}
|
||||
|
||||
@@ -19,3 +19,13 @@ pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
pub fn api(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||
api::api_core(args, item)
|
||||
}
|
||||
|
||||
/// Automatically generate typescript's types
|
||||
/// from Rust's structs, types and enums.
|
||||
///
|
||||
/// The types will be exported on the client side
|
||||
/// and it will be available from the `"tuono/types"` module.
|
||||
#[proc_macro_derive(Type)]
|
||||
pub fn derive_typescript_type(_: TokenStream) -> TokenStream {
|
||||
TokenStream::new()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use quote::quote;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::token::Comma;
|
||||
use syn::{parse2, parse_quote, FnArg, Pat, Stmt};
|
||||
use syn::{FnArg, Pat, Stmt, parse_quote, parse2};
|
||||
|
||||
pub fn create_struct_fn_arg() -> FnArg {
|
||||
parse2(quote! {
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"oxc-transform": "0.52.0",
|
||||
"oxc-transform": "0.63.0",
|
||||
"rollup-plugin-preserve-directives": "0.4.0",
|
||||
"unplugin-isolated-decl": "0.11.2",
|
||||
"vite": "6.1.1",
|
||||
"unplugin-isolated-decl": "0.13.6",
|
||||
"vite": "6.1.3",
|
||||
"vite-plugin-externalize-deps": "0.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::Serialize;
|
||||
use tuono_lib::{Props, Request, Response};
|
||||
use tuono_lib::{Props, Request, Response, Type};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Type)]
|
||||
struct MyResponse<'a> {
|
||||
subtitle: &'a str,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import type { TuonoRouteProps } from 'tuono'
|
||||
import { Link } from 'tuono'
|
||||
|
||||
interface IndexProps {
|
||||
subtitle: string
|
||||
}
|
||||
import type { MyResponse } from 'tuono/types'
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
isLoading,
|
||||
}: TuonoProps<IndexProps>): JSX.Element {
|
||||
}: TuonoRouteProps<MyResponse>): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <h1>Loading...</h1>
|
||||
}
|
||||
@@ -17,8 +14,8 @@ export default function IndexPage({
|
||||
return (
|
||||
<>
|
||||
<h1>TUONO</h1>
|
||||
<h2>{data?.subtitle}</h2>
|
||||
<Link href={'/second-route'}>Routing link</Link>)
|
||||
<h2>{data.subtitle}</h2>
|
||||
<Link href={'/second-route'}>Routing link</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
+31
-4
@@ -5,9 +5,19 @@ import eslintPluginImport from 'eslint-plugin-import'
|
||||
import eslintPluginReact from 'eslint-plugin-react'
|
||||
// @ts-expect-error no types are available for this plugin
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import eslintPluginVitest from '@vitest/eslint-plugin'
|
||||
|
||||
const REACT_FILES_MATCH =
|
||||
'packages/{tuono,tuono-router}/**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'
|
||||
const JS_EXTENSIONS_MATCH = [
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'jsx',
|
||||
'mjsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'mtsx',
|
||||
].join(',')
|
||||
const REACT_FILES_MATCH = `packages/{tuono,tuono-router}/**/*.{${JS_EXTENSIONS_MATCH}}`
|
||||
|
||||
/** @type import('typescript-eslint').ConfigArray */
|
||||
const tuonoEslintConfig = tseslint.config(
|
||||
@@ -21,7 +31,7 @@ const tuonoEslintConfig = tseslint.config(
|
||||
// #endregion shared
|
||||
|
||||
// #region package-specific
|
||||
'packages/tuono-fs-router-vite-plugin/tests/generator/**',
|
||||
'packages/tuono-react-vite-plugin/tests/generator/**',
|
||||
|
||||
'packages/tuono-lazy-fn-vite-plugin/tests/sources/**',
|
||||
|
||||
@@ -59,7 +69,6 @@ const tuonoEslintConfig = tseslint.config(
|
||||
{
|
||||
files: [REACT_FILES_MATCH],
|
||||
plugins: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
'react-hooks': eslintPluginReactHooks,
|
||||
},
|
||||
rules: {
|
||||
@@ -172,6 +181,24 @@ const tuonoEslintConfig = tseslint.config(
|
||||
// #endregion misc
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: [`packages/**/*.spec.{${JS_EXTENSIONS_MATCH}}`],
|
||||
plugins: { vitest: eslintPluginVitest },
|
||||
rules: {
|
||||
...eslintPluginVitest.configs.recommended.rules,
|
||||
'vitest/consistent-test-filename': [
|
||||
'error',
|
||||
{
|
||||
pattern: '.*\\.spec\\.[tj]sx?$',
|
||||
allTestPattern: '.*\\.(test|spec)\\.[tj]sx?$',
|
||||
},
|
||||
],
|
||||
'vitest/consistent-test-it': 'error',
|
||||
'vitest/max-expects': 'error',
|
||||
'vitest/max-nested-describe': ['error', { max: 3 }],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export default tuonoEslintConfig
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ tuono new [NAME]
|
||||
You can install any example included in this folder by just using the `--template` flag:
|
||||
|
||||
```sh
|
||||
tuono new [NAME] --template [TEMPLATE]
|
||||
tuono new [NAME] --template [TEMPLATE]
|
||||
```
|
||||
|
||||
`[TEMPLATE]` is the folder name.
|
||||
|
||||
@@ -11,3 +11,6 @@ node_modules
|
||||
.tuono
|
||||
out
|
||||
target
|
||||
|
||||
# Ignore local env files
|
||||
.env*.local
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "tuono-app"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "tuono"
|
||||
|
||||
@@ -5,5 +5,3 @@ This is the starter tuono project. To download it run in your terminal:
|
||||
```sh
|
||||
tuono new my-first-tuono-app
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode, JSX } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { TuonoScripts } from 'tuono'
|
||||
import type { TuonoLayoutProps } from 'tuono'
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
import '../styles/global.css'
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: TuonoLayoutProps): JSX.Element {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import type { TuonoRouteProps } from 'tuono'
|
||||
|
||||
interface IndexProps {
|
||||
subtitle: string
|
||||
@@ -8,7 +8,7 @@ interface IndexProps {
|
||||
export default function IndexPage({
|
||||
data,
|
||||
isLoading,
|
||||
}: TuonoProps<IndexProps>): JSX.Element {
|
||||
}: TuonoRouteProps<IndexProps>): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <h1>Loading...</h1>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["tuono/build-client"],
|
||||
|
||||
// Interop Constraints
|
||||
"isolatedModules": true,
|
||||
@@ -27,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -11,3 +11,6 @@ node_modules
|
||||
.tuono
|
||||
out
|
||||
target
|
||||
|
||||
# Ignore local env files
|
||||
.env*.local
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "tuono-tutorial"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "tuono"
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
border-radius: 18px;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
|
||||
padding: 16px 32px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -10,13 +14,17 @@
|
||||
}
|
||||
|
||||
.pokemon img {
|
||||
width: 400px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.spec {
|
||||
display: flex;
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
|
||||
> dt {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode, JSX } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { TuonoScripts } from 'tuono'
|
||||
import type { TuonoLayoutProps } from 'tuono'
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
import '../styles/global.css'
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: TuonoLayoutProps): JSX.Element {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/routes/index.tsx
|
||||
import type { JSX } from 'react'
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import type { TuonoRouteProps } from 'tuono'
|
||||
|
||||
import PokemonLink from '../components/PokemonLink'
|
||||
|
||||
@@ -10,7 +10,7 @@ interface IndexProps {
|
||||
|
||||
export default function IndexPage({
|
||||
data,
|
||||
}: TuonoProps<IndexProps>): JSX.Element | null {
|
||||
}: TuonoRouteProps<IndexProps>): JSX.Element | null {
|
||||
if (!data?.results) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/routes/pokemons/[pokemon].tsx
|
||||
import type { JSX } from 'react'
|
||||
import type { TuonoProps } from 'tuono'
|
||||
import type { TuonoRouteProps } from 'tuono'
|
||||
import { Link } from 'tuono'
|
||||
|
||||
import PokemonView from '../../components/PokemonView'
|
||||
@@ -15,7 +15,7 @@ interface Pokemon {
|
||||
export default function PokemonPage({
|
||||
isLoading,
|
||||
data,
|
||||
}: TuonoProps<Pokemon>): JSX.Element {
|
||||
}: TuonoRouteProps<Pokemon>): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Link href="/">Back</Link>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["tuono/build-client"],
|
||||
|
||||
// Interop Constraints
|
||||
"isolatedModules": true,
|
||||
@@ -27,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TuonoConfig } from 'tuono/config'
|
||||
|
||||
const config: TuonoConfig = {
|
||||
vite: {
|
||||
alias: {
|
||||
'@': 'src',
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
alias: {
|
||||
'@': 'src',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -11,3 +11,6 @@ node_modules
|
||||
.tuono
|
||||
out
|
||||
target
|
||||
|
||||
# Ignore local env files
|
||||
.env*.local
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "with-mdx"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "tuono"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ReactNode, JSX } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
import { TuonoScripts } from 'tuono'
|
||||
import type { TuonoLayoutProps } from 'tuono'
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
import '../styles/global.css'
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: TuonoLayoutProps): JSX.Element {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["tuono/build-client"],
|
||||
|
||||
// Interop Constraints
|
||||
"isolatedModules": true,
|
||||
@@ -27,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
@@ -11,3 +11,6 @@ node_modules
|
||||
.tuono
|
||||
out
|
||||
target
|
||||
|
||||
# Ignore local env files
|
||||
.env*.local
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "with-tailwind"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "tuono"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode, JSX } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { TuonoScripts } from 'tuono'
|
||||
import type { TuonoLayoutProps } from 'tuono'
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
import '../styles/global.css'
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: TuonoLayoutProps): JSX.Element {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
|
||||
@@ -1 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tailwindcss' source('../..');
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["tuono/build-client"],
|
||||
|
||||
// Interop Constraints
|
||||
"isolatedModules": true,
|
||||
@@ -27,5 +28,5 @@
|
||||
// Completeness
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "tuono.config.ts"]
|
||||
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
|
||||
}
|
||||
|
||||
+22
-16
@@ -2,35 +2,41 @@
|
||||
"name": "workspace",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808",
|
||||
"scripts": {
|
||||
"dev": "turbo dev --filter=./devtools/* --filter=./packages/*",
|
||||
"build": "turbo build --filter=./devtools/* --filter=./packages/*",
|
||||
"lint": "turbo lint --filter=./devtools/* --filter=./packages/*",
|
||||
"format:fix": "turbo format --filter=./devtools/* --filter=./packages/*",
|
||||
"format:fix": "turbo format:fix --filter=./devtools/* --filter=./packages/*",
|
||||
"format": "turbo format --filter=./devtools/* --filter=./packages/*",
|
||||
"typecheck": "turbo typecheck --filter=./devtools/* --filter=./packages/*",
|
||||
"test": "turbo test --filter=./devtools/* --filter=./packages/*",
|
||||
"test:watch": "turbo test:watch --filter=./devtools/* --filter=./packages/*",
|
||||
"test:e2e": "pnpm --filter='fixture-*' run test:e2e",
|
||||
"repo:root:format": "prettier . !./assets/** !./crates !./examples !./devtools/** !./packages/** --check",
|
||||
"repo:root:format:fix": "prettier . !./assets/** !./crates !./examples !./devtools/** !./packages/** --write",
|
||||
"check-all": "turbo build lint format typecheck --filter=!./examples"
|
||||
"test:e2e": "pnpm --filter \"./e2e/fixtures/*\" run test:e2e",
|
||||
"examples:format": "prettier ./examples/** --check",
|
||||
"examples:format:fix": "prettier ./examples/** --write",
|
||||
"examples:typecheck": "pnpm --filter \"./examples/*\" exec tsc --noEmit",
|
||||
"repo:root:format": "prettier . !./assets !./crates !./examples !./devtools !./packages --check",
|
||||
"repo:root:format:fix": "prettier . !./assets !./crates !./examples !./devtools !./packages --write",
|
||||
"check-packages": "turbo build test lint format typecheck --filter=!./examples",
|
||||
"check-examples": "pnpm run \"/^examples:(format|typecheck)$/\"",
|
||||
"check-all": "pnpm run check-packages && pnpm run check-examples && pnpm run repo:root:format"
|
||||
},
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.20.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@types/node": "22.13.5",
|
||||
"eslint": "9.20.0",
|
||||
"eslint-import-resolver-typescript": "3.7.0",
|
||||
"@eslint/js": "9.24.0",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@types/node": "22.14.1",
|
||||
"@vitest/eslint-plugin": "1.1.42",
|
||||
"eslint": "9.24.0",
|
||||
"eslint-import-resolver-typescript": "4.3.2",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-react": "7.37.4",
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"prettier": "3.5.2",
|
||||
"turbo": "2.4.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"prettier": "3.5.3",
|
||||
"turbo": "2.5.0",
|
||||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "8.24.0"
|
||||
"typescript-eslint": "8.29.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { normalize } from 'node:path'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
import { routeGenerator } from './generator'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
|
||||
let lock = false
|
||||
|
||||
export function TuonoFsRouterPlugin(): Plugin {
|
||||
const generate = async (): Promise<void> => {
|
||||
if (lock) return
|
||||
lock = true
|
||||
|
||||
try {
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file: string): Promise<void> => {
|
||||
const filePath = normalize(file)
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router',
|
||||
configResolved: async (): Promise<void> => {
|
||||
await generate()
|
||||
},
|
||||
watchChange: async (
|
||||
file: string,
|
||||
context: { event: string },
|
||||
): Promise<void> => {
|
||||
if (['create', 'update', 'delete'].includes(context.event)) {
|
||||
await handleFile(file)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono-fs-router-vite-plugin",
|
||||
"version": "0.17.10",
|
||||
"name": "tuono-react-vite-plugin",
|
||||
"version": "0.19.6",
|
||||
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
||||
"homepage": "https://tuono.dev",
|
||||
"scripts": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tuono-labs/tuono.git",
|
||||
"directory": "packages/tuono-fs-router-vite-plugin"
|
||||
"directory": "packages/tuono-react-vite-plugin"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Valerio Ageno",
|
||||
@@ -46,6 +46,6 @@
|
||||
"devDependencies": {
|
||||
"@types/babel__core": "7.20.5",
|
||||
"vite-config": "workspace:*",
|
||||
"vitest": "3.0.7"
|
||||
"vitest": "3.1.1"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user