Compare commits

...

42 Commits

Author SHA1 Message Date
Valerio Ageno 607da92c71 Make handler arguments optional (#87)
* feat: support axum extractors

* chore: update tuono example

* chore: update tutorial example

* doc: update documentation

* feat: update version to v0.12.0

* fix: use clippy suggestion
2024-11-11 21:52:22 +01:00
Henry Chen ae0427604e Fix tutorial link in README (#86) 2024-11-11 13:32:56 +01:00
Marco Pasqualetti 2aa981c581 chore: use GitHub forms in issue template (#85)
* chore: use GitHub forms in issue template

* Update .github/ISSUE_TEMPLATE/bug_report.yml

---------

Co-authored-by: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com>
2024-11-11 09:28:25 +01:00
Valerio Ageno 5d8ec82502 feat: add log to catch_all routes (#83) 2024-11-10 11:08:06 +01:00
Valerio Ageno fc9bb52c41 Update getting started (#82)
* feat: update getting started doc

* feat: update README
2024-11-10 10:44:36 +01:00
Valerio Ageno 3224e4138f feat: update doc hero 2024-11-10 09:52:51 +01:00
Valerio Ageno 7ea28c4fd3 fix: documentation typo 2024-11-10 09:50:47 +01:00
Valerio Ageno acb5567f6a fix: breadcrumbs structured data 2024-11-09 19:27:02 +01:00
Valerio Ageno cc877c7c11 fix: doc formatting 2024-11-06 21:11:57 +01:00
Valerio Ageno 87bdddf7cf feat: add twitter OG 2024-11-06 21:11:32 +01:00
Valerio Ageno d939b64d64 fix: documentation meta breadcrumb 2024-11-06 21:09:30 +01:00
Valerio Ageno 3f64f71947 feat: update documentation version to v0.11.2 (#81)
Co-authored-by: Valerio Ageno <valerio.ageno@qonto.com>
2024-11-05 12:13:30 +01:00
Valerio Ageno aaae7db24d Improve route preloading (#80)
* feat: improve route preloading

* feat: update version to v0.11.2

---------

Co-authored-by: Valerio Ageno <valerio.ageno@qonto.com>
2024-11-05 12:03:20 +01:00
Valerio Ageno 48c3934b6e Add seo tags to documentation (#78)
* refactor: update documentation meta tags

* feat: update documentation version to v0.11.1

* fix: formatting

* feat: add SEO breadcrumb structured data
2024-11-04 21:23:56 +01:00
Valerio Ageno 258b4af0b1 feat: prevent scroll offset on page navigation (#77) 2024-11-04 19:29:37 +01:00
Valerio Ageno edfbd03f13 Prevent not exported routes error (#76)
* fix: prevent no exported route compiler error

* feat: update version to v0.11.1

* fix: formatting
2024-11-04 19:25:46 +01:00
Valerio Ageno 9cb0f62da7 fix: documentation server/client mismatch 2024-11-04 17:15:37 +01:00
Valerio Ageno 9591960e31 feat: add canonical tags to documentation 2024-11-03 22:34:23 +01:00
Valerio Ageno 132d8ebe8f feat: add navigation buttons to tutorial 2024-11-03 22:34:12 +01:00
Valerio Ageno 4020073aca feat: update doc tuono version to v0.11.0 2024-11-03 22:34:00 +01:00
Valerio Ageno 5025692220 fix: put mantine color scheme component outside Head 2024-11-03 22:27:17 +01:00
Valerio Ageno d04a4c4103 Move to react-helmet-async (#72)
* feat: move to react-helmet-async

* feat: set version to v0.11.0
2024-11-03 21:09:32 +01:00
renovate[bot] 7e6dea68f4 chore(deps): update pnpm to v9.12.3 (#58)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-03 19:02:59 +01:00
renovate[bot] a046833414 chore(deps): update dependency node to v22 (#71)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-03 19:02:43 +01:00
renovate[bot] cfa59ca8bf fix(deps): update dependency @types/node to v22 (#70) 2024-10-29 07:02:39 +01:00
renovate[bot] d470bd58f2 doc: update tuono version to v0.10.4 (#61)
Co-authored-by: Valerio Ageno <valerio.ageno@qonto.com>
2024-10-18 15:22:54 +02:00
Valerio Ageno ea82d2b1df Correct documentation spelling (#68)
* fix: documentation/index spelling

* fix: documentation/development-setup spelling

* fix: documentation/api-fetching spelling

* fix: documentation/components spelling

* fix: documentation/dynamic-routes spelling

* feat: add correct tutorial link to README.md

* chore: remove legacy  folder

* fix: formatting

* fix: documentation/error-handling spelling

* fix: documentation/seo spelling

* fix: documentation/redirections spelling

* fix: documentation/production spelling

* fix: documentation/conclusion spelling

* feat: update version to v0.10.4

* fix: redirections spelling

* fix: API fetching spelling

* feat: create CLI doc page

* fix: correct CLI spelling

* fix: update CLI output spelling
2024-10-17 19:00:23 +02:00
renovate[bot] 93b44088a9 chore(deps): update dependency node to v20.18.0 (#60)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-17 18:09:15 +02:00
renovate[bot] 625c091fdc chore(deps): update dependency @testing-library/react to v16 (#63)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-17 18:08:57 +02:00
Valerio Ageno e0c0447fb6 fix: remove useless container 2024-10-17 12:17:38 +02:00
Valerio Ageno b67a8e8537 fix: remove table of contents 2024-10-17 12:14:33 +02:00
renovate[bot] c9f1b02954 chore(deps): update dependency jsdom to v25 (#64)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-17 09:47:47 +02:00
renovate[bot] 6b3bfca1f6 chore(deps): update dependency vitest to v2 (#65)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-17 09:47:29 +02:00
Valerio Ageno 15e568594d feat: update code font size 2024-10-17 09:03:05 +02:00
Valerio Ageno 089f41ee89 feat: update doc code example font size 2024-10-17 08:56:17 +02:00
Valerio Ageno 636c68e532 chore: update doc sidebar 2024-10-17 08:55:23 +02:00
Valerio Ageno 6d8a04dfb2 Move tutorial into web documentation (#54)
* chore: move tutorial into web documentation

* fix: update ci-documentation tuono version

* fix: rollback documentation website version to v0.10.1

* temporaly remove 'dynamic' keyword

* feat: update tuono documentation to v0.10.3

* chore: format

---------

Co-authored-by: Valerio Ageno <valerio.ageno@qonto.com>
2024-10-17 08:20:01 +02:00
Valerio Ageno 01d5aad8aa fix: update testing-library/jest-dom version 2024-10-16 20:49:46 +02:00
Valerio Ageno 002733cd99 Prevent dynamic fn replacement when not imported by tuono (#57)
* fix: prevent dynamic fn replacement when not imported by tuono

* feat: update version to v0.10.3

* Revert "fix(deps): update dependency zustand to v5 (#55)"

This reverts commit d7a086dfb1.

* Revert "fix(deps): update dependency zustand to v4.5.5 (#37)"

This reverts commit b36dc0b897.

* Revert "chore(deps): update dependency @testing-library/react to v16 (#38)"

This reverts commit 1a1b62698c.

* Revert "chore(deps): update dependency @tanstack/config to ^0.13.0 (#32)"

This reverts commit 6c7cc62371.

* revert pnpm update

* Revert "chore(deps): update dependency vitest to v2 (#45)"

This reverts commit c5b5f6aefe.

* Revert "chore(deps): update dependency node to v20.18.0 (#33)"

This reverts commit 588326cc57.
2024-10-16 20:41:33 +02:00
renovate[bot] d7a086dfb1 fix(deps): update dependency zustand to v5 (#55)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 19:38:29 +02:00
renovate[bot] d60c7dcd9b fix(deps): update rust crate watchexec-supervisor to v3 (#56)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 19:29:33 +02:00
renovate[bot] 58baf8aa82 fix(deps): update dependency tuono to v0.10.2 (#51)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-13 17:57:44 +02:00
85 changed files with 1745 additions and 1090 deletions
-23
View File
@@ -1,23 +0,0 @@
## Description
A clear and concise description of what the bug is.
## Expected behaviour
A clear and concise description of what you expected to happen.
## How to reproduce
Steps to reproduce the behavior:
## Screenshots
If applicable, add screenshots to help explain your problem.
## Tooling and environment
- Tuono version [e.g. 0.4.0]
- OS: [e.g. MacOS, Windows]
- Browser: [e.g. chrome, safari]
- Node version: [e.g. 20.0.0]
- Rust version: [e.g. 1.79.0]
- Crate version: [e.g. 1.78.0]
- Node Package Manger version: [e.g. pnpm: 9.5.0]
## Additional context
Add any other context about the problem here.
+90
View File
@@ -0,0 +1,90 @@
name: Bug report
description: File a bug report
title: '[Bug]: '
labels: ['bug']
body:
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: expected-behaviour
attributes:
label: Expected behaviour
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to reproduce
description: Steps to reproduce the behavior (code, example repository)
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: input
id: tuono-version
attributes:
label: Tuono version
placeholder: "[e.g. 0.4.0]"
validations:
required: false
- type: input
id: os-version
attributes:
label: OS
placeholder: "[e.g. MacOS, Windows]"
validations:
required: false
- type: input
id: browser-version
attributes:
label: Browser
placeholder: "[e.g. chrome, safari]"
validations:
required: false
- type: input
id: node-version
attributes:
label: Node version
placeholder: "[e.g. 20.0.0]"
validations:
required: false
- type: input
id: rust-version
attributes:
label: Rust version
placeholder: "[e.g. 1.79.0]"
validations:
required: false
- type: input
id: create-version
attributes:
label: Crate version
placeholder: "[e.g. 1.78.0]"
validations:
required: false
- type: input
id: node-package-manager-version
attributes:
label: Node Package Manger version
placeholder: "[e.g. pnpm: 9.5.0] "
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context about the bug here.
validations:
required: false
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,36 @@
name: Feature request
description: Suggest an idea for this project
title: '[Feature request]: '
labels: ['enhancement']
body:
- type: textarea
id: description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is.
placeholder: I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
+40 -40
View File
@@ -18,32 +18,32 @@ jobs:
defaults:
run:
working-directory: ./apps/documentation
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install tuono
run: cargo install tuono@0.10.1
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --ignore-workspace
- name: Install tuono
run: cargo install tuono@0.10.4
- name: Build project
run: tuono build --static
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --ignore-workspace
- name: Build project
run: tuono build --static
fmt-lint-and-types:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
@@ -53,23 +53,23 @@ jobs:
run:
working-directory: ./apps/documentation
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --ignore-workspace
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check formatting
run: pnpm format:check
- name: Lint
run: pnpm lint
- name: Types
run: pnpm types
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --ignore-workspace
- name: Check formatting
run: pnpm format:check
- name: Lint
run: pnpm lint
- name: Types
run: pnpm types
+4 -4
View File
@@ -16,7 +16,7 @@ jobs:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
steps:
- name: Check out code
uses: actions/checkout@v4
with:
@@ -28,14 +28,14 @@ jobs:
node-version: 20
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install tuono
run: cargo install tuono@0.10.1
run: cargo install tuono@0.10.4
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --ignore-workspace
+1 -1
View File
@@ -1 +1 @@
20.18.0
22.11.0
+22 -12
View File
@@ -1,4 +1,4 @@
[Documentation](https://tuono.dev) | [Tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) |
[Documentation](https://tuono.dev) | [Tutorial](https://tuono.dev/documentation/tutorial) |
[✨Contributing](https://tuono.dev/documentation/contributing)
# Tuono
@@ -10,16 +10,18 @@
**Tuono is the fastest React framework out there**
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
Why Tuono? Just a badass name.
Some of its features are:
If you have experience with [Next.js](https://nextjs.org/) you will feel home here.
- 🟦 Native Typescript
- 🌐 NextJs like Routing
- 🍭 CSS/SCSS modules
- 🧬 Server Side Rendering
- 🔥 Hot Module Reload
Some of its features are:
- 🟦 Native Typescript
- 🌐 Next.js like Routing
- 🍭 CSS/SCSS modules
- 🧬 Server Side Rendering
- 🔥 Hot Module Reload
## 📖 Documentation
@@ -30,7 +32,7 @@ The [documentation](https://tuono.dev/documentation) is available on
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be full-stack right? (no)**
Tuono is a full-stack React framework with the server side written in Rust.
Tuono is a full-stack React framework with the server side written in Rust.
Because of this, Tuono is extremely fast, and the requests are handled by a multithreaded Rust server.
React is still React - it is just superpowered.
@@ -40,12 +42,19 @@ Tuono provides a collection of utilities to handle the server side code seamless
Each server-side route is managed in a separate file alongside the React route. The routing is handled
by Tuono based on the files defined within the `./src/routes` directory.
**How is Tuono different from Next.js?**
The Tuono API tries to stick as much as possible to the Next.js one (or at least takes a huge inspiration
from it). The major difference is the backend system. While Next.js relies entirely on Node/Deno/Bun,
Tuono runs the server without any intermediary runtime. This enables impressive performance improvements
(check the benchmarks [here](https://github.com/Valerioageno/tuono/tree/main/benches)).
## Getting started
As already mentioned above, I strongly suggest you take a look at the
[tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md).
[tutorial](https://tuono.dev/documentation/tutorial).
Tuono is the CLI that provides all the needed commands to handle the full-stack project.
Tuono is the CLI that provides all the needed commands to handle the full-stack project.
To download it is required [cargo](https://doc.rust-lang.org/cargo/), which is the [rust](https://www.rust-lang.org/)
package manager.
@@ -53,7 +62,7 @@ Then run `cargo install tuono`.
To list all the available commands, run `tuono -h`
To create a new project, run `tuono new [NAME]` (optionally, you can pass the `--template` (or `-t`) flag - check the
To create a new project, run `tuono new [NAME]` (optionally, you can pass the `--template` (or `-t`) flag - check the
[examples](https://github.com/Valerioageno/tuono/tree/main/examples) folder).
Then, to run the local development environment, run it inside the project folder `tuono dev`
@@ -64,6 +73,7 @@ and to set the server project in `production` mode.
Now to execute it just run `cargo run --release`.
## Contributing
Any help or suggestion will be appreciated and encouraged.
Check the [✨Contributing](https://tuono.dev/documentation/contributing) page
+1 -1
View File
@@ -8,7 +8,7 @@ name = "tuono"
path = ".tuono/main.rs"
[dependencies]
tuono_lib = "0.10.1"
tuono_lib = "0.11.2"
glob = "0.3.1"
time = { version = "0.3", features = ["macros"] }
serde = { version = "1.0.202", features = ["derive"] }
+1 -1
View File
@@ -17,7 +17,7 @@
"clsx": "^2.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"tuono": "0.10.1"
"tuono": "0.11.2"
},
"devDependencies": {
"@types/mdx": "^2.0.13",
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

@@ -1,31 +1,72 @@
import { Breadcrumbs, Button } from '@mantine/core'
import { Link } from 'tuono'
import { Link, Head } from 'tuono'
import { IconChevronRight, IconBolt } from '@tabler/icons-react'
import type { ReactNode } from 'react'
interface Breadcrumb {
href?: string
label: string
}
interface BreadcrumbsProps {
breadcrumbs: Breadcrumb[]
}
export default function TuonoBreadcrumbs({
children,
}: {
children: ReactNode
}): JSX.Element {
breadcrumbs = [],
}: BreadcrumbsProps): JSX.Element {
return (
<Breadcrumbs
separator={<IconChevronRight size="1.1rem" stroke={1.5} />}
mb="md"
mt="md"
>
<Button
href="/documentation"
component={Link}
variant="subtle"
radius="xl"
p={5}
<>
<Head>
<script type="application/ld+json">
{`{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Tuono - The React/Rust fullstack framework",
"item": "https://tuono.dev"
},
{
"@type": "ListItem",
"position": 2,
"name": "Tuono - Documentation",
"item": "https://tuono.dev/documentation"
}${breadcrumbs.length > 0 ? ',' : ''}
${breadcrumbs
.map((br, i) =>
JSON.stringify({
'@type': 'ListItem',
position: i + 3,
name: br.label,
item: br.href ? `https://tuono.dev${br.href}` : undefined,
}),
)
.join(',')}]
}
`}
</script>
</Head>
<Breadcrumbs
separator={<IconChevronRight size="1.1rem" stroke={1.5} />}
mb="md"
mt="md"
>
<IconBolt />
</Button>
{children}
</Breadcrumbs>
<Button
href="/documentation"
component={Link}
variant="subtle"
radius="xl"
p={5}
>
<IconBolt />
</Button>
{breadcrumbs.map((br) => (
<BreadcrumbElement href={br.href} label={br.label} key={br.label} />
))}
</Breadcrumbs>
</>
)
}
@@ -15,7 +15,7 @@ export default function Hero(): JSX.Element {
return (
<Container size={1100} my={100}>
<Center>
<Title order={1}>The React/Rust full-stack framework</Title>
<Title order={1}>Superfast fullstack react framework</Title>
</Center>
<Center mt={50}>
<Text fz="18px">
@@ -4,5 +4,5 @@ import type { HTMLAttributes } from 'react'
export default function MdxCode(
props: HTMLAttributes<HTMLPreElement>,
): JSX.Element {
return <Code {...props} />
return <Code {...props} style={{ fontSize: 14 }} />
}
@@ -1,5 +1,5 @@
.pre {
code {
font-size: 16px;
font-size: 14px;
}
}
@@ -9,7 +9,7 @@ export default function MdxPre({ children }: PreProps): JSX.Element {
<CodeHighlight
className={styles.pre}
code={children.props.children || ''}
style={{ borderRadius: 8 }}
style={{ borderRadius: 8, fontWeight: 100 }}
language={children.props.className?.replace('language-', '')}
/>
)
@@ -0,0 +1,3 @@
import MetaTags from './meta-tags'
export default MetaTags
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import { Head } from 'tuono'
interface MetaTagsProps {
title: string
description?: string
type?: 'website' | 'article' | 'book' | 'video.movie' | 'music.song'
canonical: string
}
export default function MetaTags({
title,
description,
type = 'article',
canonical,
}: MetaTagsProps): ReactNode {
return (
<Head>
<title>{title}</title>
<link rel="canonical" href={canonical} />
<meta
name="description"
content={
description ??
'The technologies we love seamlessly working together to unleash the highest web performance ever met on React'
}
/>
<meta
name="keywords"
content="ReactJs, Typescript, Rust, JavaScript, Fullstack framework, Web development"
/>
<meta property="og:title" content={title} />
<meta property="og:type" content={type} />
<meta property="og:image" content="https://tuono.dev/og-cover.png" />
<meta property="og:url" content={canonical} />
<meta property="og:description" content={description} />
<meta property="og:site_name" content="Tuono" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@tuono_dev" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="https://tuono.dev/og-cover.png" />
</Head>
)
}
@@ -0,0 +1,3 @@
import NavigationButtons from './navigation-buttons'
export default NavigationButtons
@@ -0,0 +1,62 @@
import { Box, Button, Text, Title, Flex } from '@mantine/core'
import { Link } from 'tuono'
import { IconArrowRight, IconArrowLeft } from '@tabler/icons-react'
interface NavigationButton {
href: string
title: string
}
interface NavigationButtonsProps {
prev?: NavigationButton
next?: NavigationButton
}
export default function NavigationButtons({
prev,
next,
}: NavigationButtonsProps): JSX.Element {
return (
<Flex mt={50} gap={10}>
{prev && <NavigationBtn type="prev" {...prev} />}
{next && <NavigationBtn type="next" {...next} />}
</Flex>
)
}
interface NavigationButtonProps extends NavigationButton {
type: 'next' | 'prev'
}
const NavigationBtn = ({
type,
title,
href,
}: NavigationButtonProps): JSX.Element => {
const heading = type === 'next' ? 'Next' : 'Previous'
const textAlign = type === 'next' ? 'left' : 'right'
const variant = type === 'next' ? 'filled' : 'outline'
return (
<Button
component={Link}
fullWidth
variant={variant}
href={href}
justify="space-between"
h="auto"
rightSection={type === 'next' && <IconArrowRight />}
leftSection={type === 'prev' && <IconArrowLeft />}
p="20"
>
<Box>
<Title component="span" display="block" order={4} style={{ textAlign }}>
{heading}
</Title>
<Text component="span" display="block" style={{ textAlign }}>
{title}
</Text>
</Box>
</Button>
)
}
@@ -2,5 +2,5 @@
border-radius: 8px;
margin-top: 0.25rem;
line-height: 1.25rem;
font-weight: bold;
font-weight: 500;
}
@@ -4,28 +4,72 @@ import SidebarLink from './sidebar-link'
export default function Sidebar({ close }: { close: () => void }): JSX.Element {
return (
<AppShell.Navbar p="md">
<h3>Tutorial</h3>
<SidebarLink
href="/documentation/tutorial/intro"
label="Intro"
href="/documentation"
label="Getting started"
onClick={close}
/>
<h3>Documentation</h3>
<SidebarLink href="/documentation" label="Overview" onClick={close} />
<SidebarLink
href="/documentation/installation"
label="Installation"
onClick={close}
/>
<SidebarLink
href="/documentation/getting-started"
label="Getting started"
href="/documentation/tutorial"
label="Tutorial"
defaultOpened
onClick={close}
/>
>
<SidebarLink
href="/documentation/tutorial/development-setup"
label="Development setup"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/api-fetching"
label="API fetching"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/components"
label="Components"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/dynamic-routes"
label="Dynamic routes"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/error-handling"
label="Error handling"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/seo"
label="SEO and meta tags"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/redirections"
label="Server redirection"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/production"
label="Production build"
onClick={close}
/>
<SidebarLink
href="/documentation/tutorial/conclusion"
label="Conclusion"
onClick={close}
/>
</SidebarLink>
<SidebarLink href="/documentation/cli" label="CLI" onClick={close} />
<SidebarLink
label="Routing"
href="/documentation/routing"
defaultOpened
onClick={close}
>
<SidebarLink
+1 -1
View File
@@ -86,8 +86,8 @@ export default function RootRoute({ children }: RootRouteProps): JSX.Element {
href="/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<ColorSchemeScript />
</Head>
<ColorSchemeScript />
<MantineProvider theme={theme}>
<AppShell
header={{ height: 60 }}
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
import { AppShell, Container } from '@mantine/core'
import MdxProvider from '../../components/mdx-provider'
import EditPage from '../../components/edit-page'
import TableOfContents from '../../components/table-of-content'
interface RootRouteProps {
children: ReactNode
@@ -11,14 +10,9 @@ interface RootRouteProps {
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
return (
<AppShell.Main>
<Container size="lg" p={20} display={{ lg: 'flex' }}>
<Container id="mdx-root" component="article" size="md" p={0}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
<Container size="xs" visibleFrom="lg" p={0}>
<TableOfContents withTabs={false} />
</Container>
<Container id="mdx-root" component="article" size="md" p={20}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
</AppShell.Main>
)
@@ -1,18 +1,16 @@
import { Head } from 'tuono'
import MetaTags from '../../components/meta-tags'
<Head>
<title>Tuono - Getting started</title>
</Head>
<MetaTags
title="Tuono - CLI"
canonical="https://tuono.dev/documentation/cli"
description="Tuono is the CLI that provides all the needed commands to handle the full-stack project."
/>
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Getting started" />
</Breadcrumbs>
<Breadcrumbs breadcrumbs={[{ label: 'CLI' }]} />
# Getting started
## The CLI
# CLI
Tuono is the CLI that provides all the needed commands to handle the full-stack project.
@@ -21,6 +19,22 @@ Tuono is the CLI that provides all the needed commands to handle the full-stack
To list all the available commands, run `tuono -h`.
```bash
The React/Rust full-stack framework
Usage: tuono <COMMAND>
Commands:
dev Start the development environment
build Build the production assets
new Scaffold a new project
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
```
To create a new project, run `tuono new [NAME]` (optionally you can pass the --template (or -t) flag - check the examples' folder).
Then to run the local development environment run inside the project folder `tuono dev`
@@ -1,14 +1,14 @@
import { Head } from 'tuono'
import MetaTags from '../../components/meta-tags'
<Head>
<title>Tuono - Contributing</title>
</Head>
<MetaTags
title="Tuono - Contributing"
canonical="https://tuono.dev/documentation/contributing"
description="The project is massive - if you like it, do consider contributing!"
/>
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Breadcrumbs>
<Element label="✨ Contributing" />
</Breadcrumbs>
<Breadcrumbs breadcrumbs={[{ label: '✨ Contributing' }]} />
# Contributing
@@ -1,48 +1,54 @@
import { Head } from 'tuono'
import MetaTags from '../../components/meta-tags'
<Head>
<title>Tuono - Documentation</title>
</Head>
<MetaTags
title="Tuono - Documentation"
canonical="https://tuono.dev/documentation"
description="Tuono is a full-stack framework for building React applications using Rust as the backend."
/>
import Breadcrumbs from '../../components/breadcrumbs'
<Breadcrumbs></Breadcrumbs>
<Breadcrumbs />
# Tuono
**Tuono is a full-stack framework for building React applications using Rust as the
backend.**
Tuono is a full-stack web framework for building React applications using Rust as the
backend with a strong focus on usability and performance.
Tuono (Italian word for "thunder", pronounced /2 Oh No/). Why Tuono? Just a badass name.
## Introduction
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be full-stack right? (no)**
Tuono is a full-stack React framework with the server side written in Rust.
Because of this, Tuono is extremely fast and, the requests are handled by a multithreaded Rust server.
React is still React - it is just superpowered.
**Rust is a hard language, then writing server side code is hard as well, right? (no again)**
Tuono provides a collection of utilities to handle the server side code seamlessly with
the React code. Each server side route is managed in a separate file alongside the React route.
The routing is handled by Tuono based on the files defined within the `./src/routes` directory.
If you have experience with [Next.js](https://nextjs.org/) you will feel home here.
## Features
Some of its features include:
- 🟦 Full typescript support
- 🌐 File system based routing
- 🔥 Hot Module Reload
- 🍭 SCSS/CSS modules
- 🟦 Native Typescript
- 🌐 Next.js like Routing
- 🍭 CSS/SCSS modules
- 🧬 Server Side Rendering
- 🧵 Multi-threaded backend
- ⌨️ MDX support
- ⚙️ Build optimizations
- Custom APIs\*
- Image optimization\*
- Server streamed content\*
- 🔥 Hot Module Reload
> \*development in progress
## Introduction
Tuono addresses the following two limits:
- Enable React server-side rendering projects without using JS runtimes (Node, Deno)
- Ease the web development using a typically hard language like Rust.
#### NodeJs/Deno/Bun are the only runtimes that allow a React app to be server-side rendered (SSR)? (no)
Tuono is the first full-stack React framework with the server side written in Rust (check the [ssr-rs](https://github.com/Valerioageno/ssr-rs) crate).
Because of this, Tuono is extremely fast, and the requests are handled by a multithreaded Rust server.
React is still React - it is just superpowered.
#### Rust is a hard language, then writing server side code is hard as well, right? (no again)
Tuono provides a collection of utilities to handle server-side code seamlessly with
the React code. Each server side route is managed in a separate file alongside the React route.
The routing is handled by Tuono based on the files defined within the `./src/routes` directory.
#### How is Tuono different from Next.js?
The Tuono API tries to stick as much as possible to the Next.js one (or at least takes a huge inspiration
from it). The major difference is the backend system. While Next.js relies entirely on Node/Deno/Bun,
Tuono runs the server without any intermediary runtime. This enables impressive performance improvements
(check the benchmarks [here](https://github.com/Valerioageno/tuono/tree/main/benches)).
@@ -1,13 +1,14 @@
import { Head } from 'tuono'
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Head>
<title>Tuono - Installation</title>
</Head>
import MetaTags from '../../components/meta-tags'
<Breadcrumbs>
<Element label="Installation" />
</Breadcrumbs>
<MetaTags
title="Tuono - Installation"
canonical="https://tuono.dev/documentation/installation"
description="Tuono is a development environment built in rust and typescript that outputs a website written in both the languages"
/>
<Breadcrumbs breadcrumbs={[{ label: 'Installation' }]} />
# Installation
@@ -40,7 +41,7 @@ tuono --version
Run `tuono -h` to see all the available commands.
```bash
The react/rust fullstack framework
The React/Rust full-stack framework
Usage: tuono <COMMAND>
@@ -1,14 +1,13 @@
import { Head } from 'tuono'
import MetaTags from '../../../components/meta-tags'
<Head>
<title>Tuono - Routing</title>
</Head>
<MetaTags
title="Tuono - Routing"
canonical="https://tuono.dev/documentation/routing/intro"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Routing" />
</Breadcrumbs>
<Breadcrumbs breadcrumbs={[{ label: 'Routing' }]} />
# Routing index
@@ -1,15 +1,18 @@
import { Head } from 'tuono'
import MetaTags from '../../../components/meta-tags'
<Head>
<title>Tuono - Routing</title>
</Head>
<MetaTags
title="Tuono - Routing"
canonical="https://tuono.dev/documentation/routing"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Routing" href="/documentation/routing" />
<Element label="Project structure" />
</Breadcrumbs>
<Breadcrumbs
breadcrumbs={[
{ label: 'Routing', href: '/documentation/routing' },
{ label: 'Project structure' },
]}
/>
# Project structure
@@ -0,0 +1,123 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - API fetching"
canonical="https://tuono.dev/documentation/tutorial/api-fetching"
description="Learn how to fetch remote APIs on the backend"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'API fetching' },
]}
/>
# API fetching
The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the Pokémon of the first generation (the best one, btw) and then reserve a dynamic page for each one separately.
## Fetch all the Pokémon
To start, lets fetch all of them from the root page. Since we want to render them on the server side, we are going to need to implement the logic in the `index.rs` file.
Clear the `index.rs` file and paste:
```rust
// src/routes/index.rs
use serde::{Deserialize, Serialize};
use tuono_lib::reqwest::Client;
use tuono_lib::{Props, Request, Response};
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemons {
results: Vec<Pokemon>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
url: String,
}
#[tuono_lib::handler]
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
return match fetch.get(ALL_POKEMON).send().await {
Ok(res) => {
let data = res.json::<Pokemons>().await.unwrap();
Response::Props(Props::new(data))
}
Err(_err) => Response::Props(Props::new("{}")),
};
}
```
> The first argument is always the request `req: Request` which contains the request data like the query parameters and the headers.
The rest of the arguments are optional and they don't need to be specified
if they are not used. Enabled out of the box a [Reqwest](https://docs.rs/reqwest/latest/reqwest/) HTTP client.
Now the Pokémon are correctly fetched and hydrated on the client side, so we can actually use them. Clear the `index.tsx` file and paste:
```tsx
// src/routes/index.tsx
import type { TuonoProps } from 'tuono'
interface Pokemon {
name: string
}
interface IndexProps {
results: Pokemon[]
}
export default function IndexPage({
data,
}: TuonoProps<IndexProps>): JSX.Element {
if (!data?.results) {
return <></>
}
return (
<>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
{data.results.map((pokemon) => {
return pokemon.name
})}
</ul>
</>
)
}
```
Refresh the browser now! A bit ugly, but all the Pokémon are finally printed on screen!
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{
title: 'Development setup',
href: '/documentation/tutorial/development-setup',
}}
next={{ title: 'Components', href: '/documentation/tutorial/components' }}
/>
@@ -0,0 +1,143 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Components"
canonical="https://tuono.dev/documentation/tutorial/components"
description="Learn how to manage the components in a Tuono codebase"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Components' },
]}
/>
# Components
## Creating a stand-alone component
Lets then create the button needed for displaying the list of Pokémon.
Create the following file `src/components/PokemonLink.tsx` and fill the content with:
```tsx
// src/components/PokemonLink.tsx
import { Link } from 'tuono'
interface Pokemon {
name: string
}
export default function PokemonLink({
pokemon,
id,
}: {
pokemon: Pokemon
id: number
}): JSX.Element {
return (
<Link href={`/pokemons/${pokemon.name}`}>
{pokemon.name}
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
/>
</Link>
)
}
```
Now that the link is done, lets import it into the `index.tsx` file
```diff
// src/routes/index.tsx
++ import PokemonLink from '../components/PokemonLink'
// ...
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
-- {pokemons.map((pokemon) => {
-- return pokemon.name;
++ {pokemons.map((pokemon, i) => {
++ return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />;
})}
</ul>
// ...
```
Now the links work. Clicking on any of them, we get redirected to the 404 page because we havent yet implemented the `pokemons/[pokemon]` page.
As previously said, CSS modules are enabled out of the box, so lets make those links a little bit nicer.
Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.module.css` and copy the following content into it:
```css
/* src/components/PokemonLink.module.css */
.link {
width: 100%;
max-width: 216px;
position: relative;
background: white;
margin-bottom: 10px;
border: solid #f0f0f0 1px;
text-decoration: none;
color: black;
padding: 5px 5px 5px 15px;
border-radius: 10px;
display: flex;
justify-content: space-between;
transition: 0.2s;
align-items: center;
}
.link:hover {
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
}
.link img {
width: 70px;
background: white;
border-radius: 50%;
}
```
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `npm i -D sass` and run again `tuono dev`
Then import the styles into the `PokemonLink` component as following:
```diff
// src/components/PokemonLink.tsx
import { Link } from "tuono";
import type { Pokemon } from "./../types/pokemon";
++ import styles from './PokemonLink.module.css'
export default function PokemonLink({
pokemon,
id,
}: {
pokemon: Pokemon;
id: number;
}): JSX.Element {
return (
-- <Link href={`/pokemons/${pokemon.name}`}>
++ <Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
{pokemon.name}
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
/>
</Link>
);
}
```
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{ title: 'API fetching', href: '/documentation/tutorial/api-fetching' }}
next={{
title: 'Dynamic routes',
href: '/documentation/tutorial/dynamic-routes',
}}
/>
@@ -0,0 +1,37 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Conclusion"
canonical="https://tuono.dev/documentation/tutorial/conclusion"
description="You just created a multi thread full stack application with Rust and React"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Conclusion' },
]}
/>
# Conclusion
Thats it! You just created a multi thread full stack application with Rust and React.
The project is still under heavy development and many features are not ready yet, but
I hope you got the taste of what is like working with rust and react in the same stack.
As I mentioned in the introduction, I'd love to hear what you thought about the framework and the tutorial - feel free to reach me
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs [@valerioageno](https://twitter.com/valerioageno).
Ciao
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{
title: 'Production build',
href: '/documentation/tutorial/production',
}}
/>
@@ -0,0 +1,77 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Development setup"
canonical="https://tuono.dev/documentation/tutorial/development-setup"
description="Learn how to setup a new Tuono project"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Development setup' },
]}
/>
# Development setup
## Scaffold a new project
To set up a new fresh project you just need to run the following command:
```
$ tuono new tuono-tutorial
```
Get into the project folder and install the dependencies with:
```
$ npm install
```
Open it with your favorite code editor.
The project will have the following structure:
```
├── package.json
├── public
├── src
│ ├── routes
│ └── styles
├── Cargo.toml
├── README.md
└── tsconfig.json
```
`public/`: put here all the files you want to be public
`src/routes/`: All the files in this folder are considered routes. All the routes are server side rendered out of the box.
To add server side capabilities, just create a Rust file with the same name as the route (i.e. `about.tsx` → `about.rs`).
`src/styles/`: In this folder there is the `global.css` file that stores all the global styles. For the rest of the project, you can use
CSS modules.
## Start the dev environment
To start the development environment, you just need to run the following command within the project folder:
```
$ tuono dev
```
The first time might take a little bit because it will install all the Rusts dependencies. All the other execution will be pretty quick!
> 💡 The tuono dev development script is currently under strong optimization improvements.
> In case you face any error delete the cache `.tuono` folder and run it again!
Then open [http://localhost:3000/](http://localhost:3000) on the browser.
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{ title: 'Tutorial', href: '/documentation/tutorial' }}
next={{ title: 'API fetching', href: '/documentation/tutorial/api-fetching' }}
/>
@@ -0,0 +1,173 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Dynamic routes"
canonical="https://tuono.dev/documentation/tutorial/dynamic-routes"
description="Learn how to catch more endpoint with the same route loading dynamic data"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Dynamic routes' },
]}
/>
# Dynamic routes
## Create the Pokémon route
The homepage is ready. We have the full list of Pokémon and they are all links. Great!
Now we want to make those links actually point to a real page. Lets create the dynamic route.
Create the folder `routes/pokemons` and then create the two files `[pokemon].tsx` and `[pokemon].rs`.
These two will handle every request that points to `http://localhost:3000/pokemons/bulbasaur..mew`.
Lets first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
```rust
// src/routes/pokemons/[pokemon].rs
use serde::{Deserialize, Serialize};
use tuono_lib::reqwest::Client;
use tuono_lib::{Props, Request, Response};
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
id: u16,
weight: u16,
height: u16,
}
#[tuono_lib::handler]
async fn get_pokemon(req: Request, fetch: Client) -> Response {
// The param `pokemon` is defined in the route filename [pokemon].rs
let pokemon = req.params.get("pokemon").unwrap();
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
Ok(res) => {
let data = res.json::<Pokemon>().await.unwrap();
Response::Props(Props::new(data))
}
Err(_err) => Response::Props(Props::new("{}"))
};
}
```
Then lets work on the frontend. Paste into the `[pokemon].tsx` file the following code:
```tsx
import { TuonoProps } from 'tuono'
import PokemonView from '../../components/PokemonView'
export default function Pokemon({ data }: TuonoProps): JSX.Element {
return <PokemonView pokemon={data} />
}
```
The browser should complain that the component `PokemonView` does not exist. Lets create it then!
```tsx
// components/PokemonView.tsx
import { Link } from 'tuono'
import styles from './PokemonView.module.css'
interface Pokemon {
name: string
id: string
weight: number
height: number
}
export default function PokemonView({
pokemon,
}: {
pokemon?: Pokemon
}): JSX.Element {
return (
<div>
<Link className={styles['back-btn']} href="/">
Back
</Link>
{pokemon?.name && (
<div className={styles.pokemon}>
<div>
<h1 className={styles.name}>{pokemon.name}</h1>
<dl className={styles.spec}>
<dt className={styles.label}>Weight:</dt>
<dd>{pokemon.weight}lbs</dd>
</dl>
<dl className={styles.spec}>
<dt className={styles.label}>Height:</dt>
<dd>{pokemon.height}ft</dd>
</dl>
</div>
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemon.id}.png`}
/>
</div>
)}
</div>
)
}
```
<br />
```css
/* components/PokemonView.module.css */
.back-btn {
background-color: white;
border-radius: 10px;
padding: 7px 15px;
color: black;
text-decoration: none;
border: solid #f0f0f0 1px;
font-size: 20px;
}
.back-btn:hover {
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
}
.pokemon {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.name {
font-size: 50px;
font-weight: 700;
}
.pokemon img {
width: 400px;
}
.spec {
display: flex;
font-size: 18px;
margin-top: 10px;
}
.label {
font-weight: 700;
}
```
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{ title: 'Components', href: '/documentation/tutorial/components' }}
next={{
title: 'Error handling',
href: '/documentation/tutorial/error-handling',
}}
/>
@@ -0,0 +1,116 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Error handling"
canonical="https://tuono.dev/documentation/tutorial/error-handling"
description="Learn how to handle the server side error on your Tuono project"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Error handling' },
]}
/>
# Error handling
With the current setup, all the routes always return a `200 Success` HTTP status, no matter the response type.
In order to return a more meaningful status code to the browser, the `Props` struct can also be initialized with the
`Props::new_with_status()` method.
Let's see how it works!
```diff
// src/routes/pokemons/[pokemon].rs
use serde::{Deserialize, Serialize};
-- use tuono_lib::reqwest::Client;
++ use tuono_lib::reqwest::{Client, StatusCode};
use tuono_lib::{Props, Request, Response};
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
id: u16,
weight: u16,
height: u16,
}
#[tuono_lib::handler]
async fn get_pokemon(req: Request, fetch: Client) -> Response {
// The param `pokemon` is defined in the route filename [pokemon].rs
let pokemon = req.params.get("pokemon").unwrap();
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
Ok(res) => {
++ if res.status() == StatusCode::NOT_FOUND {
++ return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
++ }
let data = res.json::<Pokemon>().await.unwrap();
Response::Props(Props::new(data))
}
-- Err(_err) => Response::Props(Props::new(
++ Err(_err) => Response::Props(Props::new_with_status(
++ "{}",
++ StatusCode::INTERNAL_SERVER_ERROR,
)),
};
}
```
<br />
```diff
// src/routes/index.rs
use serde::{Deserialize, Serialize};
-- use tuono_lib::reqwest::Client;
++ use tuono_lib::reqwest::{Client, StatusCode};
use tuono_lib::{Props, Request, Response};
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemons {
results: Vec<Pokemon>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
url: String,
}
#[tuono_lib::handler]
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
return match fetch.get(ALL_POKEMON).send().await {
Ok(res) => {
let data = res.json::<Pokemons>().await.unwrap();
Response::Props(Props::new(data))
}
-- Err(_err) => Response::Props(Props::new(
++ Err(_err) => Response::Props(Props::new_with_status(
++ "{}", // Return empty JSON
++ StatusCode::INTERNAL_SERVER_ERROR,
)),
};
}
```
If you now try to load a not-existing Pokémon (`http://localhost:3000/pokemons/tuono-pokemon`) you will
correctly receive a 404 status code in the console.
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{
title: 'Dynamic routes',
href: '/documentation/tutorial/dynamic-routes',
}}
next={{ title: 'SEO and meta tags', href: '/documentation/tutorial/seo' }}
/>
@@ -0,0 +1,37 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Tutorial"
canonical="https://tuono.dev/documentation/tutorial"
description="This tutorial is meant to give you a sneak peek at the framework and is intended to evolve during the development - be sure to have installed the latest version"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs breadcrumbs={[{ label: 'Tutorial' }]} />
# Tutorial
This tutorial is meant to give you a sneak peek at the framework and is intended to evolve during the
development - be sure to have [installed](/documentation/installation) the [latest version](https://crates.io/crates/tuono).
This tutorial is not meant for people who don't know React - in that case, I suggest you first read the
[React Doc](https://react.dev/); Typescript and Rust knowledge is not a requirement though!
If you prefer to just read the code rather than write you can download the finished tutorial project with:
```bash
$ tuono new tutorial --template tutorial
```
> I'd love to hear your thoughts about the framework and the tutorial - feel free to reach me at [valerioageno@yahoo.it](mailto:valerioageno@ahoo.it)
> or on Twitter (X) DM [@valerioageno](https://twitter.com/valerioageno)
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
next={{
title: 'Development setup',
href: '/documentation/tutorial/development-setup',
}}
/>
@@ -1,17 +0,0 @@
import { Head } from 'tuono'
<Head>
<title>Tuono - Tutorial intro</title>
</Head>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Tutorial" />
</Breadcrumbs>
# Tutorial
You can find the tutorial [here](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md).
Other tasks have the priority right now. The tutorial will be ported here as soon as possible.
@@ -0,0 +1,47 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Building for production"
canonical="https://tuono.dev/documentation/tutorial/production"
description="Learn how to build the production project and run the optimize version"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Production build' },
]}
/>
# Production build
The source is now ready to be released. Both server and client have been managed in an unoptimized way
to ease the development experience. To build the project to the production state, just run:
```shell
$ tuono build
```
This command just created the final assets within the `out` directory. To run the production server,
run:
```shell
$ cargo run --release
```
Check again [`http://localhost:3000/`](http://localhost:3000/) This environment now has all the
optimizations ready to unleash the power of a rust server that seamlessly renders a React application!🚀
> Note: The `out` directory is not standalone. You can't rely just on it to run the production server.
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{
title: 'Server redirection',
href: '/documentation/tutorial/redirections',
}}
next={{ title: 'Conclusion', href: '/documentation/tutorial/conclusion' }}
/>
@@ -0,0 +1,61 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - Redirection"
canonical="https://tuono.dev/documentation/tutorial/redirection"
description="Learn how to redirect to different route from the server"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'Redirections' },
]}
/>
# Redirections
What if there is a Pokémon among all of them that should be considered the GOAT? What
we are going to do right now is creating a new route `/pokemons/GOAT` that points to the best
Pokémon of the first generation.
First, let's create a new route by just creating a new file `/pokemons/GOAT.rs` and pasting the following code:
```rs
// src/routes/pokemons/GOAT.rs
use tuono_lib::{Request, Response};
#[tuono_lib::handler]
async fn redirect_to_goat(_req: Request) -> Response {
// Of course the GOAT is mewtwo - feel free to select your favourite 😉
Response::Redirect("/pokemons/mewtwo".to_string())
}
```
Now let's create the button in the home page to actually point to it!
```diff
// src/routes/index.tsx
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
++ <PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
{data.results.map((pokemon, i) => {
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
})}
</ul>
```
Now at [http://localhost:3000/](http:/localhost:3000/) You will find a new link at the beginning of the list.
Click on it and see the application automatically redirecting you to your favourite pokemon's route!
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{ title: 'SEO and meta tags', href: '/documentation/tutorial/seo' }}
next={{
title: 'Production build',
href: '/documentation/tutorial/production',
}}
/>
@@ -0,0 +1,113 @@
import MetaTags from '../../../components/meta-tags'
<MetaTags
title="Tuono - SEO and meta tags"
canonical="https://tuono.dev/documentation/tutorial/eeo"
description="Learn how to handle meaningful meta tags for your Tuono project"
/>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs
breadcrumbs={[
{ label: 'Tutorial', href: '/documentation/tutorial' },
{ label: 'SEO and meta tags' },
]}
/>
# SEO and meta tags
The website now works and the HTTP errors are meaningful, but we should also take care to be meaningful
for the web crawlers. The best way to do it is to enrich the meta tags like the `<title>` and the
`<description>`.
To do so `tuono` also exposes the `<Head />` component useful exactly for handling this scenario. Let's update the `/` and the
`/pokemons/[pokemon]` routes with this.
```diff
// src/routes/index.tsx
import type { TuonoProps } from "tuono";
++ import { Head } from "tuono"
interface Pokemon {
name: string
}
interface IndexProps {
results: Pokemon[]
}
export default function IndexPage({
data,
}: TuonoProps<IndexProps>): JSX.Element {
if (!data?.results) {
return <></>;
}
return (
<>
++ <Head>
++ <title>Tuono tutorial</title>
++ </Head>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
{data.results.map((pokemon) => {
return pokemon.name;
})}
</ul>
</>
);
}
```
<br />
```diff
// src/routes/pokemons/[pokemon].tsx
-- import { TuonoProps } from "tuono";
++ import { TuonoProps, Head } from "tuono";
import PokemonView from "../../components/PokemonView";
export default function Pokemon({ data }: TuonoProps): JSX.Element {
-- return <PokemonView pokemon={data} />;
++ return (
++ <>
++ <Head>
++ <title>Pokemon: ${data?.name}</title>
++ </Head>
++ <PokemonView pokemon={data} />
++ </>
++ )
}
```
The `Head` component takes as children any valid HTML meta tag.
import NavigationButtons from '../../../components/navigation-buttons'
<NavigationButtons
prev={{
title: 'Error handling',
href: '/documentation/tutorial/error-handling',
}}
next={{
title: 'Server redirection',
href: '/documentation/tutorial/redirections',
}}
/>
+6 -8
View File
@@ -1,16 +1,14 @@
import { Head } from 'tuono'
import Hero from '../components/hero'
import MetaTags from '../components/meta-tags'
export default function IndexPage(): JSX.Element {
return (
<>
<Head>
<title>Tuono - The React/Rust full-stack framework</title>
<meta
name="description"
content="The technologies we love seamlessly working together to unleash the highest web performance ever met on React"
/>
</Head>
<MetaTags
title="Tuono - The React/Rust full-stack framework"
canonical="https://tuono.dev"
description="The technologies we love seamlessly working together to unleash the highest web performance ever met on React"
/>
<Hero />
</>
)
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.10.2"
version = "0.12.0"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
@@ -25,7 +25,7 @@ miette = "7.2.0"
watchexec-signals = "3.0.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0.202", features = ["derive"] }
watchexec-supervisor = "2.0.0"
watchexec-supervisor = "3.0.0"
glob = "0.3.1"
regex = "1.10.4"
reqwest = {version = "0.12.4", features =["blocking", "json"]}
+1 -1
View File
@@ -33,7 +33,7 @@ enum Actions {
}
#[derive(Parser, Debug)]
#[command(version, about = "The react/rust fullstack framework")]
#[command(version, about = "The React/Rust full-stack framework")]
struct Args {
#[command(subcommand)]
action: Actions,
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.10.2"
version = "0.12.0"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
@@ -33,7 +33,7 @@ either = "1.13.0"
tower-http = {version = "0.6.0", features = ["fs"]}
colored = "2.1.0"
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.10.2"}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.12.0"}
# Match the same version used by axum
tokio-tungstenite = "0.24.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
+15
View File
@@ -1,12 +1,15 @@
use crate::{ssr::Js, Payload};
use axum::extract::{Path, Request};
use axum::response::Html;
use colored::*;
use std::collections::HashMap;
use tokio::time::Instant;
pub async fn catch_all(
Path(params): Path<HashMap<String, String>>,
request: Request,
) -> Html<String> {
let start = Instant::now();
let pathname = request.uri();
let headers = request.headers();
@@ -17,6 +20,18 @@ pub async fn catch_all(
let result = Js::render_to_string(Some(&payload));
let duration = start.elapsed();
// TODO: handle 404 error on catch_all route
let http_code = "200";
println!(
" GET {} {} in {}ms",
req.uri.path(),
http_code.green(),
duration.as_millis()
);
match result {
Ok(html) => Html(html),
_ => Html("500 internal server error".to_string()),
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib_macros"
version = "0.10.2"
version = "0.12.0"
edition = "2021"
description = "The react/rust fullstack framework"
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
+53 -16
View File
@@ -1,43 +1,80 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
use syn::punctuated::Punctuated;
use syn::token::Comma;
use syn::{parse2, parse_macro_input, FnArg, ItemFn, Pat, Type};
fn create_struct_fn_arg(arg_name: Pat, arg_type: Type) -> FnArg {
parse2(quote! {
tuono_lib::axum::extract::State(#arg_name): tuono_lib::axum::extract::State<#arg_type>
})
.unwrap()
}
fn params_argument() -> FnArg {
parse2(quote! {
tuono_lib::axum::extract::Path(params): tuono_lib::axum::extract::Path<
std::collections::HashMap<String, String>
>
})
.unwrap()
}
fn request_argument() -> FnArg {
parse2(quote! {
request: tuono_lib::axum::extract::Request
})
.unwrap()
}
pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
let item = parse_macro_input!(item as ItemFn);
let fn_name = item.clone().sig.ident;
let fn_name = &item.sig.ident;
let mut argument_names: Punctuated<Pat, Comma> = Punctuated::new();
let mut axum_arguments: Punctuated<FnArg, Comma> = Punctuated::new();
// Fn Arguments minus the first which always is the request
for (i, arg) in item.sig.inputs.iter().enumerate() {
if i == 0 {
axum_arguments.insert(i, params_argument());
continue;
}
if let FnArg::Typed(pat_type) = arg {
let index = i - 1;
let argument_name = *pat_type.pat.clone();
let argument_type = *pat_type.ty.clone();
argument_names.insert(index, argument_name.clone());
axum_arguments.insert(index, create_struct_fn_arg(argument_name, argument_type))
}
}
axum_arguments.insert(axum_arguments.len(), request_argument());
quote! {
use tuono_lib::axum::response::IntoResponse;
use std::collections::HashMap;
use tuono_lib::axum::extract::{State, Path};
#item
pub async fn route(
Path(params): Path<HashMap<String, String>>,
State(client): State<tuono_lib::reqwest::Client>,
request: tuono_lib::axum::extract::Request
) -> impl IntoResponse {
#axum_arguments
) -> impl tuono_lib::axum::response::IntoResponse {
let pathname = request.uri();
let headers = request.headers();
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
#fn_name(req.clone(), client).await.render_to_string(req)
#fn_name(req.clone(), #argument_names).await.render_to_string(req)
}
pub async fn api(
Path(params): Path<HashMap<String, String>>,
State(client): State<tuono_lib::reqwest::Client>,
request: tuono_lib::axum::extract::Request
) -> impl IntoResponse{
#axum_arguments
) -> impl tuono_lib::axum::response::IntoResponse {
let pathname = request.uri();
let headers = request.headers();
let req = tuono_lib::Request::new(pathname.to_owned(), headers.to_owned(), params);
#fn_name(req.clone(), client).await.json()
#fn_name(req.clone(), #argument_names).await.json()
}
}
.into()
-12
View File
@@ -1,12 +0,0 @@
# Documentation
This folder contains the documentation related to the `tuono` framework.
The framework expects to enable SSG and MDX support in the next months.
At that time a standalone website will be built with tuono to hold the whole documentation.
The following docs are currently actively maintained:
- [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
## Contributions
Any help or suggestion will be strongly appreciated.
-697
View File
@@ -1,697 +0,0 @@
# Tuono tutorial
This tutorial is meant for giving you a sneak peek of the framework and is intended to evolve along the development - be sure to have installed the latest version.
The first part is about the project setup and the base knowledge needed to work with tuono. The actual tutorial starts at [Tutorial introduction](#tutorial-introduction).
> I'd love to hear your thoughts about the framework and the tutorial - feel free to reach me
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or on Twitter (X) DMs
[@valerioageno](https://twitter.com/valerioageno)
This tutorial is **not meant** for people that don't know React - in that case I suggest you to first read the [React doc](https://react.dev/);
Typescript and Rust knowledge is not a requirement though!
## Table of Content
* [CLI Installation](#cli-installation)
* [Project scaffold](#project-scaffold)
* [Start the dev environment](#start-the-dev-environment)
* [The “/” route](#the--route)
* [Tutorial introduction](#tutorial-introduction)
* [Fetch all the pokemons](#fetch-all-the-pokemons)
* [Create a stand-alone component](#create-a-stand-alone-component)
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
* [Error handling](#error-handling)
* [Seo and meta tags](#seo-and-meta-tags)
* [Handle redirections](#handle-redirections)
* [Building for production](#building-for-production)
* [Conclusion](#conclusion)
## CLI Installation
The tuono CLI is hosted on [crates.io](https://crates.io/crates/tuono); to download and install it just run on a terminal:
```bash
$ cargo install tuono
```
To check that is correctly installed run:
```bash
$ tuono --version
```
Run `tuono -h` to see all the available commands.
## Project scaffold
To setup a new fresh project you just need to run the following command:
```bash
$ tuono new tuono-tutorial
```
Get into the project folder and install the dependencies with:
```bash
$ npm install
```
Open it with your favourite code editor.
The project will have the following structure:
```
├── package.json
├── public
├── src
│ ├── routes
│ └── styles
├── Cargo.toml
├── README.md
└── tsconfig.json
```
**public/**: put here all the files you want to be public
**src/routes/**: All the files in this folder are considered routes. All the routes are server side rendered out of the box. To add server side capabilities just create a rust file with the same name as the route (i.e. `about.tsx``about.rs`).
**src/styles/**: In this folder there is the `global.css` file that stores all the global styles. For the rest of the project you can use CSS modules (⚠️ CSS modules on routes are forbidden).
## Start the dev environment
To start the development environment you just need to run the following command within the project folder:
```bash
$ tuono dev
```
The first time might take a little bit because it will install all the rusts dependencies. All the other execution will be pretty quick!
> 💡 The `tuono dev` development script is currently under strong optimization improvements. In case you face any error delete the cache `.tuono` folder and run it again!
Then open [`http://localhost:3000/`](http://localhost:3000/) on the browser.
## The “/” route
All the `index.tsx` files represent the folder root page (i.e. `src/routes/posts/index.tsx` is [`http://localhost:3000/posts`](http://localhost:3000/posts) as well as `src/routes/posts.tsx`).
The file `index.rs` represents the server side capabilities for the index route. On this file you can:
- Passing server side props
- Changing http status code
- Redirecting to a different route
## Tutorial introduction
Now that we have some knowledge about the project structure lets start the real tutorial.
The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately.
> If you have already installed the tuono CLI and you prefer read the code instead of writing it yourself
you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
## Fetch all the pokemons
To start lets fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file.
Clear the `index.rs` file and paste:
```rust
// src/routes/index.rs
use serde::{Deserialize, Serialize};
use tuono_lib::reqwest::Client;
use tuono_lib::{Props, Request, Response};
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemons {
results: Vec<Pokemon>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
url: String,
}
#[tuono_lib::handler]
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
return match fetch.get(ALL_POKEMON).send().await {
Ok(res) => {
let data = res.json::<Pokemons>().await.unwrap();
Response::Props(Props::new(data))
}
Err(_err) => Response::Props(Props::new("{}")),
};
}
```
Now the pokemons are correctly fetched and hydrated on the client side so we can actually use them. Clear the `index.tsx` file and paste:
```tsx
// src/routes/index.tsx
import type { TuonoProps } from "tuono";
interface Pokemon {
name: string
}
interface IndexProps {
results: Pokemon[]
}
export default function IndexPage({
data,
}: TuonoProps<IndexProps>): JSX.Element {
if (!data?.results) {
return <></>;
}
return (
<>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
{data.results.map((pokemon) => {
return pokemon.name;
})}
</ul>
</>
);
}
```
Refresh now the browser! A bit ugly but all the pokemons are finally printed on screen!
## Create a stand-alone component
Lets then create the button needed for displaying the list of pokemons.
Create the following file `src/components/PokemonLink.tsx` and fill the content with:
```tsx
// src/components/PokemonLink.tsx
import { Link } from "tuono";
interface Pokemon {
name: string
}
export default function PokemonLink({
pokemon,
id,
}: {
pokemon: Pokemon;
id: number;
}): JSX.Element {
return (
<Link href={`/pokemons/${pokemon.name}`}>
{pokemon.name}
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
/>
</Link>
);
}
```
Now that the link is done lets import it in the `index.tsx` file
```diff
// src/routes/index.tsx
++ import PokemonLink from '../components/PokemonLink'
// ...
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
-- {pokemons.map((pokemon) => {
-- return pokemon.name;
++ {pokemons.map((pokemon, i) => {
++ return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />;
})}
</ul>
// ...
```
Now the links work. Clicking on any of them we get redirected to the 404 page because we havent yet implemented the `pokemons/[pokemon]` page.
As previously said CSS modules are enabled out of the box so lets make those links a little bit nicer.
Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.module.css` and copy the following content into it:
```css
/* src/components/PokemonLink.module.css */
.link {
width: 100%;
max-width: 216px;
position: relative;
background: white;
margin-bottom: 10px;
border: solid #f0f0f0 1px;
text-decoration: none;
color: black;
padding: 5px 5px 5px 15px;
border-radius: 10px;
display: flex;
justify-content: space-between;
transition: 0.2s;
align-items: center;
}
.link:hover {
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
}
.link img {
width: 70px;
background: white;
border-radius: 50%;
}
```
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `npm i -D sass` and run again `tuono dev`
Then import the styles into the `PokemonLink` component as following:
```diff
// src/components/PokemonLink.tsx
import { Link } from "tuono";
import type { Pokemon } from "./../types/pokemon";
++ import styles from './PokemonLink.module.css'
export default function PokemonLink({
pokemon,
id,
}: {
pokemon: Pokemon;
id: number;
}): JSX.Element {
return (
-- <Link href={`/pokemons/${pokemon.name}`}>
++ <Link className={styles.link} href={`/pokemons/${pokemon.name}`}>
{pokemon.name}
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
/>
</Link>
);
}
```
## Create the `/pokemons/[pokemon]` route
The homepage is ready. We have the full list of pokemons and they are all links. Great!
Now we want to make those links actually pointing to a real page. Lets create the dynamic route.
Create the folder `routes/pokemons` and then create the two files `[pokemon].tsx` and `[pokemon].rs`.
These two will handle every requests that points to `http://localhost:3000/pokemons/bulbasaur..mew`.
Lets first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
```rust
// src/routes/pokemons/[pokemon].rs
use serde::{Deserialize, Serialize};
use tuono_lib::reqwest::Client;
use tuono_lib::{Props, Request, Response};
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
id: u16,
weight: u16,
height: u16,
}
#[tuono_lib::handler]
async fn get_pokemon(req: Request, fetch: Client) -> Response {
// The param `pokemon` is defined in the route filename [pokemon].rs
let pokemon = req.params.get("pokemon").unwrap();
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
Ok(res) => {
let data = res.json::<Pokemon>().await.unwrap();
Response::Props(Props::new(data))
}
Err(_err) => Response::Props(Props::new("{}"))
};
}
```
Then lets work on the frontend. Paste into the `[pokemon].tsx` file the following code:
```tsx
import { TuonoProps } from "tuono";
import PokemonView from "../../components/PokemonView";
export default function Pokemon({ data }: TuonoProps): JSX.Element {
return <PokemonView pokemon={data} />;
}
```
The browser should complain that the component `PokemonView` does not exist. Lets create it then!
```tsx
// components/PokemonView.tsx
import { Link } from "tuono";
import styles from "./PokemonView.module.css";
interface Pokemon {
name: string
id: string
weight: number
height: number
}
export default function PokemonView({
pokemon,
}: {
pokemon?: Pokemon;
}): JSX.Element {
return (
<div>
<Link className={styles["back-btn"]} href="/">
Back
</Link>
{pokemon?.name && (
<div className={styles.pokemon}>
<div>
<h1 className={styles.name}>
{pokemon.name}
</h1>
<dl className={styles.spec}>
<dt className={styles.label}>Weight:</dt>
<dd>{pokemon.weight}lbs</dd>
</dl>
<dl className={styles.spec}>
<dt className={styles.label}>Height:</dt>
<dd>{pokemon.height}ft</dd>
</dl>
</div>
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemon.id}.png`}
/>
</div>
)}
</div>
);
}
```
```css
/* components/PokemonView.module.css */
.back-btn {
background-color: white;
border-radius: 10px;
padding: 7px 15px;
color: black;
text-decoration: none;
border: solid #f0f0f0 1px;
font-size: 20px;
}
.back-btn:hover {
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
}
.pokemon {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.name {
font-size: 50px;
font-weight: 700;
}
.pokemon img {
width: 400px;
}
.spec {
display: flex;
font-size: 18px;
margin-top: 10px;
}
.label {
font-weight: 700;
}
```
## Error handling
With the current setup all the routes always return a `200 Success` http status no matter the response type.
In order to return a more meaningful status code to the browser the `Props` struct can be initialized with also the
`Props::new_with_status()` method.
Let's see how it works!
```diff
// src/routes/pokemons/[pokemon].rs
use serde::{Deserialize, Serialize};
-- use tuono_lib::reqwest::Client;
++ use tuono_lib::reqwest::{Client, StatusCode};
use tuono_lib::{Props, Request, Response};
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
id: u16,
weight: u16,
height: u16,
}
#[tuono_lib::handler]
async fn get_pokemon(req: Request, fetch: Client) -> Response {
// The param `pokemon` is defined in the route filename [pokemon].rs
let pokemon = req.params.get("pokemon").unwrap();
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
Ok(res) => {
++ if res.status() == StatusCode::NOT_FOUND {
++ return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
++ }
let data = res.json::<Pokemon>().await.unwrap();
Response::Props(Props::new(data))
}
-- Err(_err) => Response::Props(Props::new(
++ Err(_err) => Response::Props(Props::new_with_status(
++ "{}",
++ StatusCode::INTERNAL_SERVER_ERROR,
)),
};
}
```
```diff
// src/routes/index.rs
use serde::{Deserialize, Serialize};
-- use tuono_lib::reqwest::Client;
++ use tuono_lib::reqwest::{Client, StatusCode};
use tuono_lib::{Props, Request, Response};
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
#[derive(Debug, Serialize, Deserialize)]
struct Pokemons {
results: Vec<Pokemon>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Pokemon {
name: String,
url: String,
}
#[tuono_lib::handler]
async fn get_all_pokemons(_req: Request, fetch: Client) -> Response {
return match fetch.get(ALL_POKEMON).send().await {
Ok(res) => {
let data = res.json::<Pokemons>().await.unwrap();
Response::Props(Props::new(data))
}
-- Err(_err) => Response::Props(Props::new(
++ Err(_err) => Response::Props(Props::new_with_status(
++ "{}", // Return empty JSON
++ StatusCode::INTERNAL_SERVER_ERROR,
)),
};
}
```
If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon`) you will
correctly receive a 404 status code in the console.
## Seo and meta tags
The website now works and the http errors are meaningful but we should also take care to be meaningful
for the web crawlers. The best way to do it is to enrich the meta tags like the `<title>` and the
`<description>`.
To do so `tuono` exposes also the `<Head />` component useful exactly for handling this scenario. Let's update the `/` and the
`/pokemons/[pokemon]` routes with this.
```diff
// src/routes/index.tsx
import type { TuonoProps } from "tuono";
++ import { Head } from "tuono"
interface Pokemon {
name: string
}
interface IndexProps {
results: Pokemon[]
}
export default function IndexPage({
data,
}: TuonoProps<IndexProps>): JSX.Element {
if (!data?.results) {
return <></>;
}
return (
<>
++ <Head>
++ <title>Tuono tutorial</title>
++ </Head>
<header className="header">
<a href="https://crates.io/crates/tuono" target="_blank">
Crates
</a>
<a href="https://www.npmjs.com/package/tuono" target="_blank">
Npm
</a>
</header>
<div className="title-wrap">
<h1 className="title">
TU<span>O</span>NO
</h1>
<div className="logo">
<img src="rust.svg" className="rust" />
<img src="react.svg" className="react" />
</div>
</div>
<ul style={{ flexWrap: "wrap", display: "flex", gap: 10 }}>
{data.results.map((pokemon) => {
return pokemon.name;
})}
</ul>
</>
);
}
```
```diff
// src/routes/pokemons/[pokemon].tsx
-- import { TuonoProps } from "tuono";
++ import { TuonoProps, Head } from "tuono";
import PokemonView from "../../components/PokemonView";
export default function Pokemon({ data }: TuonoProps): JSX.Element {
-- return <PokemonView pokemon={data} />;
++ return (
++ <>
++ <Head>
++ <title>Pokemon: ${data?.name}</title>
++ </Head>
++ <PokemonView pokemon={data} />
++ </>
++ )
}
```
The `Head` component takes as children any valid HTML meta tag.
## Handle redirections
What if there is a pokemon among all of them that should be considered the GOAT? What
we are going to do right now is creating a new route `/pokemons/GOAT` that points to the best
pokemon of the first generation.
First let's create a new route by just creating an new file `/pokemons/GOAT.rs` and pasting the following code:
```rs
// src/routes/pokemons/GOAT.rs
use tuono_lib::{reqwest::Client, Request, Response};
#[tuono_lib::handler]
async fn redirect_to_goat(_: Request, _: Client) -> Response {
// Of course the GOAT is mewtwo - feel free to select your favourite 😉
Response::Redirect("/pokemons/mewtwo".to_string())
}
```
Now let's create the button in the home page to actually point to it!
```diff
// src/routes/index.tsx
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
++ <PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
{data.results.map((pokemon, i) => {
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
})}
</ul>
```
Now at [http://localhost:3000/](http:/localhost:3000/) you will find a new link at the beginning of the list.
Click on it and see the application automatically redirecting you to your favourite pokemon's route!
## Building for production
The source now is ready to be released. Both server and client have been managed in a unoptimized way
to easy the development experience. To build the project to the production state just run:
```shell
$ tuono build
```
This command just created the final assets within the `out` directory. To run then the prodiction server
run:
```shell
$ cargo run --release
```
Check again [`http://localhost:3000/`](http://localhost:3000/) - This environment now has all the
optimizations ready to unleash the power of a rust server that seamessly renders a React application!🚀
> Note: The `out` directory is not standalone. You can't rely just on it to run the production server.
## Conclusion
Thats it! You just created a multi thread full stack application with rust and react.
The project is still under heavy development and many features are not ready yet but
I hope you got the taste of what is like working with rust and react in the same stack.
As I mentioned in the introduction I'd love to hear what you thought about the framework and the tutorial - feel free to reach me
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs [@valerioageno](https://twitter.com/valerioageno).
+1 -2
View File
@@ -1,5 +1,4 @@
use serde::Serialize;
use tuono_lib::reqwest;
use tuono_lib::{Props, Request, Response};
#[derive(Serialize)]
@@ -8,7 +7,7 @@ struct MyResponse<'a> {
}
#[tuono_lib::handler]
async fn get_server_side_props(_req: Request, _fetch: reqwest::Client) -> Response {
async fn get_server_side_props(_req: Request) -> Response {
Response::Props(Props::new(MyResponse {
subtitle: "The react / rust fullstack framework",
}))
@@ -1,7 +1,7 @@
// src/routes/pokemons/GOAT.rs
use tuono_lib::{reqwest::Client, Request, Response};
use tuono_lib::{Request, Response};
#[tuono_lib::handler]
async fn redirect_to_goat(_: Request, _: Client) -> Response {
async fn redirect_to_goat(_req: Request) -> Response {
Response::Redirect("/pokemons/mewtwo".to_string())
}
@@ -2,21 +2,21 @@ import { Head, type TuonoProps } from 'tuono'
import PokemonView from '../../components/PokemonView'
interface Pokemon {
name: string
id: string
weight: number
height: number
name: string
id: string
weight: number
height: number
}
export default function PokemonPage({
data,
data,
}: TuonoProps<Pokemon>): JSX.Element {
return (
<>
<Head>
<title>Pokemon: {data?.name}</title>
</Head>
<PokemonView pokemon={data} />
</>
)
return (
<>
<Head>
<title>{`Pokemon: ${data?.name}`}</title>
</Head>
<PokemonView pokemon={data} />
</>
)
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "workspace",
"packageManager": "pnpm@9.12.1",
"packageManager": "pnpm@9.12.3+sha256.24235772cc4ac82a62627cd47f834c72667a2ce87799a846ec4e8e555e2d4b8b",
"scripts": {
"dev": "turbo watch dev",
"build": "turbo build --filter tuono",
@@ -20,8 +20,8 @@
"author": "Valerio Ageno",
"license": "MIT",
"devDependencies": {
"@tanstack/config": "^0.13.0",
"@types/node": "^20.12.7",
"@tanstack/config": "^0.7.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-fs-router-vite-plugin",
"version": "0.10.2",
"version": "0.12.0",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -44,7 +44,7 @@
"vite": "^5.2.11"
},
"devDependencies": {
"@tanstack/config": "^0.13.0",
"@tanstack/config": "^0.7.11",
"@types/babel__core": "^7.20.5",
"vitest": "^2.0.0"
}
@@ -21,6 +21,7 @@ import { ROUTES_FOLDER, ROOT_PATH_ID, GENERATED_ROUTE_TREE } from './constants'
import { format } from 'prettier'
import { sortRouteNodes } from './sort-route-nodes'
import isDefaultExported from './utils/is-default-exported'
let latestTask = 0
@@ -50,6 +51,13 @@ async function getRouteNodes(
if (dirent.isDirectory()) {
await recurse(relativePath)
} else if (fullPath.match(/\.(tsx|ts|jsx|js|mdx)$/)) {
// Check that the route is correctly default exported
if (
fullPath.match(/\.(tsx|ts|jsx|js)$/) &&
!isDefaultExported((await fsp.readFile(fullPath)).toString())
) {
return
}
const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath)
let routePath =
@@ -0,0 +1,4 @@
export default function isDefaultExported(source: string): boolean {
const regex = new RegExp('export default')
return regex.test(source)
}
@@ -1 +1,2 @@
/** */
// export default
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1 +1 @@
/** */
/** export default */
@@ -1,11 +1,11 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackViteConfig } from '@tanstack/config/vite'
import { tanstackBuildConfig } from '@tanstack/config/build'
const config = defineConfig({})
export default mergeConfig(
config,
tanstackViteConfig({
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-lazy-fn-vite-plugin",
"version": "0.10.2",
"version": "0.12.0",
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -43,7 +43,7 @@
"vite": "^5.2.11"
},
"devDependencies": {
"@tanstack/config": "^0.13.0",
"@tanstack/config": "^0.7.11",
"@types/babel__core": "^7.20.5",
"prettier": "^3.2.4",
"vitest": "^2.0.0"
+37 -35
View File
@@ -1,6 +1,7 @@
import type { Plugin } from 'vite'
import * as babel from '@babel/core'
import type { PluginItem } from '@babel/core'
import { isTuonoDynamicFnImported } from './utils'
import {
TUONO_MAIN_PACKAGE,
@@ -12,7 +13,6 @@ import * as t from '@babel/types'
import type {
Identifier,
ImportDeclaration,
CallExpression,
ArrowFunctionExpression,
StringLiteral,
@@ -26,13 +26,8 @@ const RemoveTuonoLazyImport: PluginItem = {
name: 'remove-tuono-lazy-import-plugin',
visitor: {
ImportSpecifier: (path) => {
if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) {
if (
(path.parentPath.node as ImportDeclaration).source.value ===
TUONO_MAIN_PACKAGE
) {
path.remove()
}
if (isTuonoDynamicFnImported(path)) {
path.remove()
}
},
},
@@ -46,18 +41,39 @@ const ReplaceTuonoLazyImport: PluginItem = {
name: 'remove-tuono-lazy-import-plugin',
visitor: {
ImportSpecifier: (path) => {
if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) {
if (
(path.parentPath.node as ImportDeclaration).source.value ===
TUONO_MAIN_PACKAGE
) {
;(path.node.imported as Identifier).name = TUONO_LAZY_FN_ID
}
if (isTuonoDynamicFnImported(path)) {
;(path.node.imported as Identifier).name = TUONO_LAZY_FN_ID
}
},
},
}
const turnLazyIntoStatic = {
VariableDeclaration: (path: babel.NodePath<t.VariableDeclaration>): void => {
path.node.declarations.forEach((el) => {
const init = el.init as CallExpression
if ((init.callee as Identifier).name === TUONO_DYNAMIC_FN_ID) {
const importName = (el.id as Identifier).name
const importPath = (
(
(init.arguments[0] as ArrowFunctionExpression)
.body as CallExpression
).arguments[0] as StringLiteral
).value
if (importName && importPath) {
const importDeclaration = t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(importName))],
t.stringLiteral(importPath),
)
path.replaceWith(importDeclaration)
}
}
})
},
}
/**
* [SERVER build]
* This plugin statically imports the lazy loaded components
@@ -65,27 +81,13 @@ const ReplaceTuonoLazyImport: PluginItem = {
const TurnLazyIntoStaticImport: PluginItem = {
name: 'turn-lazy-into-static-import-plugin',
visitor: {
VariableDeclaration: (path) => {
path.node.declarations.forEach((el) => {
const init = el.init as CallExpression
if ((init.callee as Identifier).name === TUONO_DYNAMIC_FN_ID) {
const importName = (el.id as Identifier).name
const importPath = (
(
(init.arguments[0] as ArrowFunctionExpression)
.body as CallExpression
).arguments[0] as StringLiteral
).value
if (importName && importPath) {
const importDeclaration = t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(importName))],
t.stringLiteral(importPath),
)
path.replaceWith(importDeclaration)
Program: (path) => {
path.traverse({
ImportSpecifier: (subPath) => {
if (isTuonoDynamicFnImported(subPath)) {
path.traverse(turnLazyIntoStatic)
}
}
},
})
},
},
+19
View File
@@ -0,0 +1,19 @@
import { TUONO_MAIN_PACKAGE, TUONO_DYNAMIC_FN_ID } from './constants'
import type * as t from '@babel/types'
import type { Identifier, ImportDeclaration } from '@babel/types'
export const isTuonoDynamicFnImported = (
path: babel.NodePath<t.ImportSpecifier>,
): boolean => {
if ((path.node.imported as Identifier).name !== TUONO_DYNAMIC_FN_ID) {
return false
}
if (
(path.parentPath.node as ImportDeclaration).source.value !==
TUONO_MAIN_PACKAGE
) {
return false
}
return true
}
@@ -10,24 +10,51 @@ const PokemonspokemonImport = dynamic(
)
`
const CLIENT_RESULT = `import { createRoute, lazyLoadComponent as dynamic } from 'tuono';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));`
const NON_DYNAMIC_SOURCE = `
import { createRoute } from 'tuono'
import {dynamic} from 'external-lib'
const SERVER_RESULT = `import { createRoute } from 'tuono';
import IndexImport from "./../src/routes/index";
import PokemonspokemonImport from "./../src/routes/pokemons/[pokemon]";`
const IndexImport = dynamic(() => import('./../src/routes/index'))
const PokemonspokemonImport = dynamic(
() => import('./../src/routes/pokemons/[pokemon]'),
)
`
describe('Transpile tuono source', () => {
it('Into the client bundle', () => {
const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id')
expect(bundle).toBe(CLIENT_RESULT)
expect(bundle)
.toBe(`import { createRoute, lazyLoadComponent as dynamic } from 'tuono';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));`)
})
it('Into the server bundle', () => {
const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id', {
ssr: true,
})
expect(bundle).toBe(SERVER_RESULT)
expect(bundle).toBe(`import { createRoute } from 'tuono';
import IndexImport from "./../src/routes/index";
import PokemonspokemonImport from "./../src/routes/pokemons/[pokemon]";`)
})
})
describe('Non tuono dynamic function', () => {
it('Into the client bundle', () => {
const bundle = LazyLoadingPlugin().transform?.(NON_DYNAMIC_SOURCE, 'id')
expect(bundle).toBe(`import { createRoute } from 'tuono';
import { dynamic } from 'external-lib';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));`)
})
it('Into the server bundle', () => {
const bundle = LazyLoadingPlugin().transform?.(NON_DYNAMIC_SOURCE, 'id', {
ssr: true,
})
expect(bundle).toBe(`import { createRoute } from 'tuono';
import { dynamic } from 'external-lib';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));`)
})
})
+2 -2
View File
@@ -1,11 +1,11 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackViteConfig } from '@tanstack/config/vite'
import { tanstackBuildConfig } from '@tanstack/config/build'
const config = defineConfig({})
export default mergeConfig(
config,
tanstackViteConfig({
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-router",
"version": "0.10.2",
"version": "0.12.0",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -44,11 +44,11 @@
"dependencies": {
"react-intersection-observer": "^9.13.0",
"vite": "^5.2.11",
"zustand": "4.5.5"
"zustand": "4.4.7"
},
"devDependencies": {
"@tanstack/config": "^0.13.0",
"@testing-library/jest-dom": "^6.4.5",
"@tanstack/config": "^0.7.11",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"jsdom": "^25.0.0",
"prettier": "^3.2.4",
+9 -2
View File
@@ -5,13 +5,20 @@ import useRoute from '../hooks/useRoute'
import { useInView } from 'react-intersection-observer'
interface TuonoLinkProps {
/**
* If "true" the route gets loaded when the link enters the viewport. Default "true"
*/
preload?: boolean
/**
* If "false" the scroll offset will be kept across page navigation. Default "true"
*/
scroll?: boolean
}
export default function Link(
componentProps: AnchorHTMLAttributes<HTMLAnchorElement> & TuonoLinkProps,
): JSX.Element {
const { preload = true, ...props } = componentProps
const { preload = true, scroll = true, ...props } = componentProps
const router = useRouter()
const route = useRoute(props.href)
const { ref } = useInView({
@@ -30,7 +37,7 @@ export default function Link(
return
}
router.push(props.href || '')
router.push(props.href || '', { scroll })
}
return (
+14 -2
View File
@@ -1,4 +1,5 @@
import * as React from 'react'
import type { ReactElement, ComponentType } from 'react'
import type { RouteComponent } from './types'
type ImportFn = () => Promise<{ default: React.ComponentType<any> }>
@@ -40,7 +41,18 @@ export const dynamic = (importFn: ImportFn): JSX.Element => {
}
export const lazyLoadComponent = (factory: ImportFn): RouteComponent => {
const Component = React.lazy(factory) as unknown as RouteComponent
Component.preload = factory
let LoadedComponent: ComponentType<any> | undefined
const LazyComponent = React.lazy(factory) as unknown as RouteComponent
const loadComponent = (): Promise<void> =>
factory().then((module) => {
LoadedComponent = module.default
})
const Component = (props: any): ReactElement =>
React.createElement(LoadedComponent || LazyComponent, props)
Component.preload = loadComponent
return Component
}
+14 -2
View File
@@ -1,10 +1,17 @@
import { useRouterStore } from './useRouterStore'
interface PushOptions {
/**
* If "false" the scroll offset will be kept across page navigation. Default "true"
*/
scroll?: boolean
}
interface UseRouterHook {
/**
* Redirects to the path passed as argument updating the browser history.
*/
push: (path: string) => void
push: (path: string, opt?: PushOptions) => void
/**
* This object contains all the query params of the current route
@@ -23,7 +30,8 @@ export const useRouter = (): UseRouterHook => {
st.updateLocation,
])
const push = (path: string): void => {
const push = (path: string, opt?: PushOptions): void => {
const { scroll = true } = opt || {}
const url = new URL(path, window.location.origin)
updateLocation({
@@ -34,6 +42,10 @@ export const useRouter = (): UseRouterHook => {
hash: url.hash,
})
history.pushState(path, '', path)
if (scroll) {
window.scroll(0, 0)
}
}
return {
+3 -3
View File
@@ -1,13 +1,13 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackViteConfig } from '@tanstack/config/vite'
import { tanstackBuildConfig } from '@tanstack/config/build'
import react from '@vitejs/plugin-react'
const config = defineConfig({
plugins: [react()],
test: {
name: 'react-router',
name: 'tuono-router',
watch: true,
environment: 'jsdom',
globals: true,
@@ -16,7 +16,7 @@ const config = defineConfig({
export default mergeConfig(
config,
tanstackViteConfig({
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "tuono",
"version": "0.10.2",
"version": "0.12.0",
"description": "The react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -84,10 +84,10 @@
"@babel/types": "^7.24.0",
"@mdx-js/rollup": "^3.0.1",
"@types/babel__core": "^7.20.5",
"@types/node": "^20.12.7",
"@types/node": "^22.0.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"fast-text-encoding": "^1.0.6",
"react-meta-tags": "^1.0.1",
"react-helmet-async": "^2.0.5",
"tuono-fs-router-vite-plugin": "workspace:*",
"tuono-lazy-fn-vite-plugin": "workspace:*",
"tuono-router": "workspace:*",
+4 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from 'tuono-router'
import { HelmetProvider } from 'react-helmet-async'
type RouteTree = any
@@ -14,7 +15,9 @@ export function hydrate(routeTree: RouteTree): void {
hydrateRoot(
rootElement,
<React.StrictMode>
<RouterProvider router={router} />
<HelmetProvider>
<RouterProvider router={router} />
</HelmetProvider>
</React.StrictMode>,
)
}
+1 -3
View File
@@ -1,4 +1,4 @@
import Head from 'react-meta-tags'
export { Helmet as Head } from 'react-helmet-async'
export {
createRoute,
@@ -11,6 +11,4 @@ export {
useRouter,
} from 'tuono-router'
export { Head }
export type { TuonoProps } from './types'
+13 -10
View File
@@ -1,8 +1,8 @@
import 'fast-text-encoding' // Mandatory for React18
import * as React from 'react'
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
import MetaTagsServer from 'react-meta-tags/server'
import { MetaTagsContext } from 'react-meta-tags'
import type { HelmetServerState } from 'react-helmet-async'
import { HelmetProvider } from 'react-helmet-async'
import { RouterProvider, createRouter } from 'tuono-router'
type RouteTree = any
@@ -44,23 +44,26 @@ export function serverSideRendering(routeTree: RouteTree) {
const cssBundles = props.cssBundles as string[]
const router = createRouter({ routeTree }) // Render the app
const metaTagsInstance = MetaTagsServer()
const helmetContext = {}
const app = renderToString(
<MetaTagsContext extract={metaTagsInstance.extract}>
<HelmetProvider context={helmetContext}>
<RouterProvider router={router} serverProps={props} />
</MetaTagsContext>,
</HelmetProvider>,
)
const metaTags = metaTagsInstance.renderToString()
const { helmet } = helmetContext as { helmet: HelmetServerState }
return `<!doctype html>
<html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${metaTags}
${helmet.title.toString()}
${helmet.priority.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
${helmet.script.toString()}
${generateCssLinks(cssBundles, mode)}
</head>
<body>
<body ${helmet.bodyAttributes.toString()}>
<div id="__tuono">${app}</div>
${renderToStaticMarkup(
<script
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackViteConfig } from '@tanstack/config/vite'
import { tanstackBuildConfig } from '@tanstack/config/build'
import react from '@vitejs/plugin-react'
const config = defineConfig({
@@ -10,7 +10,7 @@ const config = defineConfig({
export default mergeConfig(
config,
tanstackViteConfig({
tanstackBuildConfig({
entry: [
'./src/index.ts',
'./src/build/index.ts',