Compare commits

...

24 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
50 changed files with 542 additions and 251 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
you're trying to solve? If a new feature is being added, describe the intended
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.
+10 -10
View File
@@ -1,15 +1,15 @@
<p align="center">
<img src="https://raw.githubusercontent.com/Valerioageno/tuono/main/assets/logo.png" width="200px">
</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>
[Documentation](https://tuono.dev) | [Tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) |
[✨Contributing](https://tuono.dev/documentation/contributing)
<br>
<br>
# Tuono
<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/).
Why Tuono? Just a badass name.
+1 -1
View File
@@ -8,6 +8,6 @@ name = "tuono"
path = ".tuono/main.rs"
[dependencies]
tuono_lib = "0.8.3"
tuono_lib = "0.9.1"
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.8.3"
"tuono": "0.9.1"
},
"devDependencies": {
"@types/mdx": "^2.0.13",
@@ -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 }
@@ -25,7 +25,7 @@ export default function Hero(): JSX.Element {
</Center>
<Center mt={50}>
<Group>
<Button component={Link} href="/documentation/installation" size="lg">
<Button component={Link} href="/documentation" size="lg">
Get Started
</Button>
<CopyButton value="cargo install tuono">
@@ -0,0 +1,5 @@
import { Text, type TextProps } from '@mantine/core'
export default function MdxBold(props: TextProps): JSX.Element {
return <Text fw={700} {...props} />
}
@@ -1,8 +1,17 @@
import { Title } from '@mantine/core'
import type { HTMLAttributes } from 'react'
import { useRef, type HTMLAttributes } from 'react'
export default function MdxH2(
props: HTMLAttributes<HTMLHeadingElement>,
): JSX.Element {
return <Title {...props} mt={20} order={2} />
return (
<Title
{...props}
mt={20}
order={2}
id={String(props.children ?? '')
.toLowerCase()
.replaceAll(' ', '-')}
/>
)
}
@@ -6,7 +6,7 @@ import { IconExternalLink } from '@tabler/icons-react'
export default function MdxLink(
props: AnchorHTMLAttributes<HTMLAnchorElement>,
): JSX.Element {
if (props.href?.startsWith('http')) {
if (props.href?.startsWith('http') || props.href?.startsWith('mailto')) {
return (
<Button
component="a"
@@ -27,7 +27,6 @@ export default function MdxLink(
component={Link}
{...props}
target="_blank"
rightSection={<IconExternalLink size="16px" style={{ marginLeft: -5 }} />}
variant="transparent"
style={{ height: '20px' }}
mt={-2}
@@ -6,6 +6,7 @@ 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 {
@@ -25,6 +26,7 @@ export default function MdxProvider({
code: MdxCode,
h1: MdxTitle,
h2: MdxH2,
strong: MdxBold,
}}
>
{children}
@@ -8,7 +8,7 @@ export default function MdxQuote(
<>
<Blockquote
color="violet"
p={8}
py={1}
px={20}
mt={30}
iconSize={30}
@@ -4,5 +4,13 @@ import type { HTMLAttributes } from 'react'
export default function MdxTitle(
props: HTMLAttributes<HTMLHeadingElement>,
): JSX.Element {
return <Title {...props} order={1} />
return (
<Title
{...props}
order={1}
id={String(props.children ?? '')
.toLowerCase()
.replaceAll(' ', '-')}
/>
)
}
@@ -1,4 +1,4 @@
import { Flex, Button, ActionIcon } from '@mantine/core'
import { Flex, Button, ActionIcon, Group } from '@mantine/core'
import { IconBrandGithub, IconBook } from '@tabler/icons-react'
import ThemeBtn from '../theme-btn'
import { Link } from 'tuono'
@@ -7,7 +7,7 @@ export default function Actions(): JSX.Element {
return (
<Flex gap={8}>
<Button
href="/documentation/installation"
href="/documentation"
component={Link}
size="compact-lg"
rightSection={<IconBook />}
@@ -15,17 +15,19 @@ export default function Actions(): JSX.Element {
>
Get started
</Button>
<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 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>
)
}
@@ -12,7 +12,11 @@ export default function Navbar({ opened, toggle }: NavbarProps): JSX.Element {
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}
@@ -21,11 +25,7 @@ export default function Navbar({ opened, toggle }: NavbarProps): JSX.Element {
size="sm"
/>
)}
<Button component={Link} href="/" variant="transparent" p={0} fz={28}>
Tuono
</Button>
</Flex>
<Actions />
</Flex>
</AppShell.Header>
)
@@ -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} />
}
@@ -1,32 +1,44 @@
import { AppShell, NavLink } from '@mantine/core'
import { Link } from 'tuono'
import { AppShell } from '@mantine/core'
import SidebarLink from './sidebar-link'
export default function Sidebar(): JSX.Element {
export default function Sidebar({ close }: { close: () => void }): JSX.Element {
return (
<AppShell.Navbar p="md">
<h3>Tutorial</h3>
<NavLink
<SidebarLink
href="/documentation/tutorial/intro"
component={Link}
label="Intro"
onClick={close}
/>
<h3>Documentation</h3>
<NavLink
<SidebarLink href="/documentation" label="Overview" onClick={close} />
<SidebarLink
href="/documentation/installation"
component={Link}
label="Installation"
onClick={close}
/>
<NavLink label="Routing" href="#required-for-focus" defaultOpened>
<NavLink
<SidebarLink
href="/documentation/getting-started"
label="Getting started"
onClick={close}
/>
<SidebarLink
label="Routing"
href="/documentation/routing"
defaultOpened
onClick={close}
>
<SidebarLink
href="/documentation/routing/intro"
component={Link}
label="FS routing"
label="Project structure"
onClick={close}
/>
</NavLink>
<NavLink
</SidebarLink>
<SidebarLink
label="Contributing"
component={Link}
href="/documentation/contributing"
onClick={close}
leftSection="✨"
/>
</AppShell.Navbar>
)
+5 -1
View File
@@ -6,11 +6,12 @@ import {
AppShell,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { Head } from 'tuono'
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
@@ -52,6 +53,8 @@ const theme = createTheme({
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
const [opened, { toggle }] = useDisclosure()
const { pathname } = useRouter()
return (
<>
<Head>
@@ -67,6 +70,7 @@ export default function RootRoute({ children }: RootRouteProps): JSX.Element {
}}
>
<Navbar opened={opened} toggle={toggle} />
{pathname.startsWith('/documentation') && <Sidebar close={toggle} />}
{children}
</AppShell>
</MantineProvider>
@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import { AppShell, Container } from '@mantine/core'
import MdxProvider from '../../components/mdx-provider'
import Sidebar from '../../components/sidebar'
import EditPage from '../../components/edit-page'
interface RootRouteProps {
@@ -10,14 +9,11 @@ interface RootRouteProps {
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
return (
<>
<Sidebar />
<AppShell.Main>
<Container component="article" p={20}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
</AppShell.Main>
</>
<AppShell.Main>
<Container component="article" p={20}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
</AppShell.Main>
)
}
@@ -4,7 +4,13 @@ import { Head } from 'tuono'
<title>Tuono - Contributing</title>
</Head>
# How to contribute
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Breadcrumbs>
<Element label="✨ Contributing" />
</Breadcrumbs>
# Contributing
## TL;DR
@@ -12,15 +18,31 @@ The project is massive - if you like it do consider to contribute!
## Getting started
Currently I'm keeping a private dashboard to prioritize new features and bug fixes.
The `tuono` project can mostly be split by the following sub-domains:
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)
- 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
To contribute is not strictly required to know both typescript and rust (even though it
would be a great nice to have). Most of the feature tasks and bugfixes can be handled solely on
the client or on the server side.
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
@@ -1,9 +1,14 @@
import { Head } from 'tuono'
import Breadcrumbs, { Element } from '../../components/breadcrumbs'
<Head>
<title>Tuono - Installation</title>
</Head>
<Breadcrumbs>
<Element label="Installation" />
</Breadcrumbs>
# Installation
## Requirements
@@ -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
@@ -4,6 +4,13 @@ import { Head } from 'tuono'
<title>Tuono - Routing</title>
</Head>
# Routing
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
<Breadcrumbs>
<Element label="Routing" href="/documentation/routing" />
<Element label="Project structure" />
</Breadcrumbs>
# Project structure
Todo
@@ -4,6 +4,14 @@ import { Head } from 'tuono'
<title>Tuono - Tutorial intro</title>
</Head>
# Tutorial intro
import Breadcrumbs, { Element } from '../../../components/breadcrumbs'
TODO
<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.
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.8.4"
version = "0.9.2"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.8.4"
version = "0.9.2"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
@@ -32,7 +32,7 @@ regex = "1.10.5"
either = "1.13.0"
tower-http = {version = "0.5.2", features = ["fs"]}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.8.4"}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.9.2"}
# Match the same version used by axum
tokio-tungstenite = "0.21.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib_macros"
version = "0.8.4"
version = "0.9.2"
edition = "2021"
description = "The react/rust fullstack framework"
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-fs-router-vite-plugin",
"version": "0.8.4",
"version": "0.9.2",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-lazy-fn-vite-plugin",
"version": "0.8.4",
"version": "0.9.2",
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -1,3 +1,3 @@
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'
+17 -37
View File
@@ -5,7 +5,7 @@ import type { PluginItem } from '@babel/core'
import {
TUONO_MAIN_PACKAGE,
TUONO_DYNAMIC_FN_ID,
REACT_LAZY_FN_ID,
TUONO_LAZY_FN_ID,
} from './constants'
import * as t from '@babel/types'
@@ -19,6 +19,7 @@ import type {
} from '@babel/types'
/**
* [SERVER build]
* This plugin just removes the `dynamic` imported function from any tuono import
*/
const RemoveTuonoLazyImport: PluginItem = {
@@ -38,49 +39,28 @@ const RemoveTuonoLazyImport: PluginItem = {
}
/**
* This plugin adds: "Import { lazy } from 'react'"
* and translate dynamic call into a React.lazy call
* [CLIENT build]
* This plugin replace the `dynamic` function with the `lazyLoadComponent` one
*/
const ImportReactLazy: PluginItem = {
name: 'import-react-lazy-plugin',
const ReplaceTuonoLazyImport: PluginItem = {
name: 'remove-tuono-lazy-import-plugin',
visitor: {
// Add the import statement
Program: (path: any) => {
let isReactImported = false
path.node.body.forEach((val: any) => {
if (val.type === 'ImportDeclaration' && val.source.value === 'react') {
isReactImported = true
// TODO: Handle also here case of already imported react
// Right now works just for the main routes file
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
}
})
// 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 = {
name: 'turn-lazy-into-static-import-plugin',
@@ -124,8 +104,8 @@ export function LazyLoadingPlugin(): Plugin {
plugins: [
['@babel/plugin-syntax-jsx', {}],
['@babel/plugin-syntax-typescript', { isTSX: true }],
[RemoveTuonoLazyImport],
[!opts?.ssr ? ImportReactLazy : []],
[!opts?.ssr ? ReplaceTuonoLazyImport : []],
[opts?.ssr ? RemoveTuonoLazyImport : []],
[opts?.ssr ? TurnLazyIntoStaticImport : []],
],
sourceMaps: true,
@@ -10,10 +10,9 @@ const PokemonspokemonImport = dynamic(
)
`
const CLIENT_RESULT = `import { lazy } from "react";
import { createRoute } from 'tuono';
const IndexImport = lazy(() => import('./../src/routes/index'));
const PokemonspokemonImport = lazy(() => import('./../src/routes/pokemons/[pokemon]'));`
const 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 SERVER_RESULT = `import { createRoute } from 'tuono';
import IndexImport from "./../src/routes/index";
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-router",
"version": "0.8.4",
"version": "0.9.2",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
@@ -42,6 +42,7 @@
"react-dom": ">=16.3.0"
},
"dependencies": {
"react-intersection-observer": "^9.13.0",
"vite": "^5.2.11",
"zustand": "4.4.7"
},
+22 -2
View File
@@ -1,20 +1,40 @@
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(
props: AnchorHTMLAttributes<HTMLAnchorElement>,
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} onClick={handleTransition}>
<a {...props} ref={ref} onClick={handleTransition}>
{props.children}
</a>
)
@@ -1,38 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { getRouteByPathname } from './Matches'
import { cleanup } from '@testing-library/react'
describe('Test getRouteByPathname fn', () => {
afterEach(() => {
cleanup()
})
test('match routes by ids', () => {
vi.mock('../hooks/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(getRouteByPathname('/')?.id).toBe('/')
expect(getRouteByPathname('/not-found')?.id).toBe(undefined)
expect(getRouteByPathname('/about')?.id).toBe('/about')
expect(getRouteByPathname('/posts/')?.id).toBe('/posts/')
expect(getRouteByPathname('/posts/dynamic-post')?.id).toBe('/posts/[post]')
expect(getRouteByPathname('/posts/defined-post')?.id).toBe(
'/posts/defined-post',
)
expect(getRouteByPathname('/posts/dynamic-post/dynamic-comment')?.id).toBe(
'/posts/[post]/[comment]',
)
})
})
+2 -55
View File
@@ -1,71 +1,18 @@
import * as React from 'react'
import { useInternalRouter } from '../hooks/useInternalRouter'
import { useRouterStore } from '../hooks/useRouterStore'
import type { Route } from '../route'
import { RouteMatch } from './RouteMatch'
import NotFound from './NotFound'
import useRoute from '../hooks/useRoute'
interface MatchesProps {
// user defined props
serverSideProps: any
}
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
/*
* This function is also implemented on server side to match the bundle
* file to load at the first rendering.
*
* File: crates/tuono_lib/src/payload.rs
*
* Optimizations should occour on both
*/
export function getRouteByPathname(pathname: string): Route | undefined {
const { routesById } = useInternalRouter()
if (routesById[pathname]) return routesById[pathname]
const dynamicRoutes = Object.keys(routesById).filter((route) =>
DYNAMIC_PATH_REGEX.test(route),
)
if (!dynamicRoutes.length) return
const pathSegments = pathname.split('/').filter(Boolean)
let match = undefined
// TODO: Check algo efficiency
for (const dynamicRoute of dynamicRoutes) {
const dynamicRouteSegments = dynamicRoute.split('/').filter(Boolean)
const routeSegmentsCollector: string[] = []
for (let i = 0; i < dynamicRouteSegments.length; i++) {
if (
dynamicRouteSegments[i] === pathSegments[i] ||
DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '')
) {
routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '')
} else {
break
}
}
if (routeSegmentsCollector.length === pathSegments.length) {
match = `/${routeSegmentsCollector.join('/')}`
break
}
}
if (!match) return
return routesById[match]
}
export function Matches({ serverSideProps }: MatchesProps): JSX.Element {
const location = useRouterStore((st) => st.location)
const route = getRouteByPathname(location.pathname)
const route = useRoute(location.pathname)
if (!route) {
return <NotFound />
@@ -14,7 +14,7 @@ const root = {
component: ({ children }: Props) => (
<div data-testid="root">root route {children}</div>
),
} as Route
} as unknown as Route
const parent = {
component: ({ children }: Props) => (
@@ -23,14 +23,14 @@ const parent = {
options: {
getParentRoute: () => root,
},
} as Route
} as unknown as Route
const route = {
component: () => <p data-testid="route">current route</p>,
options: {
getParentRoute: () => parent,
},
} as Route
} as unknown as Route
describe('Test RouteMatch component', () => {
afterEach(() => {
@@ -33,12 +33,8 @@ function RouterContextProvider({
const routerContext = getRouterContext()
const pendingElement = router.options.defaultPendingComponent ? (
<router.options.defaultPendingComponent />
) : null
return (
<React.Suspense fallback={pendingElement}>
<React.Suspense>
<routerContext.Provider value={router}>{children}</routerContext.Provider>
</React.Suspense>
)
+10 -1
View File
@@ -1,4 +1,7 @@
import * as React from 'react'
import type { RouteComponent } from './types'
type ImportFn = () => Promise<{ default: React.ComponentType<any> }>
/**
* 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.
*/
// 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.
@@ -35,3 +38,9 @@ export const dynamic = (importFn: () => JSX.Element): JSX.Element => {
*/
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]',
)
})
})
+70
View File
@@ -0,0 +1,70 @@
import type { Route } from '../route'
import { useInternalRouter } from './useInternalRouter'
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 hook is also implemented on server side to match the bundle
* file to load at the first rendering.
*
* File: crates/tuono_lib/src/payload.rs
*
* Optimizations should occour on both
*/
export default function useRoute(pathname?: string): Route | undefined {
if (!pathname) return
pathname = sanitizePathname(pathname)
const { routesById } = useInternalRouter()
if (routesById[pathname]) return routesById[pathname]
const dynamicRoutes = Object.keys(routesById).filter((route) =>
DYNAMIC_PATH_REGEX.test(route),
)
if (!dynamicRoutes.length) return
const pathSegments = pathname.split('/').filter(Boolean)
let match = undefined
// TODO: Check algo efficiency
for (const dynamicRoute of dynamicRoutes) {
const dynamicRouteSegments = dynamicRoute.split('/').filter(Boolean)
const routeSegmentsCollector: string[] = []
for (let i = 0; i < dynamicRouteSegments.length; i++) {
if (
dynamicRouteSegments[i] === pathSegments[i] ||
DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '')
) {
routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '')
} else {
break
}
}
if (routeSegmentsCollector.length === pathSegments.length) {
match = `/${routeSegmentsCollector.join('/')}`
break
}
}
if (!match) return
return routesById[match]
}
+1 -1
View File
@@ -2,5 +2,5 @@ export { RouterProvider } from './components/RouterProvider'
export { default as Link } from './components/Link'
export { createRouter } from './router'
export { createRoute, createRootRoute } from './route'
export { dynamic } from './dynamic'
export { dynamic, lazyLoadComponent } from './dynamic'
export { useRouter } from './hooks/useRouter'
+1 -1
View File
@@ -6,7 +6,7 @@ interface RouteOptions {
isRoot?: boolean
getParentRoute?: () => Route
path?: string
component: () => JSX.Element
component: RouteComponent
}
export function createRoute(options: RouteOptions): Route {
+3 -1
View File
@@ -17,4 +17,6 @@ export interface RouteProps {
isLoading: boolean
}
export type RouteComponent = (props: RouteProps) => JSX.Element
export type RouteComponent = ((props: RouteProps) => JSX.Element) & {
preload: () => void
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tuono",
"version": "0.8.4",
"version": "0.9.2",
"description": "The react/rust fullstack framework",
"scripts": {
"dev": "vite build --watch",
+6 -13
View File
@@ -1,15 +1,4 @@
import Head from 'react-meta-tags'
import {
createRoute,
createRootRoute,
createRouter,
Link,
RouterProvider,
dynamic,
useRouter,
} from 'tuono-router'
export type { TuonoProps } from './types'
export {
createRoute,
@@ -18,6 +7,10 @@ export {
Link,
RouterProvider,
dynamic,
lazyLoadComponent,
useRouter,
Head,
}
} from 'tuono-router'
export { Head }
export type { TuonoProps } from './types'