feat: create basic router

This commit is contained in:
Valerio Ageno
2024-05-11 15:08:49 +02:00
parent df80756c44
commit 73c94e5fbb
30 changed files with 686 additions and 105 deletions
+37 -5
View File
@@ -1,12 +1,44 @@
{
"name": "tuono-router",
"version": "1.0.0",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"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"
},
"sideEffects": false,
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
"test": "vitest --watch=false",
"types": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"keywords": [],
"author": "",
"license": "MIT"
"license": "MIT",
"devDependencies": {
"jsdom": "^24.0.0"
},
"dependencies": {
"zustand": "4.4.7"
}
}
@@ -0,0 +1,18 @@
import { useRouterStore } from '../hooks/useRouterStore'
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
export default function Link(
props: AnchorHTMLAttributes<HTMLAnchorElement>,
): JSX.Element {
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
props.onClick?.(e)
useRouterStore.setState({ location: { pathname: props.href || '' } })
window.history.pushState('', '', props.href)
}
return (
<a {...props} onClick={handleTransition}>
{props.children}
</a>
)
}
@@ -0,0 +1,30 @@
import * as React from 'react'
import { useRouter } from '../hooks/useRouter'
import { useRouterStore } from '../hooks/useRouterStore'
export function Matches(): JSX.Element | undefined {
const matchId = useRouterStore.getState().matches[0]?.id
//if (!matchId) return <></>
return (
<React.Suspense>
<Match id={matchId} />
</React.Suspense>
)
}
interface MatchProps {
id: string
}
const Match = React.memo(function ({ id }: MatchProps) {
const location = useRouterStore((st) => st.location)
const router = useRouter()
console.log(router, location)
const route = router.routesById[location?.pathname]
return route.component()
})
@@ -0,0 +1,7 @@
import * as React from 'react'
const Outlet = React.memo(function Outlet() {
return <h1>Outlet</h1>
})
export default Outlet
@@ -0,0 +1,20 @@
import * as React from 'react'
import type { Router } from '../router'
const routerContext = React.createContext<Router>(null!)
const ROUTER_DOM_CONTEXT = '__TUONO_ROUTER__CONTEXT__'
export function getRouterContext(): any {
if (typeof document === 'undefined') {
return routerContext
}
if (window[ROUTER_DOM_CONTEXT as any]) {
return window[ROUTER_DOM_CONTEXT as any]
}
window[ROUTER_DOM_CONTEXT as any] = routerContext as any
return routerContext
}
@@ -0,0 +1,60 @@
import * as React from 'react'
import { getRouterContext } from './RouterContext'
import { Matches } from './Matches'
type Router = any
interface RouterContextProviderProps {
router: Router
children: ReactNode
}
function RouterContextProvider({
router,
children,
...rest
}: RouterContextProviderProps): JSX.Element {
// Allow the router to update options on the router instance
router.update({
...router.options,
...rest,
context: {
...router.options.context,
...rest.context,
},
})
const routerContext = getRouterContext()
const pendingElement = router.options.defaultPendingComponent ? (
<router.options.defaultPendingComponent />
) : null
const provider = (
<React.Suspense fallback={pendingElement}>
<routerContext.Provider value={router}>{children}</routerContext.Provider>
</React.Suspense>
)
// NOTE: verify usefulness
if (router.options.Wrap) {
return <router.options.Wrap>{provider}</router.options.Wrap>
}
return provider
}
interface RouterProviderProps {
router: Router
}
export function RouterProvider({
router,
...rest
}: RouterProviderProps): JSX.Element {
return (
<RouterContextProvider router={router} {...rest}>
<Matches />
</RouterContextProvider>
)
}
@@ -0,0 +1,7 @@
import * as React from 'react'
import { getRouterContext } from '../components/RouterContext'
import type { Router } from '../router'
export function useRouter(): Router {
return React.useContext(getRouterContext())
}
@@ -0,0 +1,40 @@
import * as React from 'react'
import { create } from 'zustand'
export interface ParsedLocation {
href: string
pathname: string
search: Record<string, string>
searchStr: string
hash: string
}
interface RouterState {
isLoading: boolean
isTransitioning: boolean
status: 'idle'
location?: ParsedLocation
matches: string[]
pendingMatches: string[]
cachedMatches: string[]
statusCode: 200
updateLocation: (loc: ParsedLocation) => void
}
export const useRouterStore = create<RouterState>()((set) => ({
isLoading: false,
isTransitioning: false,
status: 'idle',
location: {
href: '',
pathname: '/',
search: {},
searchStr: '',
hash: '',
},
matches: [],
pendingMatches: [],
cachedMatches: [],
statusCode: 200,
updateLocation: (location: ParsedLocation): void => set({ location }),
}))
View File
+4
View File
@@ -0,0 +1,4 @@
export { RouterProvider } from './components/RouterProvider'
export { default as Link } from './components/Link'
export { createRouter } from './router'
export { createRoute, createRootRoute, getRouteApi } from './route'
+119
View File
@@ -0,0 +1,119 @@
import type { ReactNode } from 'react'
import type { RouterType } from './router'
import { trimPathLeft, joinPaths } from './utils'
interface RouteOptions {
isRoot?: boolean
getParentRoute?: () => Route
path?: string
component: ReactNode
}
export function createRoute(options: RouteOptions): Route {
return new Route(options)
}
export const rootRouteId = '__root__'
export class Route {
parentRoute!: any
id: number
fullPath!: string
path: string
options: any
children?: Route[]
router: RouterType
isRoot: boolean
originalIndex: number
component: ReactNode
constructor(options: RouteOptions) {
this.isRoot = options.isRoot || !options.getParentRoute
this.options = options
;(this as any).$$typeof = Symbol.for('react.memo')
this.component = options.component
}
init = (originalIndex: number): void => {
this.originalIndex = originalIndex
const isRoot = !this.options?.path && !this.options?.id
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.parentRoute = this.options?.getParentRoute?.()
if (isRoot) {
this.path = rootRouteId as TPath
}
let path: undefined | string = isRoot ? rootRouteId : this.options.path
// If the path is anything other than an index path, trim it up
if (path && path !== '/') {
path = trimPathLeft(path)
}
const customId = this.options?.id || path
// Strip the parentId prefix from the first level of children
let id = isRoot
? rootRouteId
: joinPaths([
this.parentRoute.id === rootRouteId ? '' : this.parentRoute.id,
customId,
])
if (path === rootRouteId) {
path = '/'
}
if (id !== rootRouteId) {
id = joinPaths(['/', id])
}
const fullPath =
id === rootRouteId ? '/' : joinPaths([this.parentRoute.fullPath, path])
this.path = path as TPath
this.id = id as TId
// this.customId = customId as TCustomId
this.fullPath = fullPath as TFullPath
this.to = fullPath as TrimPathRight<TFullPath>
}
addChildren(routes: Route[]): Route {
this.children = routes
return this
}
update = (options: RouteOptions): this => {
Object.assign(this.options, options)
return this
}
useRouteContext = () => {}
useParams = () => {}
}
export function createRootRoute(options?: RouteOptions): Route {
return new Route({ ...options, isRoot: true })
}
export function getRouteApi(id: string): RouteApi {
return new RouteApi(id)
}
class RouteApi {
id: string
constructor(id: string) {
this.id = id
}
useParams = () => {}
useRouteContext = () => {}
}
+98
View File
@@ -0,0 +1,98 @@
import { trimPath, trimPathRight, trimPathLeft, parsePathname } from './utils'
import type { Route } from './route'
interface CreateRouter {
routeTree: any
basePath?: string
}
export type RouterType = any
export function createRouter(options: CreateRouterArgs): Router {
return new Router(options)
}
export class Router {
options: any
basePath = '/'
routeTree: any
history: any
isServer = typeof document === 'undefined'
routesById = {}
routesByPath = {}
flatRoutes: any
constructor(options: CreateRouter) {
this.update({
...options,
})
if (!this.isServer) {
;(window as any).__TSR__ROUTER__ = this
}
}
update = (newOptions: CreateRouter): void => {
this.options = {
...this.options,
...newOptions,
}
this.#updateBasePath(newOptions.basePath)
// NOTE: next iteration
this.#historyUpdate()
// NOTE: next iteration
this.#storeUpdate()
if (this.options.routeTree !== this.routeTree) {
this.routeTree = this.options.routeTree
this.#buildRouteTree()
}
}
#historyUpdate = (): void => {
// TODO: update history
}
#storeUpdate = (): void => {
// TODO: update store
}
#buildRouteTree = (): void => {
const recurseRoutes = (childRoutes: Route[]): void => {
childRoutes.forEach((route: Route, i: number) => {
route.init(i)
this.routesById[route.id] = route
if (!route.isRoot && route.options.path) {
const trimmedFullPath = trimPathRight(route.fullPath)
if (
!this.routesByPath[trimmedFullPath] ||
route.fullPath.endsWith('/')
) {
this.routesByPath[trimmedFullPath] = route
}
}
const children = route.children
if (children?.length) {
recurseRoutes(children)
}
})
}
recurseRoutes([this.routeTree])
}
#updateBasePath = (basePath?: string): void => {
if (!this.basePath || (basePath && basePath !== basePath)) {
if (basePath === undefined || basePath === '' || basePath === '/') {
this.basePath = '/'
} else {
this.basePath = `/${trimPath(basePath)}`
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export interface Segment {
type: 'pathname' | 'param' | 'wildcard'
value: string
}
+77
View File
@@ -0,0 +1,77 @@
import type { Segment } from './types'
export function joinPaths(paths: (string | undefined)[]): string {
return cleanPath(paths.filter(Boolean).join('/'))
}
function cleanPath(path: string): string {
// remove double slashes
return path.replace(/\/{2,}/g, '/')
}
export function parsePathname(pathname?: string): Segment[] {
if (!pathname) {
return []
}
pathname = cleanPath(pathname)
const segments: Segment[] = []
if (pathname.slice(0, 1) === '/') {
pathname = pathname.substring(1)
segments.push({
type: 'pathname',
value: '/',
})
}
if (!pathname) {
return segments
}
// Remove empty segments and '.' segments
const split = pathname.split('/').filter(Boolean)
segments.push(
...split.map((part): Segment => {
if (part === '$' || part === '*') {
return {
type: 'wildcard',
value: part,
}
}
if (part.charAt(0) === '$') {
return {
type: 'param',
value: part,
}
}
return {
type: 'pathname',
value: part,
}
}),
)
if (pathname.slice(-1) === '/') {
pathname = pathname.substring(1)
segments.push({
type: 'pathname',
value: '/',
})
}
return segments
}
export function trimPathLeft(path: string): string {
return path === '/' ? path : path.replace(/^\/{1,}/, '')
}
export function trimPathRight(path: string): string {
return path === '/' ? path : path.replace(/\/{1,}$/, '')
}
export function trimPath(path: string): string {
return trimPathRight(trimPathLeft(path))
}
@@ -0,0 +1,34 @@
import { expectTypeOf, test } from 'vitest'
import {
RouterProvider,
createRootRoute,
createRoute,
createRouter,
} from '../../src'
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
})
const invoicesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/invoices',
})
const routeTree = rootRoute.addChildren([invoicesRoute, indexRoute])
const defaultRouter = createRouter({
routeTree,
})
type DefaultRouter = typeof defaultRouter
test('can pass default router to the provider', () => {
expectTypeOf(RouterProvider).parameter(0).toMatchTypeOf<{
router: DefaultRouter
routeTree?: DefaultRouter['routeTree']
}>()
})
+28
View File
@@ -0,0 +1,28 @@
/* eslint-disable */
import { describe, it, expect } from 'vitest'
import { getRouteApi, createRoute } from '../src'
describe('getRouteApi', () => {
it('should have the useRouteContext hook', () => {
const api = getRouteApi('foo')
expect(api.useRouteContext).toBeDefined()
})
it('should have the useParams hook', () => {
const api = getRouteApi('foo')
expect(api.useParams).toBeDefined()
})
})
describe('createRoute has the same hooks as getRouteApi', () => {
const routeApi = getRouteApi('foo')
const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use'))
const route = createRoute({} as any)
it.each(hookNames.map((name) => [name]))(
'should have the "%s" hook defined',
(hookName) => {
expect(route[hookName as keyof typeof route]).toBeDefined()
},
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react",
"jsx": "react-jsx",
},
"include": ["src", "tests", "vite.config.ts"],
}
-1
View File
@@ -18,4 +18,3 @@ export default mergeConfig(
srcDir: './src',
}),
)
+4 -1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"description": "",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
@@ -28,7 +29,9 @@
"dist",
"src"
],
"dependencies": {},
"dependencies": {
"prettier": "^3.2.4"
},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
@@ -239,29 +239,40 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
(d): any => d,
])
const imports = [
...sortedRouteNodes.map((node) => {
return `import ${node.variableName}Import from './${replaceBackslash(
removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, node.filePath),
),
false,
),
)}'`
}),
].join('\n')
const createRoutes = [
...sortedRouteNodes.map((node) => {
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import })`
}),
].join('\n')
const routeImports = [
'// This file is auto-generated by Tuono',
"import { createRoute } from 'tuono'",
[
`import { Route as rootRoute } from './${replaceBackslash(
`import RootImport from './${replaceBackslash(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, ROOT_PATH_ID),
),
)}'`,
...sortedRouteNodes.map((node) => {
return `import { Route as ${
node.variableName
}Import } from './${replaceBackslash(
removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, node.filePath),
),
false,
),
)}'`
}),
].join('\n'),
imports,
'const rootRoute = createRoute({ isRoot: true, component: RootImport });',
createRoutes,
'// Create/Update Routes',
sortedRouteNodes
.map((node) => {
@@ -269,7 +280,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy
return [
`const ${node.variableName}Route = ${node.variableName}Import.update({
`const ${node.variableName}Route = ${node.variableName}.update({
${[
`path: '${node.cleanedPath}'`,
`getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
@@ -302,25 +313,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
].join('')
})
.join('\n\n'),
...[
'// Populate the FileRoutesByPath interface',
`declare module '@tanstack/react-router' {
interface FileRoutesByPath {
${routeNodes
.map((routeNode) => {
return `'${removeTrailingUnderscores(routeNode.routePath)}': {
preLoaderRoute: typeof ${routeNode.variableName}Import
parentRoute: typeof ${
routeNode.parent?.variableName
? `${routeNode.parent.variableName}Import`
: 'rootRoute'
}
}`
})
.join('\n')}
}
}`,
],
'// Create and export the route tree',
`export const routeTree = rootRoute.addChildren([${routeConfigChildrenText}])`,
]
@@ -1,56 +1,43 @@
// This file is auto-generated by Tuono
import { Route as rootRoute } from './routes/__root'
import { Route as AboutImport } from './routes/about'
import { Route as IndexImport } from './routes/index'
import { Route as PostsIndexImport } from './routes/posts/index'
import { Route as PostsPostSlugImport } from './routes/posts/post-slug'
import { createRoute } from 'tuono'
import RootImport from './routes/__root'
import AboutImport from './routes/about'
import IndexImport from './routes/index'
import PostsIndexImport from './routes/posts/index'
import PostsPostSlugImport from './routes/posts/post-slug'
const rootRoute = createRoute({ isRoot: true, component: RootImport })
const About = createRoute({ component: AboutImport })
const Index = createRoute({ component: IndexImport })
const PostsIndex = createRoute({ component: PostsIndexImport })
const PostsPostSlug = createRoute({ component: PostsPostSlugImport })
// Create/Update Routes
const AboutRoute = AboutImport.update({
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
const PostsIndexRoute = PostsIndexImport.update({
const PostsIndexRoute = PostsIndex.update({
path: '/posts/',
getParentRoute: () => rootRoute,
} as any)
const PostsPostSlugRoute = PostsPostSlugImport.update({
const PostsPostSlugRoute = PostsPostSlug.update({
path: '/posts/post-slug',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/about': {
preLoaderRoute: typeof AboutImport
parentRoute: typeof rootRoute
}
'/posts/post-slug': {
preLoaderRoute: typeof PostsPostSlugImport
parentRoute: typeof rootRoute
}
'/posts/': {
preLoaderRoute: typeof PostsIndexImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([
@@ -1,36 +1,29 @@
// This file is auto-generated by Tuono
import { Route as rootRoute } from './routes/__root'
import { Route as AboutImport } from './routes/about'
import { Route as IndexImport } from './routes/index'
import { createRoute } from 'tuono'
import RootImport from './routes/__root'
import AboutImport from './routes/about'
import IndexImport from './routes/index'
const rootRoute = createRoute({ isRoot: true, component: RootImport })
const About = createRoute({ component: AboutImport })
const Index = createRoute({ component: IndexImport })
// Create/Update Routes
const AboutRoute = AboutImport.update({
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/about': {
preLoaderRoute: typeof AboutImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
+5 -2
View File
@@ -1,9 +1,12 @@
{
"name": "tuono-vite",
"version": "0.1.0",
"version": "0.0.1",
"description": "",
"main": "src/index.ts",
"main": "dist/cjs/index.cjs",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
+2 -2
View File
@@ -40,7 +40,7 @@ export function RouterGenerator(): Plugin {
}
return {
name: 'vite-plugin-fs-router-generator',
name: 'vite-plugin-tuono-fs-router-generator',
configResolved: async (): Promise<void> => {
await generate()
},
@@ -59,7 +59,7 @@ export function RouterCodeSplitter(): Plugin {
const ROOT: string = process.cwd()
return {
name: 'vite-plugin-fs-router-code-splitter',
name: 'vite-plugin-tuono-fs-router-code-splitter',
enforce: 'pre',
resolveId(source): string {
if (source.startsWith(SPLIT_PREFIX + ':')) {
+4
View File
@@ -3,6 +3,7 @@
"version": "0.0.1",
"description": "",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write '**/*'",
@@ -31,6 +32,9 @@
"src",
"README.md"
],
"dependencies": {
"tuono-router": "workspace:*"
},
"sideEffects": false,
"keywords": [],
"author": "Valerio Ageno",
+9 -3
View File
@@ -1,3 +1,9 @@
export default function sum(a: number, b: number): number {
return a + b
}
import {
createRoute,
createRootRoute,
createRouter,
Link,
RouterProvider,
} from 'tuono-router'
export { createRoute, createRootRoute, createRouter, Link, RouterProvider }