Compare commits

..

102 Commits

Author SHA1 Message Date
Valerio Ageno 07351587f3 chore: bump version (0.19.6) (#771) 2025-05-11 19:11:40 +02:00
Valerio Ageno 3027b828a8 fix: improve critical css generation (#770) 2025-05-11 19:11:30 +02:00
Valerio Ageno eef2cd26e0 chore: bump ssr_rs (0.8.3) (#769) 2025-05-10 20:15:13 +02:00
Valerio Ageno 8190915e3a chore: bump version (0.19.5) (#768) 2025-05-09 17:02:38 +02:00
Valerio Ageno dd157d6ef9 feat: load critical CSS on development (#765) 2025-05-09 17:02:18 +02:00
Valerio Ageno 58f4ecb260 fix: tailwindcss template (#767) 2025-05-08 20:52:42 +02:00
Valerio Ageno ab441d1fa7 chore: dedupe pnpm dependencies (#766) 2025-05-07 20:47:26 +02:00
renovate[bot] 7538031261 chore(deps): update dependency typescript to v5.8.3 (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 10:23:37 +02:00
renovate[bot] 18f66ff79b chore(deps): update react monorepo to v19.1.3 (#746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 10:23:24 +02:00
Valerio Ageno b48f7244b7 refactor: turn tuono-fs-router-vite-plugin into tuono-react-vite-plugin (#756) 2025-04-28 20:54:40 +02:00
Valerio Ageno 22844a37fa chore: bump version (v0.19.4) (#748) 2025-04-20 11:05:30 +02:00
Valerio Ageno bc444929ed feat: add automatic types generation (#703) 2025-04-20 11:04:48 +02:00
renovate[bot] 0813c14e15 fix(deps): update babel monorepo (#750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 15:25:40 +02:00
renovate[bot] 5331cc0c8c chore(deps): update dependency typescript to v5.8.3 (#749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 15:11:38 +02:00
renovate[bot] ba89a7e388 fix(deps): update babel monorepo (#747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 14:59:19 +02:00
renovate[bot] 90a4d20436 chore(deps): update dependency oxc-transform to v0.63.0 (#744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 12:01:15 +02:00
renovate[bot] d938e82998 chore(deps): update dependency typescript to v5.8.3 (#745)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 12:01:02 +02:00
renovate[bot] fc15c01c90 chore(deps): update dependency typescript-eslint to v8.29.1 (#742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:50:09 +02:00
renovate[bot] ffdf0f91ac chore(deps): update react monorepo (#743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:42:45 +02:00
renovate[bot] fa6b2f4c89 chore(deps): update dependency @vitest/eslint-plugin to v1.1.42 (#741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:18:20 +02:00
renovate[bot] 245b5c535b chore(deps): update dependency @types/node to v22.14.1 (#740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 09:04:36 +02:00
Valerio Ageno 2bb0bdb2b0 chore: bump version (v0.19.3) (#732) 2025-04-10 21:50:59 +02:00
Valerio Ageno e368cc5e23 fix: old data kept on new route during transition (#730)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
2025-04-10 21:50:16 +02:00
Valerio Ageno eb4966e88e chore: dedupe dependencies (#734) 2025-04-09 10:05:36 +02:00
renovate[bot] 64c4c1174c fix(deps): update babel monorepo (#542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-09 09:48:36 +02:00
renovate[bot] 315225184d fix(deps): update dependency vite to v6.1.4 [security] (#705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-09 09:48:20 +02:00
Valerio Ageno 39fcc33600 fix: format:fix turbo command (#731) 2025-04-09 09:24:20 +02:00
Valerio Ageno 1adf380798 fix: prevent multiple server address print (#713) 2025-04-09 08:49:01 +02:00
renovate[bot] ac20a83e7e fix(deps): update rust crate colored to v3 (#694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-09 08:36:43 +02:00
Jacob Marshall b17521063a feat: init new git repo for new projects (#558) 2025-04-08 18:03:42 +02:00
renovate[bot] f449c4537e chore(deps): update dependency typescript to v5.8.3 (#722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 09:25:27 +02:00
renovate[bot] 3c413c38bd chore(deps): update dependency eslint-import-resolver-typescript to v4 (#721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 09:04:00 +02:00
renovate[bot] 750e4b6f22 chore(deps): update devdependencies (eslint) (#720)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:53:51 +02:00
renovate[bot] e6e455e966 chore(deps): update dependency turbo to v2.5.0 (#718)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:51:11 +02:00
renovate[bot] 54de22d8c4 chore(deps): update dependency typescript to v5.8.3 (#719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:44:38 +02:00
renovate[bot] 40005c4e02 chore(deps): update dependency @types/node to v22.14.0 (#717)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:33:14 +02:00
renovate[bot] 32a76a2560 chore(deps): update dependency @testing-library/react to v16.3.0 (#716)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:31:42 +02:00
Marco Pasqualetti f3cf9b05ae refactor: refine ServerPayload type using a union with mode as discriminant (#701) 2025-04-07 08:21:25 +02:00
renovate[bot] c946c43462 chore(deps): update dependency unplugin-isolated-decl to v0.13.6 (#715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:18:58 +02:00
renovate[bot] 2ecfb0d5c0 chore(deps): update dependency @vitest/eslint-plugin to v1.1.39 (#714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 08:18:46 +02:00
Valerio Ageno 6b02c40ad4 chore: bump version (v0.19.2) (#704) 2025-04-04 08:25:20 +02:00
kulapoo 3334bddeb2 feat: add form_data method into request (#651) 2025-04-03 17:23:01 +02:00
Marco Pasqualetti 0335c8c1ad feat: add TuonoLayoutProps interface (#702) 2025-04-03 08:53:20 +02:00
Marco Pasqualetti 0fa83fb1a2 refactor: create more types to simplify TuonoConfig management (#699) 2025-04-03 08:53:06 +02:00
Marco Pasqualetti b24466fb11 ci: simplify CI OK jobs (#698) 2025-04-03 07:19:18 +02:00
Marco Pasqualetti c0b4fa8717 chore: update CODEOWNERS (#696) 2025-04-02 18:24:46 +02:00
Marco Pasqualetti 16b8125206 chore(deps-dev): dedupe (#695) 2025-04-02 18:24:31 +02:00
renovate[bot] 8ce1292960 fix(deps): update react monorepo (#693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:52:51 +02:00
renovate[bot] dbc205a09c chore(deps): update dependency @vitest/eslint-plugin to v1.1.38 (#687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:52:15 +02:00
renovate[bot] 0e5fcd4b57 chore(deps): update dependency typescript to v5.8.2 (#690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:41:10 +02:00
renovate[bot] 42b7a22a29 chore(deps): update dependency unplugin-isolated-decl to v0.13.5 (#691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:40:57 +02:00
renovate[bot] 13983a5362 chore(deps): update pnpm to v10.7.1 (#688)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:26:45 +02:00
renovate[bot] 557f412e27 chore(deps): update dependency oxc-transform to v0.62.0 (#689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:26:30 +02:00
renovate[bot] f8f21b69b6 chore(deps): update dependency vitest to v3.1.1 (#692)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:26:12 +02:00
renovate[bot] 7b656ee961 chore(deps): update dependency @types/node to v22.13.17 (#684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 17:07:24 +02:00
renovate[bot] c044ab45a7 chore(deps): update dependency @vitejs/plugin-react-swc to v3.8.1 (#685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 16:40:38 +02:00
renovate[bot] eda328f861 chore(deps): update dependency @types/babel__traverse to v7.20.7 (#683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 16:29:18 +02:00
renovate[bot] f75fce87cf fix(deps): update dependency vite to v6.1.3 [security] (#686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 16:28:48 +02:00
Valerio Ageno 7c0999a5fe refactor: tuono dev script (#681) 2025-04-01 18:20:38 +02:00
Marco Pasqualetti a7b5e92c46 test(e2e): run tests on Windows jobs (#682) 2025-03-31 08:35:03 +02:00
Valerio Ageno 4d91bc0a72 chore: bump version (v0.19.1) (#679) 2025-03-30 17:26:33 +02:00
Valerio Ageno 3b3f601ff1 refactor: SourceBuilder (#678) 2025-03-30 14:11:32 +02:00
Valerio Ageno abb12e7b3d refactor: move static string into templates/ folder (#676) 2025-03-29 14:49:47 +01:00
renovate[bot] 66c3d91dfa chore(deps): update dependency happy-dom to v17.4.4 (#663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-29 11:17:14 +01:00
Valerio Ageno 53ec3ae280 fix: body parsing function (#670) 2025-03-29 11:15:45 +01:00
Valerio Ageno f293a1dee8 chore: update examples rust edition (#675) 2025-03-29 11:08:16 +01:00
Valerio Ageno a786f11d28 chore: update cargo edition to 2024 (#673) 2025-03-29 10:50:58 +01:00
Valerio Ageno 604581c64a chore: update ssr_rs to v0.8.2 (#672) 2025-03-27 19:59:39 +01:00
Valerio Ageno ae6ece7415 chore: dedupe lockfile (#667) 2025-03-26 19:15:38 +01:00
renovate[bot] b0a7557d83 chore(deps): update dependency oxc-transform to v0.61.2 (#664)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 19:06:42 +01:00
renovate[bot] fde01421dc chore(deps): update dependency @playwright/test to v1.51.1 (#662)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 19:06:20 +01:00
renovate[bot] 497532e6fe fix(deps): update dependency prettier to v3.5.3 (#661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 19:06:09 +01:00
renovate[bot] 724ac83429 chore(deps): update pnpm to v10.7.0 (#666)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 19:05:54 +01:00
renovate[bot] c7ca9ca8b4 fix(deps): update dependency react-intersection-observer to v9.16.0 (#659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:48:45 +01:00
renovate[bot] 7934a7cbab chore(deps): update rust crate serial_test to v3 (#660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:48:32 +01:00
renovate[bot] 35b1de5d22 chore(deps): update dependency typescript to v5.8.2 (#657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:44:24 +01:00
renovate[bot] 2cfad3de12 fix(deps): update dependency vite to v6.1.2 [security] (#656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:44:10 +01:00
renovate[bot] 78a742c4c8 chore(deps): update dependency @types/node to v22.13.13 (#639)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:32:38 +01:00
renovate[bot] 57f0c9fa20 chore(deps): update dependency prettier to v3.5.3 (#640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:31:41 +01:00
renovate[bot] 8dde08892c chore(deps): update dependency turbo to v2.4.4 (#642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:22:15 +01:00
renovate[bot] da5fac0af2 chore(deps): update dependency vitest to v3.0.9 (#643)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 18:21:56 +01:00
Valerio Ageno 12aa12c94d refactor: source builder (#655) 2025-03-26 18:20:29 +01:00
Valerio Ageno 6dbdafa888 chore: bump version to v0.19.0 (#653) 2025-03-23 14:32:43 +01:00
Valerio Ageno 97082840f8 feat: remove global.css import to enable global CSS file customizations (#650)
Co-authored-by: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com>
2025-03-20 16:41:41 +01:00
Marco Pasqualetti d562aa87a3 feat(packages/tuono): improve TuonoRouteProps type (#648) 2025-03-18 09:07:38 +01:00
Valerio Ageno 2301083edc fix: allow 'index' keyword in routes name (#647) 2025-03-15 18:16:54 +01:00
Valerio Ageno 1c45b29ed1 fix: prevent returning the previous route data on route transition (#646)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
2025-03-15 11:42:34 +01:00
Marco Pasqualetti bfd0041c6a test(packages/tuono-router): improve test using mocks (#641) 2025-03-11 06:46:58 +01:00
Valerio Ageno 9f14689ca0 refactor: support crates sub-modules (#636) 2025-03-09 17:58:45 +01:00
Marco Pasqualetti b32b192f70 chore(package): add missing test script to check-packages (#637) 2025-03-09 17:18:38 +01:00
Valerio Ageno 486a254a1e chore: update version to v0.18.0 (#627) 2025-03-09 13:14:39 +01:00
Marco Pasqualetti 67de777fd1 feat: Implement .env file support (#618)
Co-authored-by: Jacob Marshall <me@jhqcat.com>
2025-03-09 12:00:12 +01:00
Valerio Ageno 8fcebae4a9 feat: add __layout wrapper to default 404 page (#633) 2025-03-09 11:58:25 +01:00
Valerio Ageno 77c902a7a5 feat: display error overlay (#607)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
2025-03-09 11:32:05 +01:00
Marco Pasqualetti da194524ee chore: add @vitest/eslint-plugin (#630) 2025-03-08 21:11:28 +01:00
renovate[bot] 81e7af6f5e chore(deps): update react monorepo (#574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-08 11:42:36 +01:00
Marco Pasqualetti fd2357cedc refactor(packages/tuono-router): use named imports (#629) 2025-03-08 10:48:36 +01:00
Nicolas Gasco 9db6f02701 feat(examples/tuono-tutorial): improve styles of PokemonView component (#628)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
2025-03-08 09:59:31 +01:00
Marco Pasqualetti 9b6f8098b6 ci: add checks on examples (#605) 2025-03-05 19:00:08 +01:00
Marco Pasqualetti a322ce2fad chore(deps): update pnpm to v10 (#623) 2025-03-05 04:53:10 +01:00
renovate[bot] 73b7a8f056 chore(deps): update node.js to v22.14.0 (#620)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-05 04:38:40 +01:00
renovate[bot] 0e6546d4d0 chore(deps): update dependency happy-dom to v17.2.2 (#621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-05 04:31:08 +01:00
181 changed files with 5334 additions and 1715 deletions
+5 -13
View File
@@ -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')) }}
+107
View File
@@ -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')) }}
+5 -13
View File
@@ -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')) }}
+6 -13
View File
@@ -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')) }}
+6 -13
View File
@@ -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')) }}
+1 -1
View File
@@ -1 +1 @@
22.13.1
22.14.0
+1
View File
@@ -2,6 +2,7 @@ pnpm-lock.yaml
dist
.tuono
out
vite.config.ts.timestamp-*
+1 -8
View File
@@ -1,9 +1,2 @@
* @Valerioageno @marcalexiei
# Rust
/crates/ @Valerioageno
/Cargo.toml @Valerioageno
# Misc
/.github/ @marcalexiei
* @Valerioageno
+13 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+201
View File
@@ -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(())
}
+3
View File
@@ -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 -3
View File
@@ -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;
+14 -1
View File
@@ -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);
}
}
+140
View File
@@ -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(),
}))
}
+21 -5
View File
@@ -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
View File
@@ -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"));
}
}
+29
View File
@@ -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
}
}
+99
View File
@@ -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"]);
}
}
+8
View File
@@ -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"
);
}
}
+217
View File
@@ -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,
}
}
}
+259
View File
@@ -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);
}
}
+71
View File
@@ -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));
}
}
-145
View File
@@ -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(())
}
+9
View File
@@ -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)
+19
View File
@@ -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>
+29
View File
@@ -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
}
+7
View File
@@ -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)
+20 -4
View File
@@ -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 {
+4 -4
View File
@@ -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"
+25 -1
View File
@@ -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
View File
@@ -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)*));
}};
}
+1 -1
View File
@@ -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 {
+8 -8
View File
@@ -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"
+2 -2
View File
@@ -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();
+301
View File
@@ -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");
}
}
+4 -3
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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());
}
}
+4 -4
View File
@@ -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,
+23 -17
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
pub mod logger;
+22 -11
View File
@@ -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("")))
}
}
}
+24 -3
View File
@@ -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());
+1 -1
View File
@@ -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";
+81 -5
View File
@@ -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");
}
+7
View File
@@ -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())
}
+13
View File
@@ -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 -1
View File
@@ -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 {
+12 -4
View File
@@ -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;
+3
View File
@@ -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;
+12
View File
@@ -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
}
+3 -3
View File
@@ -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"
]
+25 -6
View File
@@ -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
}
+3 -3
View File
@@ -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()
}
+10
View File
@@ -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 -1
View File
@@ -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! {
+3 -3
View File
@@ -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"
}
}
+2 -2
View File
@@ -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,
}
+5 -8
View File
@@ -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>
</>
)
}
+1 -1
View File
@@ -20,5 +20,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "tuono.config.ts"]
"include": ["src", "tuono.config.ts", "./.tuono/types.ts"]
}
+31 -4
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -11,3 +11,6 @@ node_modules
.tuono
out
target
# Ignore local env files
.env*.local
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "tuono-app"
version = "0.0.1"
edition = "2021"
edition = "2024"
[[bin]]
name = "tuono"
-2
View File
@@ -5,5 +5,3 @@ This is the starter tuono project. To download it run in your terminal:
```sh
tuono new my-first-tuono-app
```
+6 -5
View File
@@ -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>
+2 -2
View File
@@ -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>
}
+2 -1
View File
@@ -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"]
}
+3
View File
@@ -11,3 +11,6 @@ node_modules
.tuono
out
target
# Ignore local env files
.env*.local
+1 -1
View File
@@ -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 {
-4
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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"]
}
+5 -5
View File
@@ -1,11 +1,11 @@
import type { TuonoConfig } from 'tuono/config'
const config: TuonoConfig = {
vite: {
alias: {
'@': 'src',
},
},
vite: {
alias: {
'@': 'src',
},
},
}
export default config
+3
View File
@@ -11,3 +11,6 @@ node_modules
.tuono
out
target
# Ignore local env files
.env*.local
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "with-mdx"
version = "0.0.1"
edition = "2021"
edition = "2024"
[[bin]]
name = "tuono"
+6 -5
View File
@@ -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>
+2 -1
View File
@@ -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"]
}
+3
View File
@@ -11,3 +11,6 @@ node_modules
.tuono
out
target
# Ignore local env files
.env*.local
+1 -1
View File
@@ -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
View File
@@ -1 +1 @@
@import 'tailwindcss';
@import 'tailwindcss' source('../..');
+2 -1
View File
@@ -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
View File
@@ -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)
}
},
}
}
@@ -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