olova-router 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +387 -0
- package/dist/router.d.ts +50 -1
- package/dist/router.js +165 -0
- package/package.json +56 -15
- package/dist/olova-router.js +0 -249
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Olova Router
|
|
2
|
+
|
|
3
|
+
Next.js-style folder-based routing for React + Vite applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 📁 **File-based routing** - Create routes by adding folders with `index.tsx`
|
|
8
|
+
- 🎯 **Dynamic routes** - Use `$id` or `[id]` for dynamic segments
|
|
9
|
+
- 🌟 **Catch-all routes** - Use `$` or `[...slug]` for catch-all segments
|
|
10
|
+
- 📦 **Route groups** - Use `(group)` folders to organize without affecting URLs
|
|
11
|
+
- 🔍 **Search params** - Built-in `useSearchParams` hook
|
|
12
|
+
- 🚫 **Custom 404 pages** - Global and route-specific 404 pages
|
|
13
|
+
- 🔄 **Hot reload** - Auto-updates when you add/remove routes
|
|
14
|
+
- 📝 **Type-safe** - Full TypeScript support with typed routes
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install olova-router
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
### 1. Add the Vite plugin
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// vite.config.ts
|
|
28
|
+
import { defineConfig } from 'vite';
|
|
29
|
+
import react from '@vitejs/plugin-react';
|
|
30
|
+
import { olovaRouter } from 'olova-router';
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
plugins: [react(), olovaRouter()],
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Create your app entry
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// src/main.tsx
|
|
41
|
+
import { StrictMode } from 'react';
|
|
42
|
+
import { createRoot } from 'react-dom/client';
|
|
43
|
+
import { routes, notFoundPages, OlovaRouter } from './route.tree';
|
|
44
|
+
|
|
45
|
+
createRoot(document.getElementById('root')!).render(
|
|
46
|
+
<StrictMode>
|
|
47
|
+
<OlovaRouter routes={routes} notFoundPages={notFoundPages} />
|
|
48
|
+
</StrictMode>
|
|
49
|
+
);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 3. Create route files
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
src/
|
|
56
|
+
├── App.tsx → /
|
|
57
|
+
├── 404.tsx → Global 404 page
|
|
58
|
+
├── about/
|
|
59
|
+
│ └── index.tsx → /about
|
|
60
|
+
├── users/
|
|
61
|
+
│ └── $id/
|
|
62
|
+
│ └── index.tsx → /users/:id
|
|
63
|
+
├── blog/
|
|
64
|
+
│ └── $/
|
|
65
|
+
│ └── index.tsx → /blog/* (catch-all)
|
|
66
|
+
└── (auth)/ → Route group (not in URL)
|
|
67
|
+
├── login/
|
|
68
|
+
│ └── index.tsx → /login
|
|
69
|
+
└── register/
|
|
70
|
+
└── index.tsx → /register
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
### Navigation
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { Link, useRouter } from './route.tree';
|
|
79
|
+
|
|
80
|
+
function MyComponent() {
|
|
81
|
+
const { navigate } = useRouter();
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div>
|
|
85
|
+
<Link href="/about">About</Link>
|
|
86
|
+
<button onClick={() => navigate('/users/123')}>Go to User</button>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Route Params
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import { useParams } from './route.tree';
|
|
96
|
+
|
|
97
|
+
// In src/users/$id/index.tsx
|
|
98
|
+
function UserPage() {
|
|
99
|
+
const { id } = useParams<{ id: string }>();
|
|
100
|
+
return <div>User ID: {id}</div>;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Search Params
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { useSearchParams } from './route.tree';
|
|
108
|
+
|
|
109
|
+
function SearchPage() {
|
|
110
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
111
|
+
|
|
112
|
+
// Read params
|
|
113
|
+
const page = searchParams.page; // "2"
|
|
114
|
+
|
|
115
|
+
// Update params (merge with existing)
|
|
116
|
+
setSearchParams({ page: "3" }, { merge: true });
|
|
117
|
+
|
|
118
|
+
// Replace all params
|
|
119
|
+
setSearchParams({ page: "1", sort: "name" });
|
|
120
|
+
|
|
121
|
+
// Remove a param
|
|
122
|
+
setSearchParams({ page: null }, { merge: true });
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Custom 404 Pages
|
|
127
|
+
|
|
128
|
+
Create `404.tsx` in any folder:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
// src/404.tsx - Global 404
|
|
132
|
+
import { useRouter } from './route.tree';
|
|
133
|
+
|
|
134
|
+
export default function NotFound() {
|
|
135
|
+
const { currentPath, navigate } = useRouter();
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
<h1>Page Not Found</h1>
|
|
139
|
+
<p>Path: {currentPath}</p>
|
|
140
|
+
<button onClick={() => navigate('/')}>Go Home</button>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// src/dashboard/404.tsx - Dashboard-specific 404
|
|
148
|
+
export default function DashboardNotFound() {
|
|
149
|
+
return <div>Dashboard page not found</div>;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Route Patterns
|
|
154
|
+
|
|
155
|
+
| File Path | URL |
|
|
156
|
+
|-----------|-----|
|
|
157
|
+
| `src/App.tsx` | `/` |
|
|
158
|
+
| `src/about/index.tsx` | `/about` |
|
|
159
|
+
| `src/users/$id/index.tsx` | `/users/:id` |
|
|
160
|
+
| `src/users/[id]/index.tsx` | `/users/:id` |
|
|
161
|
+
| `src/blog/$/index.tsx` | `/blog/*` |
|
|
162
|
+
| `src/blog/[...slug]/index.tsx` | `/blog/*` |
|
|
163
|
+
| `src/(auth)/login/index.tsx` | `/login` |
|
|
164
|
+
| `src/(group)/page/index.tsx` | `/page` |
|
|
165
|
+
|
|
166
|
+
## API
|
|
167
|
+
|
|
168
|
+
### Plugin Options
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
olovaRouter({
|
|
172
|
+
rootDir: 'src', // Root directory to scan
|
|
173
|
+
extensions: ['.tsx', '.ts'] // File extensions to look for
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Hooks
|
|
178
|
+
|
|
179
|
+
- `useRouter()` - Returns `{ currentPath, params, navigate, searchParams, setSearchParams }`
|
|
180
|
+
- `useParams<T>()` - Returns route params object
|
|
181
|
+
- `useSearchParams()` - Returns `[searchParams, setSearchParams]`
|
|
182
|
+
|
|
183
|
+
### Components
|
|
184
|
+
|
|
185
|
+
- `OlovaRouter` - Main router component
|
|
186
|
+
- `Link` - Type-safe navigation link
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
export { NotFoundPageConfig, OlovaRouter, SearchParams, SetSearchParamsOptions, createLink, useParams, useRouter, useSearchParams } from './router.js';
|
|
3
|
+
import 'react/jsx-runtime';
|
|
4
|
+
import 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Olova Router - TypeScript Types
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Represents a discovered route from the file system */
|
|
11
|
+
interface RouteEntry {
|
|
12
|
+
path: string;
|
|
13
|
+
filePath: string;
|
|
14
|
+
isDynamic: boolean;
|
|
15
|
+
params: string[];
|
|
16
|
+
}
|
|
17
|
+
/** Route configuration for the generated module */
|
|
18
|
+
interface RouteConfig {
|
|
19
|
+
path: string;
|
|
20
|
+
component: string;
|
|
21
|
+
params?: string[];
|
|
22
|
+
}
|
|
23
|
+
/** Options for the Olova Router Vite plugin */
|
|
24
|
+
interface OlovaRouterOptions {
|
|
25
|
+
/** Root directory to scan (default: "src") */
|
|
26
|
+
rootDir?: string;
|
|
27
|
+
/** File extensions to look for (default: [".tsx", ".ts"]) */
|
|
28
|
+
extensions?: string[];
|
|
29
|
+
}
|
|
30
|
+
/** Represents a 404 page entry detected by the scanner */
|
|
31
|
+
interface NotFoundEntry {
|
|
32
|
+
pathPrefix: string;
|
|
33
|
+
filePath: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Olova Router - Vite Plugin
|
|
38
|
+
* Next.js-style folder-based routing for React + Vite
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Olova Router Vite Plugin
|
|
43
|
+
* Automatically generates route.tree.ts based on folder structure
|
|
44
|
+
*/
|
|
45
|
+
declare function olovaRouter(options?: OlovaRouterOptions): Plugin;
|
|
46
|
+
|
|
47
|
+
export { type NotFoundEntry, type OlovaRouterOptions, type RouteConfig, type RouteEntry, olovaRouter as default, olovaRouter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { createContext, useContext, useState, useEffect } from 'react';
|
|
4
|
+
import { jsx } from 'react/jsx-runtime';
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
function parseSearchParams(search) {
|
|
8
|
+
const params = {};
|
|
9
|
+
const urlParams = new URLSearchParams(search);
|
|
10
|
+
for (const key of urlParams.keys()) {
|
|
11
|
+
const values = urlParams.getAll(key);
|
|
12
|
+
params[key] = values.length === 1 ? values[0] : values;
|
|
13
|
+
}
|
|
14
|
+
return params;
|
|
15
|
+
}
|
|
16
|
+
function buildSearchString(params) {
|
|
17
|
+
const urlParams = new URLSearchParams();
|
|
18
|
+
for (const [key, value] of Object.entries(params)) {
|
|
19
|
+
if (value === null || value === void 0) continue;
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
value.forEach((v) => urlParams.append(key, v));
|
|
22
|
+
} else {
|
|
23
|
+
urlParams.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const str = urlParams.toString();
|
|
27
|
+
return str ? `?${str}` : "";
|
|
28
|
+
}
|
|
29
|
+
var RouterContext = createContext(null);
|
|
30
|
+
function useRouter() {
|
|
31
|
+
const context = useContext(RouterContext);
|
|
32
|
+
if (!context) throw new Error("useRouter must be used within OlovaRouter");
|
|
33
|
+
return context;
|
|
34
|
+
}
|
|
35
|
+
function useParams() {
|
|
36
|
+
const context = useContext(RouterContext);
|
|
37
|
+
return context?.params || {};
|
|
38
|
+
}
|
|
39
|
+
function useSearchParams() {
|
|
40
|
+
const context = useContext(RouterContext);
|
|
41
|
+
if (!context) throw new Error("useSearchParams must be used within OlovaRouter");
|
|
42
|
+
return [context.searchParams, context.setSearchParams];
|
|
43
|
+
}
|
|
44
|
+
function matchRoute(pattern, pathname) {
|
|
45
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
46
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
47
|
+
const params = {};
|
|
48
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
49
|
+
const patternPart = patternParts[i];
|
|
50
|
+
const pathPart = pathParts[i];
|
|
51
|
+
if (patternPart === "*") {
|
|
52
|
+
params["slug"] = pathParts.slice(i).join("/");
|
|
53
|
+
return { match: true, params };
|
|
54
|
+
}
|
|
55
|
+
if (pathPart === void 0) {
|
|
56
|
+
return { match: false, params: {} };
|
|
57
|
+
}
|
|
58
|
+
if (patternPart.startsWith(":")) {
|
|
59
|
+
params[patternPart.slice(1)] = pathPart;
|
|
60
|
+
} else if (patternPart !== pathPart) {
|
|
61
|
+
return { match: false, params: {} };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (pathParts.length > patternParts.length) {
|
|
65
|
+
return { match: false, params: {} };
|
|
66
|
+
}
|
|
67
|
+
return { match: true, params };
|
|
68
|
+
}
|
|
69
|
+
function findNotFoundPage(path2, notFoundPages) {
|
|
70
|
+
if (!notFoundPages || notFoundPages.length === 0) return null;
|
|
71
|
+
const sorted = [...notFoundPages].sort(
|
|
72
|
+
(a, b) => b.pathPrefix.length - a.pathPrefix.length
|
|
73
|
+
);
|
|
74
|
+
for (const nf of sorted) {
|
|
75
|
+
if (nf.pathPrefix === "") {
|
|
76
|
+
return nf.component;
|
|
77
|
+
}
|
|
78
|
+
if (path2 === nf.pathPrefix || path2.startsWith(nf.pathPrefix + "/")) {
|
|
79
|
+
return nf.component;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function OlovaRouter({ routes, notFoundPages = [], notFound = /* @__PURE__ */ jsx("div", { children: "404 - Not Found" }) }) {
|
|
85
|
+
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
|
86
|
+
const [searchParams, setSearchParamsState] = useState(
|
|
87
|
+
() => parseSearchParams(window.location.search)
|
|
88
|
+
);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const onPopState = () => {
|
|
91
|
+
setCurrentPath(window.location.pathname);
|
|
92
|
+
setSearchParamsState(parseSearchParams(window.location.search));
|
|
93
|
+
};
|
|
94
|
+
window.addEventListener("popstate", onPopState);
|
|
95
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
96
|
+
}, []);
|
|
97
|
+
const navigate = (path2) => {
|
|
98
|
+
window.history.pushState({}, "", path2);
|
|
99
|
+
setCurrentPath(path2.split("?")[0]);
|
|
100
|
+
setSearchParamsState(parseSearchParams(path2.includes("?") ? path2.split("?")[1] : ""));
|
|
101
|
+
};
|
|
102
|
+
const setSearchParams = (newParams, options = {}) => {
|
|
103
|
+
const { replace = false, merge = false } = options;
|
|
104
|
+
let finalParams;
|
|
105
|
+
if (merge) {
|
|
106
|
+
finalParams = { ...searchParams, ...newParams };
|
|
107
|
+
for (const key of Object.keys(finalParams)) {
|
|
108
|
+
if (finalParams[key] === null) {
|
|
109
|
+
delete finalParams[key];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
finalParams = newParams;
|
|
114
|
+
}
|
|
115
|
+
const searchString = buildSearchString(finalParams);
|
|
116
|
+
const newUrl = currentPath + searchString;
|
|
117
|
+
if (replace) {
|
|
118
|
+
window.history.replaceState({}, "", newUrl);
|
|
119
|
+
} else {
|
|
120
|
+
window.history.pushState({}, "", newUrl);
|
|
121
|
+
}
|
|
122
|
+
setSearchParamsState(parseSearchParams(searchString));
|
|
123
|
+
};
|
|
124
|
+
let Component = null;
|
|
125
|
+
let params = {};
|
|
126
|
+
const sortedRoutes = [...routes].sort((a, b) => {
|
|
127
|
+
const aHasCatchAll = a.path.includes("*");
|
|
128
|
+
const bHasCatchAll = b.path.includes("*");
|
|
129
|
+
const aHasDynamic = a.path.includes(":");
|
|
130
|
+
const bHasDynamic = b.path.includes(":");
|
|
131
|
+
if (aHasCatchAll && !bHasCatchAll) return 1;
|
|
132
|
+
if (!aHasCatchAll && bHasCatchAll) return -1;
|
|
133
|
+
if (aHasDynamic && !bHasDynamic) return 1;
|
|
134
|
+
if (!aHasDynamic && bHasDynamic) return -1;
|
|
135
|
+
return b.path.length - a.path.length;
|
|
136
|
+
});
|
|
137
|
+
for (const route of sortedRoutes) {
|
|
138
|
+
if (route.path === "/" && currentPath === "/") {
|
|
139
|
+
Component = route.component;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
const result = matchRoute(route.path, currentPath);
|
|
143
|
+
if (result.match) {
|
|
144
|
+
Component = route.component;
|
|
145
|
+
params = result.params;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!Component) {
|
|
150
|
+
const NotFoundComponent = findNotFoundPage(currentPath, notFoundPages);
|
|
151
|
+
if (NotFoundComponent) {
|
|
152
|
+
Component = NotFoundComponent;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return /* @__PURE__ */ jsx(RouterContext.Provider, { value: { currentPath, params, searchParams, navigate, setSearchParams }, children: Component ? /* @__PURE__ */ jsx(Component, {}) : notFound });
|
|
156
|
+
}
|
|
157
|
+
function createLink() {
|
|
158
|
+
return function Link({ href, children, className }) {
|
|
159
|
+
const { navigate } = useRouter();
|
|
160
|
+
return /* @__PURE__ */ jsx("a", { href, className, onClick: (e) => {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
navigate(href);
|
|
163
|
+
}, children });
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/index.ts
|
|
168
|
+
function parseDynamicSegment(segment) {
|
|
169
|
+
if (segment === "$" || segment.match(/^\[\.\.\..+\]$/)) {
|
|
170
|
+
const paramName = segment === "$" ? "slug" : segment.match(/^\[\.\.\.(.+)\]$/)?.[1] || "slug";
|
|
171
|
+
return { isDynamic: true, paramName, isCatchAll: true };
|
|
172
|
+
}
|
|
173
|
+
const bracketMatch = segment.match(/^\[(.+)\]$/);
|
|
174
|
+
if (bracketMatch) {
|
|
175
|
+
return { isDynamic: true, paramName: bracketMatch[1], isCatchAll: false };
|
|
176
|
+
}
|
|
177
|
+
const dollarMatch = segment.match(/^\$(.+)$/);
|
|
178
|
+
if (dollarMatch) {
|
|
179
|
+
return { isDynamic: true, paramName: dollarMatch[1], isCatchAll: false };
|
|
180
|
+
}
|
|
181
|
+
return { isDynamic: false, paramName: null, isCatchAll: false };
|
|
182
|
+
}
|
|
183
|
+
function isRouteGroup(segment) {
|
|
184
|
+
return /^\(.+\)$/.test(segment);
|
|
185
|
+
}
|
|
186
|
+
function pathToRoute(relativePath, sep) {
|
|
187
|
+
const params = [];
|
|
188
|
+
let hasCatchAll = false;
|
|
189
|
+
const segments = relativePath.split(sep).filter(Boolean);
|
|
190
|
+
const routeSegments = segments.filter((segment) => !isRouteGroup(segment)).map((segment) => {
|
|
191
|
+
const { isDynamic, paramName, isCatchAll } = parseDynamicSegment(segment);
|
|
192
|
+
if (isDynamic && paramName) {
|
|
193
|
+
params.push(paramName);
|
|
194
|
+
if (isCatchAll) {
|
|
195
|
+
hasCatchAll = true;
|
|
196
|
+
return `*`;
|
|
197
|
+
}
|
|
198
|
+
return `:${paramName}`;
|
|
199
|
+
}
|
|
200
|
+
return segment;
|
|
201
|
+
});
|
|
202
|
+
const routePath = "/" + routeSegments.join("/");
|
|
203
|
+
return { routePath: routePath === "/" ? "/" : routePath, params, hasCatchAll };
|
|
204
|
+
}
|
|
205
|
+
function detectExportType(filePath) {
|
|
206
|
+
try {
|
|
207
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
208
|
+
if (/export\s+default\s+/.test(content)) {
|
|
209
|
+
return { hasDefault: true, namedExport: null };
|
|
210
|
+
}
|
|
211
|
+
const namedMatch = content.match(/export\s+(?:const|function|class)\s+(\w+)/);
|
|
212
|
+
if (namedMatch) {
|
|
213
|
+
return { hasDefault: false, namedExport: namedMatch[1] };
|
|
214
|
+
}
|
|
215
|
+
const exportMatch = content.match(/export\s*\{\s*(\w+)(?:\s+as\s+default)?\s*\}/);
|
|
216
|
+
if (exportMatch) {
|
|
217
|
+
if (content.includes("as default")) {
|
|
218
|
+
return { hasDefault: true, namedExport: null };
|
|
219
|
+
}
|
|
220
|
+
return { hasDefault: false, namedExport: exportMatch[1] };
|
|
221
|
+
}
|
|
222
|
+
return { hasDefault: false, namedExport: null };
|
|
223
|
+
} catch {
|
|
224
|
+
return { hasDefault: true, namedExport: null };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function scanDirectory(dir, rootDir, extensions, routes, notFoundPages, isRoot = false) {
|
|
228
|
+
if (!fs.existsSync(dir)) return;
|
|
229
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
const fullPath = path.join(dir, entry.name);
|
|
232
|
+
if (entry.isDirectory()) {
|
|
233
|
+
if (entry.name === "node_modules" || entry.name === "assets" || entry.name.startsWith("_")) continue;
|
|
234
|
+
scanDirectory(fullPath, rootDir, extensions, routes, notFoundPages, false);
|
|
235
|
+
} else if (entry.isFile()) {
|
|
236
|
+
const ext = path.extname(entry.name);
|
|
237
|
+
const baseName = path.basename(entry.name, ext);
|
|
238
|
+
if (baseName === "404" && extensions.includes(ext)) {
|
|
239
|
+
const relativeParts = path.relative(rootDir, dir).split(path.sep).filter(Boolean);
|
|
240
|
+
const filteredParts = relativeParts.filter((p) => !isRouteGroup(p));
|
|
241
|
+
const pathPrefix = isRoot ? "" : "/" + filteredParts.join("/");
|
|
242
|
+
notFoundPages.push({ pathPrefix: pathPrefix || "", filePath: fullPath });
|
|
243
|
+
} else if (isRoot && baseName === "App" && extensions.includes(ext)) {
|
|
244
|
+
routes.push({ path: "/", filePath: fullPath, isDynamic: false, params: [] });
|
|
245
|
+
} else if (!isRoot && baseName === "index" && extensions.includes(ext)) {
|
|
246
|
+
const relativePath = path.relative(rootDir, path.dirname(fullPath));
|
|
247
|
+
const { routePath, params } = pathToRoute(relativePath, path.sep);
|
|
248
|
+
routes.push({ path: routePath, filePath: fullPath, isDynamic: params.length > 0, params });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function scanRoutes(rootDir, extensions) {
|
|
254
|
+
const routes = [];
|
|
255
|
+
const notFoundPages = [];
|
|
256
|
+
const absoluteRoot = path.isAbsolute(rootDir) ? rootDir : path.resolve(rootDir);
|
|
257
|
+
if (!fs.existsSync(absoluteRoot)) {
|
|
258
|
+
throw new Error(`Olova Router: Root directory does not exist: ${absoluteRoot}`);
|
|
259
|
+
}
|
|
260
|
+
scanDirectory(absoluteRoot, absoluteRoot, extensions, routes, notFoundPages, true);
|
|
261
|
+
routes.sort((a, b) => a.isDynamic !== b.isDynamic ? a.isDynamic ? 1 : -1 : a.path.localeCompare(b.path));
|
|
262
|
+
notFoundPages.sort((a, b) => b.pathPrefix.length - a.pathPrefix.length);
|
|
263
|
+
return { routes, notFoundPages };
|
|
264
|
+
}
|
|
265
|
+
function generateRouteTree(routes, notFoundPages, srcDir) {
|
|
266
|
+
const routeImports = routes.map((route, index) => {
|
|
267
|
+
const relativePath = "./" + path.relative(srcDir, route.component).replace(/\\/g, "/").replace(/\.tsx?$/, "");
|
|
268
|
+
if (route.hasDefault) {
|
|
269
|
+
return `import Route${index} from '${relativePath}';`;
|
|
270
|
+
} else if (route.namedExport) {
|
|
271
|
+
return `import { ${route.namedExport} as Route${index} } from '${relativePath}';`;
|
|
272
|
+
} else {
|
|
273
|
+
return `import Route${index} from '${relativePath}';`;
|
|
274
|
+
}
|
|
275
|
+
}).join("\n");
|
|
276
|
+
const notFoundImports = notFoundPages.map((nf, index) => {
|
|
277
|
+
const relativePath = "./" + path.relative(srcDir, nf.filePath).replace(/\\/g, "/").replace(/\.tsx?$/, "");
|
|
278
|
+
if (nf.hasDefault) {
|
|
279
|
+
return `import NotFound${index} from '${relativePath}';`;
|
|
280
|
+
} else if (nf.namedExport) {
|
|
281
|
+
return `import { ${nf.namedExport} as NotFound${index} } from '${relativePath}';`;
|
|
282
|
+
} else {
|
|
283
|
+
return `import NotFound${index} from '${relativePath}';`;
|
|
284
|
+
}
|
|
285
|
+
}).join("\n");
|
|
286
|
+
const routeObjects = routes.map((route, index) => {
|
|
287
|
+
return ` { path: '${route.path}', component: Route${index} }`;
|
|
288
|
+
}).join(",\n");
|
|
289
|
+
const notFoundObjects = notFoundPages.map((nf, index) => {
|
|
290
|
+
return ` { pathPrefix: '${nf.pathPrefix}', component: NotFound${index} }`;
|
|
291
|
+
}).join(",\n");
|
|
292
|
+
const routePaths = routes.length > 0 ? routes.map((r) => `'${r.path}'`).join(" | ") : "never";
|
|
293
|
+
const allImports = [routeImports, notFoundImports].filter(Boolean).join("\n");
|
|
294
|
+
return `// Auto-generated by olova-router - DO NOT EDIT
|
|
295
|
+
// This file is auto-updated when you add/remove route folders
|
|
296
|
+
|
|
297
|
+
import { createLink, OlovaRouter, useRouter, useParams, useSearchParams } from 'olova-router/router';
|
|
298
|
+
${allImports}
|
|
299
|
+
|
|
300
|
+
export const routes = [
|
|
301
|
+
${routeObjects || ""}
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
export const notFoundPages = [
|
|
305
|
+
${notFoundObjects || ""}
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
export type RoutePaths = ${routePaths};
|
|
309
|
+
|
|
310
|
+
export const Link = createLink<RoutePaths>();
|
|
311
|
+
export { OlovaRouter, useRouter, useParams, useSearchParams };
|
|
312
|
+
export type { NotFoundPageConfig, SearchParams, SetSearchParamsOptions } from 'olova-router/router';
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
function olovaRouter(options = {}) {
|
|
316
|
+
const rootDir = options.rootDir || "src";
|
|
317
|
+
const extensions = options.extensions || [".tsx", ".ts"];
|
|
318
|
+
let config;
|
|
319
|
+
let absoluteRootDir;
|
|
320
|
+
let watcher = null;
|
|
321
|
+
function generateRouteTreeFile() {
|
|
322
|
+
const { routes, notFoundPages } = scanRoutes(absoluteRootDir, extensions);
|
|
323
|
+
const routeConfigs = routes.map((r) => {
|
|
324
|
+
const exportInfo = detectExportType(r.filePath);
|
|
325
|
+
return {
|
|
326
|
+
path: r.path,
|
|
327
|
+
component: r.filePath.replace(/\\/g, "/"),
|
|
328
|
+
params: r.params.length > 0 ? r.params : void 0,
|
|
329
|
+
hasDefault: exportInfo.hasDefault,
|
|
330
|
+
namedExport: exportInfo.namedExport
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
const notFoundConfigs = notFoundPages.map((nf) => {
|
|
334
|
+
const exportInfo = detectExportType(nf.filePath);
|
|
335
|
+
return {
|
|
336
|
+
pathPrefix: nf.pathPrefix,
|
|
337
|
+
filePath: nf.filePath.replace(/\\/g, "/"),
|
|
338
|
+
hasDefault: exportInfo.hasDefault,
|
|
339
|
+
namedExport: exportInfo.namedExport
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
const content = generateRouteTree(routeConfigs, notFoundConfigs, absoluteRootDir);
|
|
343
|
+
const treePath = path.resolve(absoluteRootDir, "route.tree.ts");
|
|
344
|
+
const existing = fs.existsSync(treePath) ? fs.readFileSync(treePath, "utf-8") : "";
|
|
345
|
+
if (existing !== content) {
|
|
346
|
+
fs.writeFileSync(treePath, content);
|
|
347
|
+
console.log("\x1B[32m[olova-router]\x1B[0m Route tree updated");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function startWatcher() {
|
|
351
|
+
if (watcher) return;
|
|
352
|
+
watcher = fs.watch(absoluteRootDir, { recursive: true }, (_eventType, filename) => {
|
|
353
|
+
if (!filename) return;
|
|
354
|
+
if (filename.includes("route.tree.ts")) return;
|
|
355
|
+
const isIndexFile = filename.endsWith("index.tsx") || filename.endsWith("index.ts");
|
|
356
|
+
const isAppFile = filename === "App.tsx" || filename === "App.ts";
|
|
357
|
+
const is404File = filename.endsWith("404.tsx") || filename.endsWith("404.ts");
|
|
358
|
+
const isDirectory = !filename.includes(".");
|
|
359
|
+
if (isIndexFile || isAppFile || is404File || isDirectory) {
|
|
360
|
+
setTimeout(() => generateRouteTreeFile(), 100);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
console.log("\x1B[32m[olova-router]\x1B[0m Watching for route changes...");
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
name: "olova-router",
|
|
367
|
+
configResolved(resolvedConfig) {
|
|
368
|
+
config = resolvedConfig;
|
|
369
|
+
absoluteRootDir = path.resolve(config.root, rootDir);
|
|
370
|
+
},
|
|
371
|
+
buildStart() {
|
|
372
|
+
generateRouteTreeFile();
|
|
373
|
+
if (config.command === "serve") {
|
|
374
|
+
startWatcher();
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
buildEnd() {
|
|
378
|
+
if (watcher) {
|
|
379
|
+
watcher.close();
|
|
380
|
+
watcher = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
var src_default = olovaRouter;
|
|
386
|
+
|
|
387
|
+
export { OlovaRouter, createLink, src_default as default, olovaRouter, useParams, useRouter, useSearchParams };
|
package/dist/router.d.ts
CHANGED
|
@@ -1 +1,50 @@
|
|
|
1
|
-
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ComponentType, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface Route {
|
|
5
|
+
path: string;
|
|
6
|
+
component: ComponentType;
|
|
7
|
+
}
|
|
8
|
+
/** 404 page configuration */
|
|
9
|
+
interface NotFoundPageConfig {
|
|
10
|
+
pathPrefix: string;
|
|
11
|
+
component: ComponentType;
|
|
12
|
+
}
|
|
13
|
+
/** Search params type - values can be string or array for multi-value params */
|
|
14
|
+
type SearchParams = Record<string, string | string[]>;
|
|
15
|
+
/** Options for setSearchParams */
|
|
16
|
+
interface SetSearchParamsOptions {
|
|
17
|
+
replace?: boolean;
|
|
18
|
+
merge?: boolean;
|
|
19
|
+
}
|
|
20
|
+
interface RouterContextType {
|
|
21
|
+
currentPath: string;
|
|
22
|
+
params: Record<string, string>;
|
|
23
|
+
searchParams: SearchParams;
|
|
24
|
+
navigate: (path: string) => void;
|
|
25
|
+
setSearchParams: (params: Record<string, string | string[] | null>, options?: SetSearchParamsOptions) => void;
|
|
26
|
+
}
|
|
27
|
+
/** Hook to access router context (navigate, currentPath) */
|
|
28
|
+
declare function useRouter(): RouterContextType;
|
|
29
|
+
/** Hook to access route params (e.g., :id from /users/:id) */
|
|
30
|
+
declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
|
|
31
|
+
/** Hook to read and update URL search params (query string) */
|
|
32
|
+
declare function useSearchParams(): [
|
|
33
|
+
SearchParams,
|
|
34
|
+
(params: Record<string, string | string[] | null>, options?: SetSearchParamsOptions) => void
|
|
35
|
+
];
|
|
36
|
+
interface OlovaRouterProps {
|
|
37
|
+
routes: Route[];
|
|
38
|
+
notFoundPages?: NotFoundPageConfig[];
|
|
39
|
+
notFound?: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
/** Main router component - wrap your app with this */
|
|
42
|
+
declare function OlovaRouter({ routes, notFoundPages, notFound }: OlovaRouterProps): react_jsx_runtime.JSX.Element;
|
|
43
|
+
/** Creates a type-safe Link component for the given route paths */
|
|
44
|
+
declare function createLink<T extends string>(): ({ href, children, className }: {
|
|
45
|
+
href: T;
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
}) => react_jsx_runtime.JSX.Element;
|
|
49
|
+
|
|
50
|
+
export { type NotFoundPageConfig, OlovaRouter, type SearchParams, type SetSearchParamsOptions, createLink, useParams, useRouter, useSearchParams };
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect } from 'react';
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
// src/router.tsx
|
|
5
|
+
function parseSearchParams(search) {
|
|
6
|
+
const params = {};
|
|
7
|
+
const urlParams = new URLSearchParams(search);
|
|
8
|
+
for (const key of urlParams.keys()) {
|
|
9
|
+
const values = urlParams.getAll(key);
|
|
10
|
+
params[key] = values.length === 1 ? values[0] : values;
|
|
11
|
+
}
|
|
12
|
+
return params;
|
|
13
|
+
}
|
|
14
|
+
function buildSearchString(params) {
|
|
15
|
+
const urlParams = new URLSearchParams();
|
|
16
|
+
for (const [key, value] of Object.entries(params)) {
|
|
17
|
+
if (value === null || value === void 0) continue;
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
value.forEach((v) => urlParams.append(key, v));
|
|
20
|
+
} else {
|
|
21
|
+
urlParams.set(key, value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const str = urlParams.toString();
|
|
25
|
+
return str ? `?${str}` : "";
|
|
26
|
+
}
|
|
27
|
+
var RouterContext = createContext(null);
|
|
28
|
+
function useRouter() {
|
|
29
|
+
const context = useContext(RouterContext);
|
|
30
|
+
if (!context) throw new Error("useRouter must be used within OlovaRouter");
|
|
31
|
+
return context;
|
|
32
|
+
}
|
|
33
|
+
function useParams() {
|
|
34
|
+
const context = useContext(RouterContext);
|
|
35
|
+
return context?.params || {};
|
|
36
|
+
}
|
|
37
|
+
function useSearchParams() {
|
|
38
|
+
const context = useContext(RouterContext);
|
|
39
|
+
if (!context) throw new Error("useSearchParams must be used within OlovaRouter");
|
|
40
|
+
return [context.searchParams, context.setSearchParams];
|
|
41
|
+
}
|
|
42
|
+
function matchRoute(pattern, pathname) {
|
|
43
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
44
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
45
|
+
const params = {};
|
|
46
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
47
|
+
const patternPart = patternParts[i];
|
|
48
|
+
const pathPart = pathParts[i];
|
|
49
|
+
if (patternPart === "*") {
|
|
50
|
+
params["slug"] = pathParts.slice(i).join("/");
|
|
51
|
+
return { match: true, params };
|
|
52
|
+
}
|
|
53
|
+
if (pathPart === void 0) {
|
|
54
|
+
return { match: false, params: {} };
|
|
55
|
+
}
|
|
56
|
+
if (patternPart.startsWith(":")) {
|
|
57
|
+
params[patternPart.slice(1)] = pathPart;
|
|
58
|
+
} else if (patternPart !== pathPart) {
|
|
59
|
+
return { match: false, params: {} };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (pathParts.length > patternParts.length) {
|
|
63
|
+
return { match: false, params: {} };
|
|
64
|
+
}
|
|
65
|
+
return { match: true, params };
|
|
66
|
+
}
|
|
67
|
+
function findNotFoundPage(path, notFoundPages) {
|
|
68
|
+
if (!notFoundPages || notFoundPages.length === 0) return null;
|
|
69
|
+
const sorted = [...notFoundPages].sort(
|
|
70
|
+
(a, b) => b.pathPrefix.length - a.pathPrefix.length
|
|
71
|
+
);
|
|
72
|
+
for (const nf of sorted) {
|
|
73
|
+
if (nf.pathPrefix === "") {
|
|
74
|
+
return nf.component;
|
|
75
|
+
}
|
|
76
|
+
if (path === nf.pathPrefix || path.startsWith(nf.pathPrefix + "/")) {
|
|
77
|
+
return nf.component;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function OlovaRouter({ routes, notFoundPages = [], notFound = /* @__PURE__ */ jsx("div", { children: "404 - Not Found" }) }) {
|
|
83
|
+
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
|
84
|
+
const [searchParams, setSearchParamsState] = useState(
|
|
85
|
+
() => parseSearchParams(window.location.search)
|
|
86
|
+
);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const onPopState = () => {
|
|
89
|
+
setCurrentPath(window.location.pathname);
|
|
90
|
+
setSearchParamsState(parseSearchParams(window.location.search));
|
|
91
|
+
};
|
|
92
|
+
window.addEventListener("popstate", onPopState);
|
|
93
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
94
|
+
}, []);
|
|
95
|
+
const navigate = (path) => {
|
|
96
|
+
window.history.pushState({}, "", path);
|
|
97
|
+
setCurrentPath(path.split("?")[0]);
|
|
98
|
+
setSearchParamsState(parseSearchParams(path.includes("?") ? path.split("?")[1] : ""));
|
|
99
|
+
};
|
|
100
|
+
const setSearchParams = (newParams, options = {}) => {
|
|
101
|
+
const { replace = false, merge = false } = options;
|
|
102
|
+
let finalParams;
|
|
103
|
+
if (merge) {
|
|
104
|
+
finalParams = { ...searchParams, ...newParams };
|
|
105
|
+
for (const key of Object.keys(finalParams)) {
|
|
106
|
+
if (finalParams[key] === null) {
|
|
107
|
+
delete finalParams[key];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
finalParams = newParams;
|
|
112
|
+
}
|
|
113
|
+
const searchString = buildSearchString(finalParams);
|
|
114
|
+
const newUrl = currentPath + searchString;
|
|
115
|
+
if (replace) {
|
|
116
|
+
window.history.replaceState({}, "", newUrl);
|
|
117
|
+
} else {
|
|
118
|
+
window.history.pushState({}, "", newUrl);
|
|
119
|
+
}
|
|
120
|
+
setSearchParamsState(parseSearchParams(searchString));
|
|
121
|
+
};
|
|
122
|
+
let Component = null;
|
|
123
|
+
let params = {};
|
|
124
|
+
const sortedRoutes = [...routes].sort((a, b) => {
|
|
125
|
+
const aHasCatchAll = a.path.includes("*");
|
|
126
|
+
const bHasCatchAll = b.path.includes("*");
|
|
127
|
+
const aHasDynamic = a.path.includes(":");
|
|
128
|
+
const bHasDynamic = b.path.includes(":");
|
|
129
|
+
if (aHasCatchAll && !bHasCatchAll) return 1;
|
|
130
|
+
if (!aHasCatchAll && bHasCatchAll) return -1;
|
|
131
|
+
if (aHasDynamic && !bHasDynamic) return 1;
|
|
132
|
+
if (!aHasDynamic && bHasDynamic) return -1;
|
|
133
|
+
return b.path.length - a.path.length;
|
|
134
|
+
});
|
|
135
|
+
for (const route of sortedRoutes) {
|
|
136
|
+
if (route.path === "/" && currentPath === "/") {
|
|
137
|
+
Component = route.component;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
const result = matchRoute(route.path, currentPath);
|
|
141
|
+
if (result.match) {
|
|
142
|
+
Component = route.component;
|
|
143
|
+
params = result.params;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!Component) {
|
|
148
|
+
const NotFoundComponent = findNotFoundPage(currentPath, notFoundPages);
|
|
149
|
+
if (NotFoundComponent) {
|
|
150
|
+
Component = NotFoundComponent;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return /* @__PURE__ */ jsx(RouterContext.Provider, { value: { currentPath, params, searchParams, navigate, setSearchParams }, children: Component ? /* @__PURE__ */ jsx(Component, {}) : notFound });
|
|
154
|
+
}
|
|
155
|
+
function createLink() {
|
|
156
|
+
return function Link({ href, children, className }) {
|
|
157
|
+
const { navigate } = useRouter();
|
|
158
|
+
return /* @__PURE__ */ jsx("a", { href, className, onClick: (e) => {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
navigate(href);
|
|
161
|
+
}, children });
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { OlovaRouter, createLink, useParams, useRouter, useSearchParams };
|
package/package.json
CHANGED
|
@@ -1,15 +1,56 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "olova-router",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "olova-router",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Next.js-style folder-based routing for React + Vite applications",
|
|
5
|
+
"author": "",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./router": {
|
|
17
|
+
"import": "./dist/router.js",
|
|
18
|
+
"types": "./dist/router.d.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"react",
|
|
31
|
+
"router",
|
|
32
|
+
"vite",
|
|
33
|
+
"vite-plugin",
|
|
34
|
+
"file-based-routing",
|
|
35
|
+
"folder-routing",
|
|
36
|
+
"nextjs-style"
|
|
37
|
+
],
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": ">=18.0.0",
|
|
40
|
+
"vite": ">=5.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"@types/react": "^18.0.0",
|
|
45
|
+
"tsup": "^8.0.0",
|
|
46
|
+
"typescript": "^5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": ""
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": ""
|
|
54
|
+
},
|
|
55
|
+
"homepage": ""
|
|
56
|
+
}
|
package/dist/olova-router.js
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
/** @jsx Olova.createElement */
|
|
2
|
-
import Olova from "Olova";
|
|
3
|
-
|
|
4
|
-
const NavigationContext = Olova.createContext({
|
|
5
|
-
basename: "",
|
|
6
|
-
navigator: null,
|
|
7
|
-
static: false,
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
const LocationContext = Olova.createContext({
|
|
11
|
-
location: {
|
|
12
|
-
pathname: window.location.pathname,
|
|
13
|
-
search: window.location.search,
|
|
14
|
-
hash: window.location.hash,
|
|
15
|
-
state: null,
|
|
16
|
-
key: "",
|
|
17
|
-
},
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const RouteContext = Olova.createContext({
|
|
21
|
-
params: {},
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
function createLocation(path) {
|
|
25
|
-
const [pathname, search] = path.split("?");
|
|
26
|
-
return {
|
|
27
|
-
pathname: pathname || "/",
|
|
28
|
-
search: search ? `?${search}` : "",
|
|
29
|
-
hash: window.location.hash || "",
|
|
30
|
-
state: null,
|
|
31
|
-
key: Math.random().toString(36).slice(2),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function BrowserRouter({ children, basename = "" }) {
|
|
36
|
-
const [location, setLocation] = Olova.State(
|
|
37
|
-
createLocation(
|
|
38
|
-
window.location.pathname + window.location.search + window.location.hash
|
|
39
|
-
)
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const navigator = {
|
|
43
|
-
push(to) {
|
|
44
|
-
const newLocation = createLocation(to);
|
|
45
|
-
window.history.pushState(null, "", to);
|
|
46
|
-
setLocation(newLocation);
|
|
47
|
-
},
|
|
48
|
-
replace(to) {
|
|
49
|
-
const newLocation = createLocation(to);
|
|
50
|
-
window.history.replaceState(null, "", to);
|
|
51
|
-
setLocation(newLocation);
|
|
52
|
-
},
|
|
53
|
-
go(delta) {
|
|
54
|
-
window.history.go(delta);
|
|
55
|
-
},
|
|
56
|
-
back() {
|
|
57
|
-
window.history.back();
|
|
58
|
-
},
|
|
59
|
-
forward() {
|
|
60
|
-
window.history.forward();
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
Olova.Effect(() => {
|
|
65
|
-
const handlePopState = () => {
|
|
66
|
-
setLocation(
|
|
67
|
-
createLocation(
|
|
68
|
-
window.location.pathname +
|
|
69
|
-
window.location.search +
|
|
70
|
-
window.location.hash
|
|
71
|
-
)
|
|
72
|
-
);
|
|
73
|
-
};
|
|
74
|
-
window.addEventListener("popstate", handlePopState);
|
|
75
|
-
return () => window.removeEventListener("popstate", handlePopState);
|
|
76
|
-
}, []);
|
|
77
|
-
|
|
78
|
-
const navigationValue = Olova.Memo(
|
|
79
|
-
() => ({ navigator, basename }),
|
|
80
|
-
[navigator, basename]
|
|
81
|
-
);
|
|
82
|
-
const locationValue = Olova.Memo(() => ({ location }), [location]);
|
|
83
|
-
|
|
84
|
-
return Olova.createElement(
|
|
85
|
-
NavigationContext.Provider,
|
|
86
|
-
{ value: navigationValue },
|
|
87
|
-
Olova.createElement(
|
|
88
|
-
LocationContext.Provider,
|
|
89
|
-
{ value: locationValue },
|
|
90
|
-
children
|
|
91
|
-
)
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function Routes({ children }) {
|
|
96
|
-
const { location } = Olova.Context(LocationContext);
|
|
97
|
-
const { basename } = Olova.Context(NavigationContext);
|
|
98
|
-
|
|
99
|
-
let element = null;
|
|
100
|
-
let matchParams = {};
|
|
101
|
-
|
|
102
|
-
const childrenArray = Array.isArray(children) ? children : [children];
|
|
103
|
-
const validChildren = childrenArray.filter((child) => child != null);
|
|
104
|
-
|
|
105
|
-
for (let i = 0; i < validChildren.length; i++) {
|
|
106
|
-
const child = validChildren[i];
|
|
107
|
-
if (child.props) {
|
|
108
|
-
const path = basename + (child.props.path || "");
|
|
109
|
-
const match = matchPath(path, location.pathname);
|
|
110
|
-
if (match) {
|
|
111
|
-
matchParams = match.params;
|
|
112
|
-
element =
|
|
113
|
-
typeof child.props.element === "function"
|
|
114
|
-
? Olova.createElement(child.props.element, { params: match.params })
|
|
115
|
-
: child.props.element;
|
|
116
|
-
break;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return element;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function Route({ path, element }) {
|
|
125
|
-
return Olova.createElement("route", { path, element });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function Link({
|
|
129
|
-
to,
|
|
130
|
-
children,
|
|
131
|
-
className = "",
|
|
132
|
-
activeClassName = "",
|
|
133
|
-
...props
|
|
134
|
-
}) {
|
|
135
|
-
const { navigator, basename } = Olova.Context(NavigationContext);
|
|
136
|
-
const { location } = Olova.Context(LocationContext);
|
|
137
|
-
|
|
138
|
-
const targetPath = basename + to;
|
|
139
|
-
const isActive = location.pathname === targetPath;
|
|
140
|
-
const finalClassName = isActive
|
|
141
|
-
? `${className} ${activeClassName}`.trim()
|
|
142
|
-
: className;
|
|
143
|
-
|
|
144
|
-
const handleClick = (event) => {
|
|
145
|
-
event.preventDefault();
|
|
146
|
-
navigator.push(targetPath);
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
return Olova.createElement(
|
|
150
|
-
"a",
|
|
151
|
-
{
|
|
152
|
-
href: targetPath,
|
|
153
|
-
onClick: handleClick,
|
|
154
|
-
className: finalClassName,
|
|
155
|
-
...props,
|
|
156
|
-
},
|
|
157
|
-
children
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function matchPath(pattern, pathname) {
|
|
162
|
-
if (pattern === pathname || pattern === "*") return { params: {} };
|
|
163
|
-
if (pattern === "/" && (pathname === "" || pathname === "/"))
|
|
164
|
-
return { params: {} };
|
|
165
|
-
|
|
166
|
-
const patternSegments = pattern.split("/").filter(Boolean);
|
|
167
|
-
const pathnameSegments = pathname.split("/").filter(Boolean);
|
|
168
|
-
|
|
169
|
-
if (patternSegments.length !== pathnameSegments.length) return null;
|
|
170
|
-
|
|
171
|
-
const params = {};
|
|
172
|
-
|
|
173
|
-
for (let i = 0; i < patternSegments.length; i++) {
|
|
174
|
-
const patternSegment = patternSegments[i];
|
|
175
|
-
const pathnameSegment = pathnameSegments[i];
|
|
176
|
-
|
|
177
|
-
if (patternSegment.startsWith(":")) {
|
|
178
|
-
const paramName = patternSegment.slice(1);
|
|
179
|
-
params[paramName] = decodeURIComponent(pathnameSegment);
|
|
180
|
-
} else if (patternSegment !== pathnameSegment) {
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return { params };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function useSearchParams() {
|
|
189
|
-
const { location } = useLocation();
|
|
190
|
-
const searchParams = new URLSearchParams(location.search);
|
|
191
|
-
|
|
192
|
-
const setSearchParams = (newParams) => {
|
|
193
|
-
const navigate = useNavigate();
|
|
194
|
-
const newSearch = new URLSearchParams(newParams).toString();
|
|
195
|
-
navigate(`${location.pathname}?${newSearch}${location.hash}`);
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
return [searchParams, setSearchParams];
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function useNavigate() {
|
|
202
|
-
const { navigator } = Olova.Context(NavigationContext);
|
|
203
|
-
return Olova.Callback(
|
|
204
|
-
(to, options = {}) => {
|
|
205
|
-
if (options.replace) {
|
|
206
|
-
navigator.replace(to);
|
|
207
|
-
} else {
|
|
208
|
-
navigator.push(to);
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
[navigator]
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function useLocation() {
|
|
216
|
-
const { location } = Olova.Context(LocationContext);
|
|
217
|
-
return location;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export function useParams() {
|
|
221
|
-
const { params } = Olova.Context(RouteContext);
|
|
222
|
-
return params;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export function Navigate({ to, replace = false }) {
|
|
226
|
-
const navigate = useNavigate();
|
|
227
|
-
Olova.Effect(() => {
|
|
228
|
-
navigate(to, { replace });
|
|
229
|
-
}, [navigate, to, replace]);
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function Outlet() {
|
|
234
|
-
const { outlet } = Olova.Context(RouteContext);
|
|
235
|
-
return outlet || null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export default {
|
|
239
|
-
BrowserRouter,
|
|
240
|
-
Routes,
|
|
241
|
-
Route,
|
|
242
|
-
Link,
|
|
243
|
-
Navigate,
|
|
244
|
-
Outlet,
|
|
245
|
-
useNavigate,
|
|
246
|
-
useLocation,
|
|
247
|
-
useParams,
|
|
248
|
-
useSearchParams,
|
|
249
|
-
};
|