Compare commits

..

38 Commits

Author SHA1 Message Date
Valerio Ageno 2cbcd8ad70 feat: update version to v0.9.2 2024-08-16 11:28:42 +02:00
Valerio Ageno 9f40a55907 feat: add id to documentation headings 2024-08-16 11:27:29 +02:00
Valerio Ageno 51e01ec142 feat: add link support to hash 2024-08-16 11:21:18 +02:00
Valerio Ageno 63fc2b9855 fix: documentation mailto link 2024-08-16 11:05:49 +02:00
Valerio Ageno edd8a02423 fix: documentation type checking 2024-08-15 21:16:38 +02:00
Valerio Ageno a0a81b514c fix: documentation formatting 2024-08-15 21:08:48 +02:00
Valerio Ageno 92820804f8 fix: sidebar on mobile 2024-08-15 21:08:18 +02:00
Valerio Ageno e7ea7ed1c4 feat: improve contributing documentation 2024-08-15 21:07:28 +02:00
Valerio Ageno 7471c90f49 feat: update documentation tuono version to v0.9.1 2024-08-15 20:52:22 +02:00
Valerio Ageno f545a9c469 feat: update contributing and tutorial pages 2024-08-15 20:51:58 +02:00
Valerio Ageno 30073c226d feat: update version to v0.9.1 2024-08-15 20:44:25 +02:00
Valerio Ageno 1b1e91b6c0 fix: sanitize client side pathnames (#25) 2024-08-15 20:42:47 +02:00
Valerio Ageno 71d7ccf27d feat: update README header 2024-08-15 20:36:11 +02:00
Valerio Ageno 3ca24ec87f feat: create overview and getting started pages 2024-08-15 19:25:03 +02:00
Valerio Ageno b2940d9fa8 feat: add breadcrumbs to documentation 2024-08-15 18:52:56 +02:00
Valerio Ageno a7b7f0eac2 feat: update documentation sidebar 2024-08-15 18:20:20 +02:00
Valerio Ageno 4859916edd feat: update documentation sidebar 2024-08-15 11:31:33 +02:00
Valerio Ageno ad3e19e965 fix: documentation sidebar on mobile 2024-08-14 21:28:23 +02:00
Valerio Ageno 03dead1083 feat: update documentation tuono version to v0.9.0 2024-08-14 20:59:43 +02:00
Valerio Ageno 341fba6ca9 Preload chunks when links are visible in the viewport (#24)
* feat: preload chunk when links are visible in the viewport

* feat: update version to v0.9.0
2024-08-14 20:50:47 +02:00
Valerio Ageno 58c4890122 Update pull_request_template.md 2024-08-14 20:45:37 +02:00
Valerio Ageno af42e5ef21 fix: remove useless pendingElement 2024-08-14 14:48:26 +02:00
Valerio Ageno 5abff0384c feat: update documentation navbar 2024-08-14 14:45:47 +02:00
Valerio Ageno cc915560b8 update documentation tuono version to v0.8.4 2024-08-14 14:41:22 +02:00
Valerio Ageno 9835a4c0d1 fix: route switch wrappers re-rendering (#23)
* fix: route switch wrappers re-rendering

* feat: update version to v0.8.4
2024-08-14 14:26:52 +02:00
Valerio Ageno 90782ae710 fix: turbo dev task config 2024-08-11 18:19:38 +02:00
Valerio Ageno 75c790dd6e refactor: create standalone router package (#22)
* refactor: create standalone router package

* fix: add jsdom to tuono-router package

* fix: remove test in tuono vite.config

* update turbo to latest

* fix: wait build before lint

* fix: router types
2024-08-11 17:14:54 +02:00
Valerio Ageno 8604858a61 fix: documentation formatting 2024-08-11 16:33:45 +02:00
Valerio Ageno d1b6162d3a Update contributing.mdx 2024-08-09 17:48:23 +02:00
Valerio Ageno a445644b47 Update contributing.mdx 2024-08-09 17:47:07 +02:00
Valerio Ageno ebb4434506 Update contributing.mdx 2024-08-09 17:40:42 +02:00
Valerio Ageno 36a53662a0 Update contributing.mdx 2024-08-09 17:40:18 +02:00
Valerio Ageno 569d95dc9c fix: routing page 2024-07-27 14:58:18 +02:00
Valerio Ageno bc4c4e03f0 feat: update documentation tuono version (#21) 2024-07-27 14:52:02 +02:00
Valerio Ageno 170c5ea098 Update static build html structures (#20)
* fix: improve static build output structure

* feat: update version to v0.8.3
2024-07-27 14:42:28 +02:00
Valerio Ageno 3fd5f166e2 fix: edit page github link 2024-07-27 11:40:24 +02:00
Valerio Ageno a2285a78a1 fix: documentation output folder 2024-07-27 11:38:18 +02:00
Valerio Ageno 1ae7f72de3 Create documentation website (#19)
* feat: scaffold documentation app

* feat: init documentation website with mantine

* feat: add theme button

* feat: setup mdx link component

* feat: setup basic documentation folder

* feat: add routing folder

* feat: scaffold homepage

* feat: refined navbar and hero

* feat: create pre mdx component

* feat: create mdx blockquote component

* feat: create code mdx component

* fix: remove HTMLProps type

* feat: create base hero page

* ci: add lint/formatting/type checking to documentation

* ci: add documentation pipeline

* fix: static build documentation

* fix: remove documentation test check

* fix: install deps outside workspace

* fix: filter prettier checks

* fix: eslint plugins

* fix: clone eslintrc

* feat: update dark theme colors

* doc: add installation page

* feat: add deploy documenation CD pipeline

* fix: update documentation bucket region

* feat: update documentation head tags

* fix: deploy website on main branch push
2024-07-27 11:33:34 +02:00
107 changed files with 1683 additions and 285 deletions
+3 -8
View File
@@ -1,16 +1,11 @@
## Context ## Context & Description
<!-- <!--
Thank you for your Pull Request.
Explain the context and why you're making that change. What is the problem Explain the context and why you're making that change. What is the problem
you're trying to solve? If a new feature is being added, describe the intended you're trying to solve? If a new feature is being added, describe the intended
use case that feature fulfills. use case that feature fulfills.
-->
## Description
<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.
Bug fixes and new features should include tests. Bug fixes and new features should include tests.
+75
View File
@@ -0,0 +1,75 @@
name: Documentation Website CI
on:
push:
paths:
- '.github/**'
- 'apps/documentation/**'
pull_request:
paths:
- '.github/**'
- 'apps/documentation/**'
jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
timeout-minutes: 15
runs-on: ubuntu-latest
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
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install tuono
run: cargo install tuono@0.8.3
- 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
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
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
- name: Check formatting
run: pnpm format:check
- name: Lint
run: pnpm lint
- name: Types
run: pnpm types
@@ -0,0 +1,56 @@
name: Deploy documentation website on AWS S3
on:
push:
branches:
- main
paths:
- '.github/**'
- 'apps/documentation/**'
jobs:
deploy-documentation:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/documentation
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install tuono
run: cargo install tuono@0.8.3
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --ignore-workspace
- name: Build project
run: tuono build --static
- name: Deploy
uses: reggionick/s3-deploy@v4
with:
folder: apps/documentation/out/static
bucket: tuono-documentation
bucket-region: eu-west-3
dist-id: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
invalidation: /
delete-removed: true
no-cache: true
private: true
files-to-include: '{.*/**,**}'
+1
View File
@@ -6,6 +6,7 @@ members = [
"crates/tuono_lib_macros", "crates/tuono_lib_macros",
] ]
exclude = [ exclude = [
"apps/documentation",
"examples/mdx", "examples/mdx",
"examples/tuono", "examples/tuono",
"examples/tutorial" "examples/tutorial"
+10 -10
View File
@@ -1,15 +1,15 @@
<p align="center"> [Documentation](https://tuono.dev) | [Tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) |
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px"> [✨Contributing](https://tuono.dev/documentation/contributing)
</p>
<h1 align="center">Tuono<br>The react/rust fullstack framework</h1>
<div align="center">
<img src="https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg" />
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg" />
</div>
<br> # Tuono
<br>
<img align="right" src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="160px">
![](https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg)
![](https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg)
**Tuono** is the react/rust fullstack framework.
Tuono (Italian word for "thunder", pronounced /2 Oh No/). Tuono (Italian word for "thunder", pronounced /2 Oh No/).
Why Tuono? Just a badass name. Why Tuono? Just a badass name.
+1
View File
@@ -0,0 +1 @@
# Stadalone apps folder
+103
View File
@@ -0,0 +1,103 @@
{
"root": true,
"reportUnusedDisableDirectives": true,
"ignorePatterns": ["**/build", "**/dist"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "import"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier",
],
"env": {
"browser": true,
"es2020": true,
},
"parserOptions": {
"tsconfigRootDir": ".",
"project": true,
"sourceType": "module",
"ecmaVersion": 2020,
},
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"],
},
"import/resolver": {
"typescript": true,
},
"react": {
"version": "detect",
},
},
"rules": {
"@typescript-eslint/array-type": "error",
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{ "prefer": "type-imports" },
],
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/method-signature-style": ["error", "property"],
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "typeParameter",
"format": ["PascalCase"],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid",
"custom": {
"regex": "^(T|T[A-Z][A-Za-z]+)$",
"match": true,
},
},
],
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-inferrable-types": [
"error",
{ "ignoreParameters": true },
],
"import/default": "error",
"import/export": "error",
"import/namespace": "error",
"import/newline-after-import": "error",
"import/no-cycle": "error",
"import/no-duplicates": "off",
"import/no-named-as-default-member": "error",
"import/no-unused-modules": "error",
"import/order": [
"off",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type",
],
},
],
"no-case-declarations": "error",
"no-empty": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-shadow": "error",
"no-undef": "off",
"sort-imports": "off",
},
}
+13
View File
@@ -0,0 +1,13 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.tuono
out
target
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "tuono"
version = "0.0.1"
edition = "2021"
[[bin]]
name = "tuono"
path = ".tuono/main.rs"
[dependencies]
tuono_lib = "0.9.1"
serde = { version = "1.0.202", features = ["derive"] }
+7
View File
@@ -0,0 +1,7 @@
# Tuono starter
This is the starter tuono project. To download it run in your terminal:
```shell
$ tuono new [NAME]
```
+40
View File
@@ -0,0 +1,40 @@
{
"name": "tuono",
"description": "The react/rust fullstack framework",
"version": "0.0.1",
"scripts": {
"lint": "eslint --ext .ts,.tsx ./src -c .eslintrc",
"format": "prettier -u --write --ignore-unknown './src/**/*'",
"format:check": "prettier --check --ignore-unknown './src/**/*'",
"types": "tsc --noEmit"
},
"dependencies": {
"@mantine/code-highlight": "^7.11.2",
"@mantine/core": "^7.11.2",
"@mantine/hooks": "^7.11.2",
"@mdx-js/react": "^3.0.1",
"@tabler/icons-react": "^3.11.0",
"clsx": "^2.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"tuono": "0.9.1"
},
"devDependencies": {
"@types/mdx": "^2.0.13",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.39",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.2.4",
"typescript": "^5.5.4"
}
}
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,54 @@
import { Breadcrumbs, Button } from '@mantine/core'
import { Link } from 'tuono'
import { IconChevronRight, IconBolt } from '@tabler/icons-react'
import type { ReactNode } from 'react'
export default function TuonoBreadcrumbs({
children,
}: {
children: ReactNode
}): 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}
>
<IconBolt />
</Button>
{children}
</Breadcrumbs>
)
}
interface BreadcrumbElementProps {
href?: string
label: string
}
export function BreadcrumbElement({
href,
label,
}: BreadcrumbElementProps): JSX.Element {
if (href) {
return (
<Button component={Link} href={href} variant="subtle" radius="xl" px={10}>
{label}
</Button>
)
}
return (
<Button component="span" variant="light" radius="xl">
{label}
</Button>
)
}
@@ -0,0 +1,5 @@
import Breadcrumbs, { BreadcrumbElement } from './breadcrumbs'
export default Breadcrumbs
export { BreadcrumbElement as Element }
@@ -0,0 +1,23 @@
import { Button } from '@mantine/core'
import { IconEdit } from '@tabler/icons-react'
import { useRouter } from 'tuono'
const GITHUB_URL =
'https://github.com/Valerioageno/tuono/tree/main/apps/documentation/src/routes'
export default function EditPage(): JSX.Element {
const { pathname } = useRouter()
return (
<Button
p={0}
mt={60}
component="a"
variant="transparent"
leftSection={<IconEdit />}
target="_blank"
href={GITHUB_URL.concat(pathname).concat('.mdx')}
>
Edit page
</Button>
)
}
@@ -0,0 +1,3 @@
import EditPage from './edit-page'
export default EditPage
@@ -0,0 +1,53 @@
import {
Button,
Center,
Container,
CopyButton,
Group,
rem,
Text,
Title,
} from '@mantine/core'
import { IconCopy, IconCheck } from '@tabler/icons-react'
import { Link } from 'tuono'
export default function Hero(): JSX.Element {
return (
<Container size={1100} mt={200}>
<Center>
<Title order={1}>The react/rust fullstack framework</Title>
</Center>
<Center mt={50}>
<Text fz="18px">
The technologies we love seamessly working together to unleash the
<strong> highest web performance</strong> ever met on react
</Text>
</Center>
<Center mt={50}>
<Group>
<Button component={Link} href="/documentation" size="lg">
Get Started
</Button>
<CopyButton value="cargo install tuono">
{({ copied, copy }) => (
<Button
onClick={copy}
size="lg"
style={{ border: 'solid 1px var(--mantine-color-violet-1)' }}
color="gray"
leftSection="cargo install tuono"
rightSection={
copied ? (
<IconCheck style={{ width: rem(20) }} />
) : (
<IconCopy style={{ width: rem(20) }} />
)
}
/>
)}
</CopyButton>
</Group>
</Center>
</Container>
)
}
@@ -0,0 +1,3 @@
import Hero from './hero'
export default Hero
@@ -0,0 +1,3 @@
import MdxProvider from './mdx-provider'
export default MdxProvider
@@ -0,0 +1,5 @@
import { Text, type TextProps } from '@mantine/core'
export default function MdxBold(props: TextProps): JSX.Element {
return <Text fw={700} {...props} />
}
@@ -0,0 +1,3 @@
import MdxCode from './mdx-code'
export default MdxCode
@@ -0,0 +1,8 @@
import { Code } from '@mantine/core'
import type { HTMLAttributes } from 'react'
export default function MdxCode(
props: HTMLAttributes<HTMLPreElement>,
): JSX.Element {
return <Code {...props} />
}
@@ -0,0 +1,17 @@
import { Title } from '@mantine/core'
import { useRef, type HTMLAttributes } from 'react'
export default function MdxH2(
props: HTMLAttributes<HTMLHeadingElement>,
): JSX.Element {
return (
<Title
{...props}
mt={20}
order={2}
id={String(props.children ?? '')
.toLowerCase()
.replaceAll(' ', '-')}
/>
)
}
@@ -0,0 +1,3 @@
import MdxLink from './mdx-link'
export default MdxLink
@@ -0,0 +1,36 @@
import type { AnchorHTMLAttributes } from 'react'
import { Button } from '@mantine/core'
import { Link } from 'tuono'
import { IconExternalLink } from '@tabler/icons-react'
export default function MdxLink(
props: AnchorHTMLAttributes<HTMLAnchorElement>,
): JSX.Element {
if (props.href?.startsWith('http') || props.href?.startsWith('mailto')) {
return (
<Button
component="a"
{...props}
target="_blank"
rightSection={
<IconExternalLink size="16px" style={{ marginLeft: -5 }} />
}
variant="transparent"
style={{ height: '20px' }}
mt={-2}
p={0}
/>
)
}
return (
<Button
component={Link}
{...props}
target="_blank"
variant="transparent"
style={{ height: '20px' }}
mt={-2}
p={0}
/>
)
}
@@ -0,0 +1,3 @@
import MdxPre from './mdx-pre'
export default MdxPre
@@ -0,0 +1,5 @@
.pre {
code {
font-size: 16px;
}
}
@@ -0,0 +1,16 @@
import { CodeHighlight } from '@mantine/code-highlight'
import styles from './mdx-pre.module.css'
interface PreProps {
children: any
}
export default function MdxPre({ children }: PreProps): JSX.Element {
return (
<CodeHighlight
className={styles.pre}
style={{ borderRadius: 8 }}
code={children.props.children || ''}
language={children.props.className?.replace('language-', '')}
/>
)
}
@@ -0,0 +1,35 @@
import { MDXProvider } from '@mdx-js/react'
import MdxLink from './mdx-link'
import type { ReactNode } from 'react'
import MdxPre from './mdx-pre'
import MdxQuote from './mdx-quote'
import MdxCode from './mdx-code'
import MdxTitle from './mdx-title'
import MdxBold from './mdx-bold/mdx-bold'
import MdxH2 from './mdx-h2/mdx-h2'
interface MdxProviderProps {
children: ReactNode
}
export default function MdxProvider({
children,
}: MdxProviderProps): JSX.Element {
return (
<MDXProvider
components={{
a: MdxLink,
// @ts-expect-error: useless finding the correct props types
pre: MdxPre,
blockquote: MdxQuote,
code: MdxCode,
h1: MdxTitle,
h2: MdxH2,
strong: MdxBold,
}}
>
{children}
</MDXProvider>
)
}
@@ -0,0 +1,3 @@
import MdxQuote from './mdx-quote'
export default MdxQuote
@@ -0,0 +1,21 @@
import { Blockquote, Space } from '@mantine/core'
import type { HTMLAttributes } from 'react'
export default function MdxQuote(
props: HTMLAttributes<HTMLQuoteElement>,
): JSX.Element {
return (
<>
<Blockquote
color="violet"
py={1}
px={20}
mt={30}
iconSize={30}
{...props}
style={{ borderRadius: 8 }}
/>
<Space h="md" />
</>
)
}
@@ -0,0 +1,3 @@
import MdxTitle from './mdx-title'
export default MdxTitle
@@ -0,0 +1,16 @@
import { Title } from '@mantine/core'
import type { HTMLAttributes } from 'react'
export default function MdxTitle(
props: HTMLAttributes<HTMLHeadingElement>,
): JSX.Element {
return (
<Title
{...props}
order={1}
id={String(props.children ?? '')
.toLowerCase()
.replaceAll(' ', '-')}
/>
)
}
@@ -0,0 +1,33 @@
import { Flex, Button, ActionIcon, Group } from '@mantine/core'
import { IconBrandGithub, IconBook } from '@tabler/icons-react'
import ThemeBtn from '../theme-btn'
import { Link } from 'tuono'
export default function Actions(): JSX.Element {
return (
<Flex gap={8}>
<Button
href="/documentation"
component={Link}
size="compact-lg"
rightSection={<IconBook />}
autoContrast
>
Get started
</Button>
<Group gap={8} visibleFrom="sm">
<ActionIcon
variant="default"
size="lg"
aria-label="Check the project on github"
href="https://github.com/Valerioageno/tuono"
target="_blank"
component="a"
>
<IconBrandGithub />
</ActionIcon>
<ThemeBtn />
</Group>
</Flex>
)
}
@@ -0,0 +1,3 @@
import Navbar from './navbar'
export default Navbar
@@ -0,0 +1,32 @@
import { AppShell, Burger, Button, Flex } from '@mantine/core'
import { Link, useRouter } from 'tuono'
import Actions from './actions'
interface NavbarProps {
opened: boolean
toggle: () => void
}
export default function Navbar({ opened, toggle }: NavbarProps): JSX.Element {
const { pathname } = useRouter()
return (
<AppShell.Header p="sm">
<Flex justify="space-between">
<Button component={Link} href="/" variant="transparent" p={0} fz={28}>
Tuono
</Button>
<Flex align="center" gap={8}>
<Actions />
{pathname.startsWith('/documentation') && (
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
)}
</Flex>
</Flex>
</AppShell.Header>
)
}
@@ -0,0 +1,3 @@
import Sidebar from './sidebar'
export default Sidebar
@@ -0,0 +1,6 @@
.link {
border-radius: 8px;
margin-top: 0.25rem;
line-height: 1.25rem;
font-weight: bold;
}
@@ -0,0 +1,46 @@
import { NavLink, type NavLinkProps } from '@mantine/core'
import { useState, type ReactNode } from 'react'
import { Link, useRouter } from 'tuono'
import { IconChevronRight } from '@tabler/icons-react'
import styles from './sidebar-link.module.css'
interface SidebarLinkProps {
label: string
href: string
onClick?: () => void
children?: ReactNode
}
export default function SidebarLink(
props: SidebarLinkProps & NavLinkProps,
): JSX.Element {
const { pathname } = useRouter()
const [isOpen, setIsOpen] = useState<boolean>(!!props.defaultOpened)
const internalProps = {
active: pathname === props.href,
className: styles.link,
rightSection: props.children && (
<IconChevronRight
size="1.2rem"
stroke={1.5}
className="mantine-rotate-rtl"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsOpen((state) => !state)
}}
/>
),
opened: isOpen,
autoContrast: true,
...props,
}
if (props.href.startsWith('#')) {
return <NavLink component="button" {...internalProps} />
}
return <NavLink component={Link} {...internalProps} />
}
@@ -0,0 +1,45 @@
import { AppShell } from '@mantine/core'
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"
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"
onClick={close}
/>
<SidebarLink
label="Routing"
href="/documentation/routing"
defaultOpened
onClick={close}
>
<SidebarLink
href="/documentation/routing/intro"
label="Project structure"
onClick={close}
/>
</SidebarLink>
<SidebarLink
label="Contributing"
href="/documentation/contributing"
onClick={close}
leftSection="✨"
/>
</AppShell.Navbar>
)
}
@@ -0,0 +1,3 @@
import ThemeBtn from './theme-btn'
export default ThemeBtn
@@ -0,0 +1,24 @@
.icon {
width: 22px;
height: 22px;
}
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}
@@ -0,0 +1,30 @@
import {
ActionIcon,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core'
import { IconSun, IconMoon } from '@tabler/icons-react'
import cx from 'clsx'
import classes from './theme-btn.module.css'
export default function ThemeBtn(): JSX.Element {
const { setColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: true,
})
return (
<ActionIcon
onClick={() =>
setColorScheme(computedColorScheme === 'light' ? 'dark' : 'light')
}
variant="default"
size="lg"
aria-label="Toggle color scheme"
>
<IconSun className={cx(classes.icon, classes.light)} stroke={1.5} />
<IconMoon className={cx(classes.icon, classes.dark)} stroke={1.5} />
</ActionIcon>
)
}
+2
View File
@@ -0,0 +1,2 @@
// declaration.d.ts
declare module '*.css'
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react'
import {
ColorSchemeScript,
createTheme,
MantineProvider,
AppShell,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { Head, useRouter } from 'tuono'
import Navbar from '../components/navbar'
import '@mantine/core/styles.css'
import '@mantine/code-highlight/styles.css'
import Sidebar from '../components/sidebar'
interface RootRouteProps {
children: ReactNode
}
const theme = createTheme({
primaryColor: 'violet',
primaryShade: { light: 6, dark: 9 },
fontFamily: 'Roboto',
respectReducedMotion: true,
fontSizes: {
// 'xs' | 'sm' | 'md' | 'lg' | 'xl'
xs: '16px',
sm: '16px',
},
colors: {
dark: [
'#d5d7e0',
'#acaebf',
'#8c8fa3',
'#666980',
'#4d4f66',
'#34354a',
'#2b2c3d',
'#1d1e30',
'#0c0d21',
'#01010a',
],
},
headings: {
sizes: {
h1: {
fontSize: '48px',
},
},
},
})
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
const [opened, { toggle }] = useDisclosure()
const { pathname } = useRouter()
return (
<>
<Head>
<ColorSchemeScript />
</Head>
<MantineProvider theme={theme}>
<AppShell
header={{ height: 60 }}
navbar={{
width: 300,
breakpoint: 'sm',
collapsed: { mobile: !opened },
}}
>
<Navbar opened={opened} toggle={toggle} />
{pathname.startsWith('/documentation') && <Sidebar close={toggle} />}
{children}
</AppShell>
</MantineProvider>
</>
)
}
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { AppShell, Container } from '@mantine/core'
import MdxProvider from '../../components/mdx-provider'
import EditPage from '../../components/edit-page'
interface RootRouteProps {
children: ReactNode
}
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
return (
<AppShell.Main>
<Container component="article" p={20}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
</AppShell.Main>
)
}
@@ -0,0 +1,48 @@
import { Head } from 'tuono'
<Head>
<title>Tuono - Contributing</title>
</Head>
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Breadcrumbs>
<Element label="✨ Contributing" />
</Breadcrumbs>
# Contributing
## TL;DR
The project is massive - if you like it do consider to contribute!
## Getting started
The `tuono` project can mostly be split by the following sub-domains:
- The CLI
- The rust backend
- The react frontend
- The documentation website (which is written with tuono 🚀)
To check what are the knowledge requirement for each domain check the
[requirements](#requirements) section below.
Currently I'm keeping a private dashboard to prioritize new features and bug fixes but if you
want to propose something please open a new issue on github or reach me using
my email address [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it). I'm also available
on twitter (X) DMs `@valerioageno`, [linkedin](https://www.linkedin.com/in/valerioageno)
and discord `@__v__v__`.
## Requirements
It's not strictly required to know both React (& typescript) and Rust (even though it
would be a great nice to have).
Without taking into account specific cases we can mostly split the domain requirements by:
- The `CLI` needs Rust knowledge (even though a couple of scenario might need also Typescript)
- The Backend needs just `Rust`
- The Frontend needs just `React` & `Typescript`
- The documentation website needs just `React` & `Typescript` (or even less since most of the
code is markdown).
@@ -0,0 +1,28 @@
import { Head } from 'tuono'
<Head>
<title>Tuono - Getting started</title>
</Head>
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Getting started" />
</Breadcrumbs>
# Getting started
Tuono is the CLI that provides all the needed commands to handle the fullstack project.
> ☝️ Check the [installation](/documentation/installation) page if you haven't installed the
> `tuono` CLI yet.
To list all the available commands run `tuono -h`.
To create a new project run `tuono new [NAME]` (optionally you can pass the --template (or -t) flag - check the examples folder).
Then to run the local development environment run inside the project folder `tuono dev`
Finally when the project will be ready to be deployed just run `tuono build` to create the final React assets and to set the server project in the production mode.
Now to execute it just run `cargo run --release`.
@@ -0,0 +1,48 @@
import { Head } from 'tuono'
<Head>
<title>Tuono - Documentation</title>
</Head>
import Breadcrumbs from '../../components/breadcrumbs'
<Breadcrumbs></Breadcrumbs>
# Tuono
**Tuono is a fullstack framework for building React applications using Rust as
backend.**
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 fullstack right? (no)**
Tuono is a fullstack React framework with the server side written in Rust.
Because of this Tuono is extremely fast and the requests are handled by multithreaded Rust server.
React is still React - it is just superpowered.
**Rust is an hard language then writing server side code is hard as well right? (no again)**
Tuono provides a collection of utilities to handle the server side code seamlessly with
the React code. Each server side route is managed with a separate file alongside the React route.
The routing is handled by Tuono based on the files defined within the `./src/routes` directory.
## Features
Some of its features include:
- 🟦 Full typescript support
- 🌐 File system based routing
- 🔥 Hot Module Reload
- 🍭 SCSS/CSS modules
- 🧬 Server Side Rendering
- 🧵 Multi thread backend
- ⌨️ MDX support
- ⚙️ Build optimizations
- Custom APIs\*
- Image optimization\*
- Server streamed content\*
> \*development in progress
@@ -0,0 +1,56 @@
import { Head } from 'tuono'
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Head>
<title>Tuono - Installation</title>
</Head>
<Breadcrumbs>
<Element label="Installation" />
</Breadcrumbs>
# Installation
## Requirements
`Tuono` is a development environment built in rust and typescript that outputs a website written with both the
languages. Since that you need the following tools installed on your computer to work with it:
- `Rust` - The rust programming language toolchain (Go [here](https://rustup.rs/) for installing both `rust` and `cargo`)
- `Cargo` - The rust package manager
- `NodeJs` - The Javascript runtime (Go [here](https://nodejs.org/en/download/package-manager) for installing both `nodejs` and `npm`)
- `npm` or `yarn` or `pnpm` - A javascript package manager
> NodeJs is needed just for the development environment. The final output will run on a rust server.
## 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.
```bash
The react/rust fullstack 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
```
@@ -0,0 +1,15 @@
import { Head } from 'tuono'
<Head>
<title>Tuono - Routing</title>
</Head>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Routing" />
</Breadcrumbs>
# Routing index
TODO
@@ -0,0 +1,16 @@
import { Head } from 'tuono'
<Head>
<title>Tuono - Routing</title>
</Head>
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Routing" href="/documentation/routing" />
<Element label="Project structure" />
</Breadcrumbs>
# Project structure
Todo
@@ -0,0 +1,17 @@
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.
+17
View File
@@ -0,0 +1,17 @@
import { Head } from 'tuono'
import Hero from '../components/hero'
export default function IndexPage(): JSX.Element {
return (
<>
<Head>
<title>Tuono - The react/rust fullstack framework</title>
<meta
name="description"
content="The technologies we love seamessly working together to unleash the highest web performance ever met on react"
/>
</Head>
<Hero />
</>
)
}
+1
View File
@@ -0,0 +1 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tuono" name = "tuono"
version = "0.8.2" version = "0.9.2"
edition = "2021" edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"] authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework" description = "The react/rust fullstack framework"
+1
View File
@@ -63,6 +63,7 @@ impl App {
.unwrap() .unwrap()
.replace(&format!("{base_path_str}/src/routes"), "") .replace(&format!("{base_path_str}/src/routes"), "")
.replace(".rs", "") .replace(".rs", "")
.replace(".mdx", "")
.replace(".tsx", ""); .replace(".tsx", "");
if entry.extension().unwrap() == "rs" { if entry.extension().unwrap() == "rs" {
+3 -3
View File
@@ -104,9 +104,9 @@ pub fn app() -> std::io::Result<()> {
sleep(Duration::from_secs(1)) sleep(Duration::from_secs(1))
} }
app.route_map for (_, route) in app.route_map {
.iter() route.save_ssg_html(&reqwest)
.for_each(|(_, route)| route.save_ssg_html(&reqwest)); }
// Close server // Close server
let _ = rust_server.kill(); let _ = rust_server.kill();
+24 -1
View File
@@ -81,7 +81,7 @@ impl Route {
.send() .send()
.unwrap(); .unwrap();
let file_path = PathBuf::from(format!("out/static{}.html", &self.path)); let file_path = self.html_file_path();
let parent_dir = file_path.parent().unwrap(); let parent_dir = file_path.parent().unwrap();
@@ -123,6 +123,11 @@ impl Route {
io::copy(&mut response, &mut data_file).expect("Failed to write the JSON on the file"); io::copy(&mut response, &mut data_file).expect("Failed to write the JSON on the file");
} }
} }
fn html_file_path(&self) -> PathBuf {
let cleaned_path = self.path.replace("index", "");
PathBuf::from(format!("out/static{}/index.html", cleaned_path))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -162,4 +167,22 @@ mod tests {
assert_eq!(dyn_info.axum_route, "/:posts"); assert_eq!(dyn_info.axum_route, "/:posts");
assert_eq!(dyn_info.module_import, "dyn_posts"); assert_eq!(dyn_info.module_import, "dyn_posts");
} }
#[test]
fn should_define_the_correct_html_build_path() {
let routes = [
("/index", "out/static/index.html"),
("/documentation", "out/static/documentation/index.html"),
(
"/documentation/routing",
"out/static/documentation/routing/index.html",
),
];
for (path, html) in routes {
let route = Route::new(path.to_string());
assert_eq!(route.html_file_path(), PathBuf::from(html))
}
}
} }
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tuono_lib" name = "tuono_lib"
version = "0.8.2" version = "0.9.2"
edition = "2021" edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"] authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework" description = "The react/rust fullstack framework"
@@ -32,7 +32,7 @@ regex = "1.10.5"
either = "1.13.0" either = "1.13.0"
tower-http = {version = "0.5.2", features = ["fs"]} tower-http = {version = "0.5.2", features = ["fs"]}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.8.2"} tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.9.2"}
# Match the same version used by axum # Match the same version used by axum
tokio-tungstenite = "0.21.0" tokio-tungstenite = "0.21.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tuono_lib_macros" name = "tuono_lib_macros"
version = "0.8.2" version = "0.9.2"
edition = "2021" edition = "2021"
description = "The react/rust fullstack framework" description = "The react/rust fullstack framework"
keywords = [ "react", "typescript", "fullstack", "web", "ssr"] keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
+9 -8
View File
@@ -2,19 +2,20 @@
"name": "workspace", "name": "workspace",
"packageManager": "pnpm@9.1.1", "packageManager": "pnpm@9.1.1",
"scripts": { "scripts": {
"dev": "turbo dev --filter tuono", "dev": "turbo watch dev",
"build": "turbo build --filter tuono", "build": "turbo build --filter tuono",
"lint": "turbo lint --filter tuono", "lint": "turbo lint",
"format": "turbo format --filter tuono", "format": "turbo format",
"format:check": "turbo format:check --filter tuono", "format:check": "turbo format:check",
"types": "turbo types --filter tuono", "types": "turbo types --filter tuono",
"test": "turbo test", "test": "turbo test",
"test:watch": "turbo test:watch" "test:watch": "turbo test:watch"
}, },
"workspaces": [ "workspaces": [
"tuono", "tuono",
"tuono-lazy-fn-vite-plugin", "tuono-lazy-fn-vite-plugin",
"tuono-fs-router-vite-plugin" "tuono-fs-router-vite-plugin",
"tuono-router"
], ],
"author": "Valerio Ageno", "author": "Valerio Ageno",
"license": "MIT", "license": "MIT",
@@ -37,6 +38,6 @@
"vitest": "^1.5.2" "vitest": "^1.5.2"
}, },
"dependencies": { "dependencies": {
"turbo": "^2.0.4" "turbo": "^2.0.12"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tuono-fs-router-vite-plugin", "name": "tuono-fs-router-vite-plugin",
"version": "0.8.2", "version": "0.9.2",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework", "description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"scripts": { "scripts": {
"dev": "vite build --watch", "dev": "vite build --watch",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tuono-lazy-fn-vite-plugin", "name": "tuono-lazy-fn-vite-plugin",
"version": "0.8.2", "version": "0.9.2",
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework", "description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
"scripts": { "scripts": {
"dev": "vite build --watch", "dev": "vite build --watch",
@@ -1,3 +1,3 @@
export const TUONO_DYNAMIC_FN_ID = 'dynamic' export const TUONO_DYNAMIC_FN_ID = 'dynamic'
export const REACT_LAZY_FN_ID = 'lazy' export const TUONO_LAZY_FN_ID = 'lazyLoadComponent'
export const TUONO_MAIN_PACKAGE = 'tuono' export const TUONO_MAIN_PACKAGE = 'tuono'
+17 -37
View File
@@ -5,7 +5,7 @@ import type { PluginItem } from '@babel/core'
import { import {
TUONO_MAIN_PACKAGE, TUONO_MAIN_PACKAGE,
TUONO_DYNAMIC_FN_ID, TUONO_DYNAMIC_FN_ID,
REACT_LAZY_FN_ID, TUONO_LAZY_FN_ID,
} from './constants' } from './constants'
import * as t from '@babel/types' import * as t from '@babel/types'
@@ -19,6 +19,7 @@ import type {
} from '@babel/types' } from '@babel/types'
/** /**
* [SERVER build]
* This plugin just removes the `dynamic` imported function from any tuono import * This plugin just removes the `dynamic` imported function from any tuono import
*/ */
const RemoveTuonoLazyImport: PluginItem = { const RemoveTuonoLazyImport: PluginItem = {
@@ -38,49 +39,28 @@ const RemoveTuonoLazyImport: PluginItem = {
} }
/** /**
* This plugin adds: "Import { lazy } from 'react'" * [CLIENT build]
* and translate dynamic call into a React.lazy call * This plugin replace the `dynamic` function with the `lazyLoadComponent` one
*/ */
const ImportReactLazy: PluginItem = { const ReplaceTuonoLazyImport: PluginItem = {
name: 'import-react-lazy-plugin', name: 'remove-tuono-lazy-import-plugin',
visitor: { visitor: {
// Add the import statement ImportSpecifier: (path) => {
Program: (path: any) => { if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) {
let isReactImported = false if (
(path.parentPath.node as ImportDeclaration).source.value ===
path.node.body.forEach((val: any) => { TUONO_MAIN_PACKAGE
if (val.type === 'ImportDeclaration' && val.source.value === 'react') { ) {
isReactImported = true ;(path.node.imported as Identifier).name = TUONO_LAZY_FN_ID
// TODO: Handle also here case of already imported react
// Right now works just for the main routes file
} }
})
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!isReactImported) {
const importDeclaration = t.importDeclaration(
[
t.importSpecifier(
t.identifier(REACT_LAZY_FN_ID),
t.identifier(REACT_LAZY_FN_ID),
),
],
t.stringLiteral('react'),
)
path.unshiftContainer('body', importDeclaration)
}
},
// Update lazy function name from `dynamic` to `lazy`
CallExpression: (path: any) => {
if (path.node.callee?.name === TUONO_DYNAMIC_FN_ID) {
path.node.callee.name = REACT_LAZY_FN_ID
} }
}, },
}, },
} }
/** /**
* For the server side we need to statically import the lazy loaded components * [SERVER build]
* This plugin statically imports the lazy loaded components
*/ */
const TurnLazyIntoStaticImport: PluginItem = { const TurnLazyIntoStaticImport: PluginItem = {
name: 'turn-lazy-into-static-import-plugin', name: 'turn-lazy-into-static-import-plugin',
@@ -124,8 +104,8 @@ export function LazyLoadingPlugin(): Plugin {
plugins: [ plugins: [
['@babel/plugin-syntax-jsx', {}], ['@babel/plugin-syntax-jsx', {}],
['@babel/plugin-syntax-typescript', { isTSX: true }], ['@babel/plugin-syntax-typescript', { isTSX: true }],
[RemoveTuonoLazyImport], [!opts?.ssr ? ReplaceTuonoLazyImport : []],
[!opts?.ssr ? ImportReactLazy : []], [opts?.ssr ? RemoveTuonoLazyImport : []],
[opts?.ssr ? TurnLazyIntoStaticImport : []], [opts?.ssr ? TurnLazyIntoStaticImport : []],
], ],
sourceMaps: true, sourceMaps: true,
@@ -10,10 +10,9 @@ const PokemonspokemonImport = dynamic(
) )
` `
const CLIENT_RESULT = `import { lazy } from "react"; const CLIENT_RESULT = `import { createRoute, lazyLoadComponent as dynamic } from 'tuono';
import { createRoute } from 'tuono'; const IndexImport = dynamic(() => import('./../src/routes/index'));
const IndexImport = lazy(() => import('./../src/routes/index')); const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));`
const PokemonspokemonImport = lazy(() => import('./../src/routes/pokemons/[pokemon]'));`
const SERVER_RESULT = `import { createRoute } from 'tuono'; const SERVER_RESULT = `import { createRoute } from 'tuono';
import IndexImport from "./../src/routes/index"; import IndexImport from "./../src/routes/index";
+2
View File
@@ -0,0 +1,2 @@
dist
pnpm-lock.yaml
+5
View File
@@ -0,0 +1,5 @@
# tuono-router
This package holds the logic related to the client/server react routing.
Check [tuono](https://github.com/Valerioageno/tuono) for more.
+57
View File
@@ -0,0 +1,57 @@
{
"name": "tuono-router",
"version": "0.9.2",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write --ignore-unknown '**/*'",
"format:check": "prettier --check --ignore-unknown '**/*'",
"types": "tsc --noEmit",
"test:watch": "vitest",
"test": "vitest run"
},
"keywords": [],
"author": "Valerio Ageno",
"license": "MIT",
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"files": [
"dist",
"src",
"README.md"
],
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"dependencies": {
"react-intersection-observer": "^9.13.0",
"vite": "^5.2.11",
"zustand": "4.4.7"
},
"devDependencies": {
"@tanstack/config": "^0.7.11",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"jsdom": "^24.0.0",
"prettier": "^3.2.4",
"vitest": "^1.5.2"
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react'
import { useRouter } from '../hooks/useRouter'
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
import useRoute from '../hooks/useRoute'
import { useInView } from 'react-intersection-observer'
interface TuonoLinkProps {
preload?: boolean
}
export default function Link(
componentProps: AnchorHTMLAttributes<HTMLAnchorElement> & TuonoLinkProps,
): JSX.Element {
const { preload = true, ...props } = componentProps
const router = useRouter()
const route = useRoute(props.href)
const { ref } = useInView({
onChange(inView) {
if (inView && preload) route?.component.preload()
},
triggerOnce: true,
})
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
props.onClick?.(e)
if (props.href?.startsWith('#')) {
window.location.hash = props.href
return
}
router.push(props.href || '')
}
return (
<a {...props} ref={ref} onClick={handleTransition}>
{props.children}
</a>
)
}
@@ -0,0 +1,22 @@
import * as React from 'react'
import { useRouterStore } from '../hooks/useRouterStore'
import { RouteMatch } from './RouteMatch'
import NotFound from './NotFound'
import useRoute from '../hooks/useRoute'
interface MatchesProps {
// user defined props
serverSideProps: any
}
export function Matches({ serverSideProps }: MatchesProps): JSX.Element {
const location = useRouterStore((st) => st.location)
const route = useRoute(location.pathname)
if (!route) {
return <NotFound />
}
return <RouteMatch route={route} serverSideProps={serverSideProps} />
}
@@ -14,7 +14,7 @@ const root = {
component: ({ children }: Props) => ( component: ({ children }: Props) => (
<div data-testid="root">root route {children}</div> <div data-testid="root">root route {children}</div>
), ),
} as Route } as unknown as Route
const parent = { const parent = {
component: ({ children }: Props) => ( component: ({ children }: Props) => (
@@ -23,14 +23,14 @@ const parent = {
options: { options: {
getParentRoute: () => root, getParentRoute: () => root,
}, },
} as Route } as unknown as Route
const route = { const route = {
component: () => <p data-testid="route">current route</p>, component: () => <p data-testid="route">current route</p>,
options: { options: {
getParentRoute: () => parent, getParentRoute: () => parent,
}, },
} as Route } as unknown as Route
describe('Test RouteMatch component', () => { describe('Test RouteMatch component', () => {
afterEach(() => { afterEach(() => {
@@ -0,0 +1,96 @@
import * as React from 'react'
import type { Route } from '../route'
import { useServerSideProps } from '../hooks/useServerSideProps'
interface MatchProps {
route: Route
// User defined server side props
serverSideProps: any
}
/**
* Returns the route match with the root element if exists
*
* It handles the fetch of the client side resources
*/
export const RouteMatch = ({
route,
serverSideProps,
}: MatchProps): JSX.Element => {
const { data, isLoading } = useServerSideProps(route, serverSideProps)
const routes = React.useMemo(() => loadParentComponents(route), [route.id])
return (
<TraverseRootComponents routes={routes} data={data} isLoading={isLoading}>
<route.component data={data} isLoading={isLoading} />
</TraverseRootComponents>
)
}
interface TraverseRootComponentsProps {
routes: Route[]
data: any
isLoading: boolean
children?: React.ReactNode
index?: number
}
interface ParentProps {
children: React.ReactNode
data: any
isLoading: boolean
}
/*
* This component traverses and renders
* all the components that wraps the selected route (__root).
* The parents components need to be memoized in order to avoid
* re-rendering bugs when changing route.
*/
const TraverseRootComponents = React.memo(
({
routes,
data,
isLoading,
index = 0,
children,
}: TraverseRootComponentsProps): JSX.Element => {
if (routes.length > index) {
const Parent = React.useMemo(
() =>
routes[index]?.component as unknown as (
props: ParentProps,
) => JSX.Element,
[],
)
return (
<Parent data={data} isLoading={isLoading}>
<TraverseRootComponents
routes={routes}
data={data}
isLoading={isLoading}
index={index + 1}
>
{children}
</TraverseRootComponents>
</Parent>
)
}
return <>{children}</>
},
)
const loadParentComponents = (route: Route, loader: Route[] = []): Route[] => {
const parentComponent = route.options?.getParentRoute?.()
loader.push(parentComponent)
if (!parentComponent.isRoot) {
return loadParentComponents(parentComponent, loader)
}
return loader.reverse()
}
@@ -33,12 +33,8 @@ function RouterContextProvider({
const routerContext = getRouterContext() const routerContext = getRouterContext()
const pendingElement = router.options.defaultPendingComponent ? (
<router.options.defaultPendingComponent />
) : null
return ( return (
<React.Suspense fallback={pendingElement}> <React.Suspense>
<routerContext.Provider value={router}>{children}</routerContext.Provider> <routerContext.Provider value={router}>{children}</routerContext.Provider>
</React.Suspense> </React.Suspense>
) )
@@ -1,4 +1,7 @@
import * as React from 'react' import * as React from 'react'
import type { RouteComponent } from './types'
type ImportFn = () => Promise<{ default: React.ComponentType<any> }>
/** /**
* Helper function to lazy load any component. * Helper function to lazy load any component.
@@ -9,7 +12,7 @@ import * as React from 'react'
* It can be wrapped within a React.Suspense component in order to handle its loading state. * It can be wrapped within a React.Suspense component in order to handle its loading state.
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export const dynamic = (importFn: () => JSX.Element): JSX.Element => { export const dynamic = (importFn: ImportFn): JSX.Element => {
/** /**
* *
* This function is just a placeholder. The real work is done by the bundler. * This function is just a placeholder. The real work is done by the bundler.
@@ -35,3 +38,9 @@ export const dynamic = (importFn: () => JSX.Element): JSX.Element => {
*/ */
return <></> return <></>
} }
export const lazyLoadComponent = (factory: ImportFn): RouteComponent => {
const Component = React.lazy(factory) as unknown as RouteComponent
Component.preload = factory
return Component
}
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import useRoute from './useRoute'
import { cleanup } from '@testing-library/react'
describe('Test useRoute fn', () => {
afterEach(() => {
cleanup()
})
test('match routes by ids', () => {
vi.mock('./useInternalRouter.tsx', () => ({
useInternalRouter: (): { routesById: Record<string, any> } => {
return {
routesById: {
'/': { id: '/' },
'/about': { id: '/about' },
'/posts': { id: '/posts' }, // posts/index
'/posts/[post]': { id: '/posts/[post]' },
'/posts/defined-post': { id: '/posts/defined-post' },
'/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' },
},
}
},
}))
expect(useRoute('/')?.id).toBe('/')
expect(useRoute('/not-found')?.id).toBe(undefined)
expect(useRoute('/about')?.id).toBe('/about')
expect(useRoute('/posts/')?.id).toBe('/posts')
expect(useRoute('/posts/dynamic-post')?.id).toBe('/posts/[post]')
expect(useRoute('/posts/defined-post')?.id).toBe('/posts/defined-post')
expect(useRoute('/posts/dynamic-post/dynamic-comment')?.id).toBe(
'/posts/[post]/[comment]',
)
})
})
@@ -1,26 +1,33 @@
import * as React from 'react'
import { useInternalRouter } from '../hooks/useInternalRouter'
import { useRouterStore } from '../hooks/useRouterStore'
import type { Route } from '../route' import type { Route } from '../route'
import { RouteMatch } from './RouteMatch' import { useInternalRouter } from './useInternalRouter'
import NotFound from './NotFound'
interface MatchesProps {
// user defined props
serverSideProps: any
}
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/ const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
/**
* In order to correctly handle pathnames that might finish with a slash
* we first sanitize them by removing the final slash.
*/
export function sanitizePathname(pathname: string): string {
if (pathname.endsWith('/') && pathname !== '/') {
return pathname.substring(0, pathname.length - 1)
}
return pathname
}
/* /*
* This function is also implemented on server side to match the bundle * This hook is also implemented on server side to match the bundle
* file to load at the first rendering. * file to load at the first rendering.
* *
* File: crates/tuono_lib/src/payload.rs * File: crates/tuono_lib/src/payload.rs
* *
* Optimizations should occour on both * Optimizations should occour on both
*/ */
export function getRouteByPathname(pathname: string): Route | undefined { export default function useRoute(pathname?: string): Route | undefined {
if (!pathname) return
pathname = sanitizePathname(pathname)
const { routesById } = useInternalRouter() const { routesById } = useInternalRouter()
if (routesById[pathname]) return routesById[pathname] if (routesById[pathname]) return routesById[pathname]
@@ -61,15 +68,3 @@ export function getRouteByPathname(pathname: string): Route | undefined {
if (!match) return if (!match) return
return routesById[match] return routesById[match]
} }
export function Matches({ serverSideProps }: MatchesProps): JSX.Element {
const location = useRouterStore((st) => st.location)
const route = getRouteByPathname(location.pathname)
if (!route) {
return <NotFound />
}
return <RouteMatch route={route} serverSideProps={serverSideProps} />
}
@@ -2,5 +2,5 @@ export { RouterProvider } from './components/RouterProvider'
export { default as Link } from './components/Link' export { default as Link } from './components/Link'
export { createRouter } from './router' export { createRouter } from './router'
export { createRoute, createRootRoute } from './route' export { createRoute, createRootRoute } from './route'
export { dynamic } from './dynamic' export { dynamic, lazyLoadComponent } from './dynamic'
export { useRouter } from './hooks/useRouter' export { useRouter } from './hooks/useRouter'
@@ -1,11 +1,12 @@
import type { RouterType } from './router' import type { RouterType } from './router'
import type { RouteComponent } from './types'
import { trimPathLeft, joinPaths } from './utils' import { trimPathLeft, joinPaths } from './utils'
interface RouteOptions { interface RouteOptions {
isRoot?: boolean isRoot?: boolean
getParentRoute?: () => Route getParentRoute?: () => Route
path?: string path?: string
component: () => JSX.Element component: RouteComponent
} }
export function createRoute(options: RouteOptions): Route { export function createRoute(options: RouteOptions): Route {
@@ -25,7 +26,7 @@ export class Route {
router: RouterType router: RouterType
isRoot: boolean isRoot: boolean
originalIndex?: number originalIndex?: number
component: () => JSX.Element component: RouteComponent
constructor(options: RouteOptions) { constructor(options: RouteOptions) {
this.isRoot = options.isRoot ?? typeof options.getParentRoute !== 'function' this.isRoot = options.isRoot ?? typeof options.getParentRoute !== 'function'
@@ -11,3 +11,12 @@ export interface ServerProps {
} }
props: any props: any
} }
export interface RouteProps {
data: any
isLoading: boolean
}
export type RouteComponent = ((props: RouteProps) => JSX.Element) & {
preload: () => void
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react"
},
"include": ["src", "tests", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
import react from '@vitejs/plugin-react'
const config = defineConfig({
plugins: [react()],
test: {
name: 'react-router',
watch: true,
environment: 'jsdom',
globals: true,
},
})
export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
)
+4 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "tuono", "name": "tuono",
"version": "0.8.2", "version": "0.9.2",
"description": "The react/rust fullstack framework", "description": "The react/rust fullstack framework",
"scripts": { "scripts": {
"dev": "vite build --watch", "dev": "vite build --watch",
@@ -8,9 +8,7 @@
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc", "lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write --ignore-unknown '**/*'", "format": "prettier -u --write --ignore-unknown '**/*'",
"format:check": "prettier --check --ignore-unknown '**/*'", "format:check": "prettier --check --ignore-unknown '**/*'",
"types": "tsc --noEmit", "types": "tsc --noEmit"
"test:watch": "vitest",
"test": "vitest run"
}, },
"type": "module", "type": "module",
"types": "dist/esm/index.d.ts", "types": "dist/esm/index.d.ts",
@@ -92,17 +90,14 @@
"react-meta-tags": "^1.0.1", "react-meta-tags": "^1.0.1",
"tuono-fs-router-vite-plugin": "workspace:*", "tuono-fs-router-vite-plugin": "workspace:*",
"tuono-lazy-fn-vite-plugin": "workspace:*", "tuono-lazy-fn-vite-plugin": "workspace:*",
"vite": "^5.2.11", "tuono-router": "workspace:*",
"zustand": "4.4.7" "vite": "^5.2.11"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@types/babel-traverse": "^6.25.10", "@types/babel-traverse": "^6.25.10",
"@types/babel__traverse": "^7.20.6", "@types/babel__traverse": "^7.20.6",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"jsdom": "^24.0.0",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"vitest": "^1.5.2" "vitest": "^1.5.2"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { hydrateRoot } from 'react-dom/client' import { hydrateRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '../router' import { RouterProvider, createRouter } from 'tuono-router'
type RouteTree = any type RouteTree = any
+6 -13
View File
@@ -1,15 +1,4 @@
import Head from 'react-meta-tags' import Head from 'react-meta-tags'
import {
createRoute,
createRootRoute,
createRouter,
Link,
RouterProvider,
dynamic,
useRouter,
} from './router'
export type { TuonoProps } from './types'
export { export {
createRoute, createRoute,
@@ -18,6 +7,10 @@ export {
Link, Link,
RouterProvider, RouterProvider,
dynamic, dynamic,
lazyLoadComponent,
useRouter, useRouter,
Head, } from 'tuono-router'
}
export { Head }
export type { TuonoProps } from './types'

Some files were not shown because too many files have changed in this diff Show More