what-router 0.5.3 → 0.5.5
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 +188 -0
- package/package.json +5 -5
- package/src/index.js +75 -12
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# what-router
|
|
2
|
+
|
|
3
|
+
Client-side router for [What Framework](https://whatfw.com). Supports dynamic routes, nested layouts, route groups, middleware, View Transitions API, scroll restoration, and prefetching.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install what-router what-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use via the main package:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { Router, Link, navigate } from 'what-framework/router';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```jsx
|
|
20
|
+
import { mount } from 'what-framework';
|
|
21
|
+
import { Router, Link, navigate } from 'what-router';
|
|
22
|
+
|
|
23
|
+
function Home() {
|
|
24
|
+
return <h1>Home</h1>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function User({ params }) {
|
|
28
|
+
return <h1>User {params.id}</h1>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function App() {
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<nav>
|
|
35
|
+
<Link href="/">Home</Link>
|
|
36
|
+
<Link href="/users/1">User 1</Link>
|
|
37
|
+
</nav>
|
|
38
|
+
<Router
|
|
39
|
+
routes={[
|
|
40
|
+
{ path: '/', component: Home },
|
|
41
|
+
{ path: '/users/:id', component: User },
|
|
42
|
+
]}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
mount(<App />, '#app');
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Route Patterns
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
{ path: '/users/:id', component: User } // Dynamic param
|
|
55
|
+
{ path: '/docs/*', component: DocsLayout } // Catch-all
|
|
56
|
+
{ path: '/blog/[slug]', component: Post } // File-based syntax
|
|
57
|
+
{ path: '/[...rest]', component: CatchAll } // Named catch-all
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Navigation
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { navigate, route } from 'what-router';
|
|
64
|
+
|
|
65
|
+
// Programmatic navigation
|
|
66
|
+
navigate('/dashboard');
|
|
67
|
+
navigate('/login', { replace: true });
|
|
68
|
+
navigate('/page', { transition: false }); // skip View Transition
|
|
69
|
+
|
|
70
|
+
// Reactive route state
|
|
71
|
+
route.path; // current path
|
|
72
|
+
route.params; // { id: '123' }
|
|
73
|
+
route.query; // { page: '1' }
|
|
74
|
+
route.hash; // '#section'
|
|
75
|
+
route.isNavigating;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Link Component
|
|
79
|
+
|
|
80
|
+
```jsx
|
|
81
|
+
<Link href="/about">About</Link>
|
|
82
|
+
<Link href="/about" activeClass="active" exactActiveClass="exact-active">About</Link>
|
|
83
|
+
<Link href="/about" replace prefetch={false}>About</Link>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Links automatically get `active` and `exact-active` CSS classes based on the current route. Hover prefetching is enabled by default.
|
|
87
|
+
|
|
88
|
+
## Nested Layouts
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { defineRoutes, nestedRoutes, Outlet } from 'what-router';
|
|
92
|
+
|
|
93
|
+
function DashboardLayout({ children }) {
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<Sidebar />
|
|
97
|
+
<main>{children}</main>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const routes = [
|
|
103
|
+
...nestedRoutes('/dashboard', [
|
|
104
|
+
{ path: '/', component: DashboardHome },
|
|
105
|
+
{ path: '/settings', component: Settings },
|
|
106
|
+
], { layout: DashboardLayout }),
|
|
107
|
+
];
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Route Guards & Middleware
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
import { guard, asyncGuard } from 'what-router';
|
|
114
|
+
|
|
115
|
+
// Sync guard
|
|
116
|
+
const requireAuth = guard(
|
|
117
|
+
() => isLoggedIn(),
|
|
118
|
+
'/login' // redirect on failure
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const ProtectedPage = requireAuth(Dashboard);
|
|
122
|
+
|
|
123
|
+
// Async guard
|
|
124
|
+
const requireRole = asyncGuard(
|
|
125
|
+
async () => await checkPermission('admin'),
|
|
126
|
+
{ fallback: '/unauthorized', loading: Spinner }
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Route-level middleware
|
|
130
|
+
{
|
|
131
|
+
path: '/admin',
|
|
132
|
+
component: AdminPanel,
|
|
133
|
+
middleware: [authMiddleware, roleMiddleware],
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## View Transitions
|
|
138
|
+
|
|
139
|
+
Navigation uses the View Transitions API by default when available. Use helpers to customize:
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { viewTransitionName, setViewTransition } from 'what-router';
|
|
143
|
+
|
|
144
|
+
// Name elements for transitions
|
|
145
|
+
<img {...viewTransitionName('hero-image')} src={url} />
|
|
146
|
+
|
|
147
|
+
// Set transition type
|
|
148
|
+
setViewTransition('slide');
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Scroll Restoration
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { enableScrollRestoration } from 'what-router';
|
|
155
|
+
|
|
156
|
+
enableScrollRestoration(); // call once at app entry
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## API
|
|
160
|
+
|
|
161
|
+
| Export | Description |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `Router` | Route matching component |
|
|
164
|
+
| `Link` / `NavLink` | Navigation link with active states |
|
|
165
|
+
| `navigate(to, opts?)` | Programmatic navigation |
|
|
166
|
+
| `route` | Reactive route state object |
|
|
167
|
+
| `useRoute()` | Hook returning computed route properties |
|
|
168
|
+
| `defineRoutes(config)` | Create routes from flat object |
|
|
169
|
+
| `nestedRoutes(base, children, opts?)` | Nested route helper |
|
|
170
|
+
| `routeGroup(name, routes, opts?)` | Group routes without affecting URL |
|
|
171
|
+
| `guard(check, fallback)` | Sync route guard |
|
|
172
|
+
| `asyncGuard(check, opts?)` | Async route guard |
|
|
173
|
+
| `Redirect` | Redirect component |
|
|
174
|
+
| `Outlet` | Nested route outlet |
|
|
175
|
+
| `FileRouter` | File-based router component |
|
|
176
|
+
| `prefetch(href)` | Prefetch a route's assets |
|
|
177
|
+
| `enableScrollRestoration()` | Enable scroll position restoration |
|
|
178
|
+
| `viewTransitionName(name)` | View Transition name helper |
|
|
179
|
+
| `setViewTransition(type)` | Set View Transition type |
|
|
180
|
+
|
|
181
|
+
## Links
|
|
182
|
+
|
|
183
|
+
- [Documentation](https://whatfw.com)
|
|
184
|
+
- [GitHub](https://github.com/CelsianJs/whatfw)
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-router",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "What Framework - File-based & programmatic router with View Transitions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -23,17 +23,17 @@
|
|
|
23
23
|
"spa",
|
|
24
24
|
"what-framework"
|
|
25
25
|
],
|
|
26
|
-
"author": "",
|
|
26
|
+
"author": "ZVN DEV (https://zvndev.com)",
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"what-core": "^0.5.3"
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "https://github.com/
|
|
33
|
+
"url": "https://github.com/CelsianJs/whatfw"
|
|
34
34
|
},
|
|
35
35
|
"bugs": {
|
|
36
|
-
"url": "https://github.com/
|
|
36
|
+
"url": "https://github.com/CelsianJs/whatfw/issues"
|
|
37
37
|
},
|
|
38
|
-
"homepage": "https://
|
|
38
|
+
"homepage": "https://whatfw.com"
|
|
39
39
|
}
|
package/src/index.js
CHANGED
|
@@ -28,19 +28,25 @@ export const route = {
|
|
|
28
28
|
// --- Navigation with View Transitions ---
|
|
29
29
|
|
|
30
30
|
export async function navigate(to, opts = {}) {
|
|
31
|
-
const { replace = false, state = null, transition = true } = opts;
|
|
31
|
+
const { replace = false, state = null, transition = true, _fromPopstate = false } = opts;
|
|
32
32
|
|
|
33
33
|
// Don't navigate if already on the same URL
|
|
34
34
|
if (to === _url()) return;
|
|
35
35
|
|
|
36
|
+
// Prevent concurrent navigations — wait for current to finish
|
|
37
|
+
if (_isNavigating.peek()) return;
|
|
38
|
+
|
|
36
39
|
_isNavigating.set(true);
|
|
37
40
|
_navigationError.set(null);
|
|
38
41
|
|
|
39
42
|
const doNavigation = () => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
// Skip history manipulation on popstate (browser already updated the URL)
|
|
44
|
+
if (!_fromPopstate) {
|
|
45
|
+
if (replace) {
|
|
46
|
+
history.replaceState(state, '', to);
|
|
47
|
+
} else {
|
|
48
|
+
history.pushState(state, '', to);
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
_url.set(to);
|
|
46
52
|
_isNavigating.set(false);
|
|
@@ -58,10 +64,12 @@ export async function navigate(to, opts = {}) {
|
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
// Back/forward support
|
|
67
|
+
// Back/forward support — route through navigate() so middleware runs
|
|
62
68
|
if (typeof window !== 'undefined') {
|
|
63
69
|
window.addEventListener('popstate', () => {
|
|
64
|
-
|
|
70
|
+
const newUrl = location.pathname + location.search + location.hash;
|
|
71
|
+
// Use _fromPopstate flag so navigate() skips pushState (browser already updated URL)
|
|
72
|
+
navigate(newUrl, { replace: true, _fromPopstate: true, transition: false });
|
|
65
73
|
});
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -138,7 +146,19 @@ function parseQuery(search) {
|
|
|
138
146
|
const qs = search.startsWith('?') ? search.slice(1) : search;
|
|
139
147
|
for (const pair of qs.split('&')) {
|
|
140
148
|
const [key, val] = pair.split('=');
|
|
141
|
-
if (key)
|
|
149
|
+
if (!key) continue;
|
|
150
|
+
const decodedKey = decodeURIComponent(key);
|
|
151
|
+
const decodedVal = val ? decodeURIComponent(val) : '';
|
|
152
|
+
if (decodedKey in params) {
|
|
153
|
+
// Collect repeated keys into arrays
|
|
154
|
+
if (Array.isArray(params[decodedKey])) {
|
|
155
|
+
params[decodedKey].push(decodedVal);
|
|
156
|
+
} else {
|
|
157
|
+
params[decodedKey] = [params[decodedKey], decodedVal];
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
params[decodedKey] = decodedVal;
|
|
161
|
+
}
|
|
142
162
|
}
|
|
143
163
|
return params;
|
|
144
164
|
}
|
|
@@ -174,6 +194,10 @@ function buildLayoutChain(route, routes) {
|
|
|
174
194
|
return layouts;
|
|
175
195
|
}
|
|
176
196
|
|
|
197
|
+
// --- Middleware redirect loop detection ---
|
|
198
|
+
const _redirectHistory = [];
|
|
199
|
+
const MAX_REDIRECTS = 10;
|
|
200
|
+
|
|
177
201
|
// --- Router Component ---
|
|
178
202
|
|
|
179
203
|
export function Router({ routes, fallback, globalLayout }) {
|
|
@@ -203,12 +227,43 @@ export function Router({ routes, fallback, globalLayout }) {
|
|
|
203
227
|
return h('div', { class: 'what-403' }, h('h1', null, '403'), h('p', null, 'Access denied'));
|
|
204
228
|
}
|
|
205
229
|
if (typeof result === 'string') {
|
|
230
|
+
// Redirect loop detection
|
|
231
|
+
_redirectHistory.push(result);
|
|
232
|
+
if (_redirectHistory.length > MAX_REDIRECTS) {
|
|
233
|
+
const cycle = _redirectHistory.slice(-5).join(' → ');
|
|
234
|
+
_redirectHistory.length = 0;
|
|
235
|
+
console.error(`[what-router] Redirect loop detected: ${cycle}`);
|
|
236
|
+
_isNavigating.set(false);
|
|
237
|
+
return h('div', { class: 'what-redirect-loop' },
|
|
238
|
+
h('h1', null, 'Redirect Loop'),
|
|
239
|
+
h('p', null, 'Too many redirects. Check your middleware configuration.')
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
// Check for direct cycle (A → B → A)
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
let hasCycle = false;
|
|
245
|
+
for (const url of _redirectHistory) {
|
|
246
|
+
if (seen.has(url)) { hasCycle = true; break; }
|
|
247
|
+
seen.add(url);
|
|
248
|
+
}
|
|
249
|
+
if (hasCycle) {
|
|
250
|
+
const cycle = _redirectHistory.join(' → ');
|
|
251
|
+
_redirectHistory.length = 0;
|
|
252
|
+
console.error(`[what-router] Redirect cycle detected: ${cycle}`);
|
|
253
|
+
_isNavigating.set(false);
|
|
254
|
+
return h('div', { class: 'what-redirect-loop' },
|
|
255
|
+
h('h1', null, 'Redirect Loop'),
|
|
256
|
+
h('p', null, 'Circular redirect detected. Check your middleware configuration.')
|
|
257
|
+
);
|
|
258
|
+
}
|
|
206
259
|
// Middleware returned a redirect path
|
|
207
260
|
navigate(result, { replace: true });
|
|
208
261
|
return null;
|
|
209
262
|
}
|
|
210
263
|
}
|
|
211
264
|
}
|
|
265
|
+
// Successful render — clear redirect history
|
|
266
|
+
_redirectHistory.length = 0;
|
|
212
267
|
|
|
213
268
|
// Build element with loading state support
|
|
214
269
|
let element;
|
|
@@ -265,11 +320,13 @@ export function Link({
|
|
|
265
320
|
...rest
|
|
266
321
|
}) {
|
|
267
322
|
const currentPath = route.path;
|
|
323
|
+
// Strip query string and hash from href for path comparison
|
|
324
|
+
const hrefPath = href.split('?')[0].split('#')[0];
|
|
268
325
|
// Segment-boundary matching: '/blog' matches '/blog/123' but not '/blog-archive'
|
|
269
|
-
const isActive =
|
|
326
|
+
const isActive = hrefPath === '/'
|
|
270
327
|
? currentPath === '/'
|
|
271
|
-
: currentPath ===
|
|
272
|
-
const isExactActive = currentPath ===
|
|
328
|
+
: currentPath === hrefPath || currentPath.startsWith(hrefPath + '/');
|
|
329
|
+
const isExactActive = currentPath === hrefPath;
|
|
273
330
|
|
|
274
331
|
const classes = [
|
|
275
332
|
cls || className,
|
|
@@ -379,14 +436,20 @@ export function asyncGuard(check, options = {}) {
|
|
|
379
436
|
return function AsyncGuardedRoute(props) {
|
|
380
437
|
const status = signal('pending');
|
|
381
438
|
const checkResult = signal(null);
|
|
439
|
+
let cancelled = false;
|
|
382
440
|
|
|
383
441
|
effect(() => {
|
|
442
|
+
cancelled = false;
|
|
384
443
|
Promise.resolve(check(props))
|
|
385
444
|
.then(result => {
|
|
445
|
+
if (cancelled) return;
|
|
386
446
|
checkResult.set(result);
|
|
387
447
|
status.set(result ? 'allowed' : 'denied');
|
|
388
448
|
})
|
|
389
|
-
.catch(() =>
|
|
449
|
+
.catch(() => {
|
|
450
|
+
if (!cancelled) status.set('denied');
|
|
451
|
+
});
|
|
452
|
+
return () => { cancelled = true; };
|
|
390
453
|
});
|
|
391
454
|
|
|
392
455
|
const currentStatus = status();
|