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.
Files changed (3) hide show
  1. package/README.md +188 -0
  2. package/package.json +5 -5
  3. 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",
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/zvndev/what-fw"
33
+ "url": "https://github.com/CelsianJs/whatfw"
34
34
  },
35
35
  "bugs": {
36
- "url": "https://github.com/zvndev/what-fw/issues"
36
+ "url": "https://github.com/CelsianJs/whatfw/issues"
37
37
  },
38
- "homepage": "https://whatframework.dev"
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
- if (replace) {
41
- history.replaceState(state, '', to);
42
- } else {
43
- history.pushState(state, '', to);
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
- _url.set(location.pathname + location.search + location.hash);
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) params[decodeURIComponent(key)] = val ? decodeURIComponent(val) : '';
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 = href === '/'
326
+ const isActive = hrefPath === '/'
270
327
  ? currentPath === '/'
271
- : currentPath === href || currentPath.startsWith(href + '/');
272
- const isExactActive = currentPath === href;
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(() => status.set('denied'));
449
+ .catch(() => {
450
+ if (!cancelled) status.set('denied');
451
+ });
452
+ return () => { cancelled = true; };
390
453
  });
391
454
 
392
455
  const currentStatus = status();