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 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
@@ -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
- declare module "olova-router";
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.1",
4
- "main": "dist/olova-router.js",
5
- "keywords": [
6
- "javascript",
7
- "framework",
8
- "router",
9
- "olova-router",
10
- "olovajs"
11
- ],
12
- "author": "Nazmul Hossain",
13
- "license": "ISC",
14
- "description": "olova-router for olovaJs"
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
+ }
@@ -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
- };