svelte-navigator-lite 1.1.5 → 2.1.0
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 +135 -206
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -2
- package/dist/match.d.ts +17 -0
- package/dist/match.js +55 -0
- package/dist/match.test.d.ts +1 -0
- package/dist/match.test.js +120 -0
- package/dist/router.svelte.d.ts +18 -20
- package/dist/router.svelte.js +67 -117
- package/dist/router.test.js +170 -253
- package/dist/types.d.ts +15 -0
- package/dist/types.js +1 -0
- package/package.json +7 -9
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# svelte-navigator-lite
|
|
2
2
|
|
|
3
|
-
A lightweight,
|
|
3
|
+
A lightweight, pattern-based router for Svelte 5. Routes are defined with familiar URL patterns, guards are reusable named functions, and the reactive `router` singleton integrates directly with Svelte runes.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,261 +8,190 @@ A lightweight, label-based router for Svelte 5. Define named routes that map to
|
|
|
8
8
|
npm install svelte-navigator-lite
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Quick start
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
import { createRouter, router } from 'svelte-navigator-lite';
|
|
17
|
-
import type { RouteList } from 'svelte-navigator-lite';
|
|
18
|
-
|
|
19
|
-
const routes: RouteList = {
|
|
20
|
-
'login': {
|
|
21
|
-
rootPath: 'login',
|
|
22
|
-
segments: [],
|
|
23
|
-
},
|
|
24
|
-
'dashboard': {
|
|
25
|
-
rootPath: 'dashboard',
|
|
26
|
-
segments: [],
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
createRouter(routes, 'dashboard'); // second argument is the default/fallback route
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Then use `router.route` anywhere in your Svelte components — it's a reactive Svelte 5 `$state` value:
|
|
13
|
+
Define your routes and guards, then call `createRouter` on mount.
|
|
34
14
|
|
|
35
15
|
```svelte
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{
|
|
39
|
-
|
|
16
|
+
<!-- App.svelte -->
|
|
17
|
+
<script lang="ts">
|
|
18
|
+
import { onMount } from 'svelte';
|
|
19
|
+
import { createRouter, router } from 'svelte-navigator-lite';
|
|
20
|
+
import { auth } from '$stores/auth.svelte';
|
|
21
|
+
import AppShell from '$lib/components/AppShell.svelte';
|
|
22
|
+
import { overlayMap } from '$lib/routes';
|
|
23
|
+
|
|
24
|
+
let currentOverlay = $derived(overlayMap[router.route] ?? null);
|
|
25
|
+
|
|
26
|
+
onMount(() => {
|
|
27
|
+
createRouter({
|
|
28
|
+
routes: [
|
|
29
|
+
{ name: 'cal', pattern: '/cal' },
|
|
30
|
+
{ name: 'cal-date', pattern: '/cal/:mm/:dd/:yyyy' },
|
|
31
|
+
{ name: 'event', pattern: '/event/:eventId', overlay: true },
|
|
32
|
+
{ name: 'edit', pattern: '/event/:eventId/edit', overlay: true },
|
|
33
|
+
{ name: 'login', pattern: '/login', guards: ['unauth'], overlay: true },
|
|
34
|
+
{ name: 'signup', pattern: '/signup', guards: ['unauth'], overlay: true },
|
|
35
|
+
],
|
|
36
|
+
guards: {
|
|
37
|
+
auth: { condition: () => !auth.isValid(), redirectTo: 'login' },
|
|
38
|
+
unauth: { condition: () => auth.isValid(), redirectTo: 'cal' },
|
|
39
|
+
},
|
|
40
|
+
fallback: 'cal',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<AppShell />
|
|
46
|
+
{#if router.overlay}
|
|
47
|
+
<svelte:component this={currentOverlay} />
|
|
40
48
|
{/if}
|
|
41
49
|
```
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
## Defining Routes
|
|
46
|
-
|
|
47
|
-
Every route has a `rootPath` — the first URL segment — and an array of `segments` for everything after it.
|
|
51
|
+
## Route patterns
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
Patterns follow standard URL conventions.
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
### Dynamic params
|
|
55
|
+
| Pattern | Example URL | Params |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `/cal` | `/cal` | `{}` |
|
|
58
|
+
| `/event/:eventId` | `/event/abc-123` | `{ eventId: 'abc-123' }` |
|
|
59
|
+
| `/event/:eventId/edit` | `/event/abc-123/edit` | `{ eventId: 'abc-123' }` |
|
|
60
|
+
| `/cal/:mm/:dd/:yyyy` | `/cal/05/13/2026` | `{ mm: '05', dd: '13', yyyy: '2026' }` |
|
|
61
|
+
| `/settings/:page?` | `/settings` or `/settings/account` | `{}` or `{ page: 'account' }` |
|
|
59
62
|
|
|
60
|
-
|
|
63
|
+
Append `?` to a param name to make it optional. Optional params must come at the end of the pattern.
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
'event': {
|
|
64
|
-
rootPath: 'event',
|
|
65
|
-
segments: [{ name: 'eventId' }], // matches /event/123
|
|
66
|
-
}
|
|
67
|
-
// router.params.eventId === '123'
|
|
68
|
-
```
|
|
65
|
+
## Navigating
|
|
69
66
|
|
|
70
|
-
|
|
67
|
+
```typescript
|
|
68
|
+
import { router } from 'svelte-navigator-lite';
|
|
71
69
|
|
|
72
|
-
|
|
70
|
+
// Static route
|
|
71
|
+
router.navigate('login');
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
'
|
|
76
|
-
rootPath: 'calendar',
|
|
77
|
-
segments: [{ enforceVal: 'create' }], // matches /calendar/create only
|
|
78
|
-
}
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
### Mixed segments
|
|
82
|
-
|
|
83
|
-
`enforceVal` and `name` segments can be combined in any order:
|
|
73
|
+
// With path params
|
|
74
|
+
router.navigate('event', { eventId: '123' });
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
'edit
|
|
87
|
-
rootPath: 'event',
|
|
88
|
-
segments: [
|
|
89
|
-
{ name: 'eventId' }, // matches /event/:eventId/edit
|
|
90
|
-
{ enforceVal: 'edit' },
|
|
91
|
-
],
|
|
92
|
-
}
|
|
76
|
+
// With path params + search params
|
|
77
|
+
router.navigate('edit', { eventId: '123' }, { from: 'calendar' });
|
|
93
78
|
```
|
|
94
79
|
|
|
95
|
-
|
|
80
|
+
`navigate` returns a `Promise<void>`. Guards are evaluated before navigation — if a guard fires, the router redirects instead and original params are not forwarded.
|
|
96
81
|
|
|
97
|
-
|
|
82
|
+
## Reading state
|
|
98
83
|
|
|
99
|
-
```
|
|
100
|
-
'
|
|
101
|
-
rootPath: 'event',
|
|
102
|
-
segments: [
|
|
103
|
-
{ name: 'eventId' },
|
|
104
|
-
{ enforceVal: 'edit', optional: true }, // matches /event/123 AND /event/123/edit
|
|
105
|
-
],
|
|
106
|
-
}
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
---
|
|
84
|
+
```typescript
|
|
85
|
+
import { router } from 'svelte-navigator-lite';
|
|
110
86
|
|
|
111
|
-
|
|
87
|
+
router.route // current route name: string
|
|
88
|
+
router.params // path params: Record<string, string>
|
|
89
|
+
router.searchParams // query params: Record<string, string>
|
|
90
|
+
router.overlay // true if the current route has overlay: true
|
|
91
|
+
router.notFound // true if the URL matched no route and the fallback was used
|
|
112
92
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
// /password-reset?token=abc → router.searchParams.token === 'abc'
|
|
117
|
-
// /password-reset → router.searchParams === {}
|
|
93
|
+
router.is('cal') // true if current route === 'cal'
|
|
94
|
+
router.matches(['cal', 'event']) // true if current route is in the list
|
|
118
95
|
```
|
|
119
96
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
## Navigating
|
|
123
|
-
|
|
124
|
-
### `router.navigate(route, params?, searchParams?)`
|
|
97
|
+
All properties are reactive — use them in Svelte templates or `$effect` blocks and they update automatically on navigation.
|
|
125
98
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
```ts
|
|
129
|
-
router.navigate('event', { eventId: '123' });
|
|
130
|
-
// → /event/123
|
|
99
|
+
## Overlays
|
|
131
100
|
|
|
132
|
-
router.
|
|
133
|
-
// → /password-reset?token=abc123
|
|
101
|
+
Mark a route with `overlay: true` to indicate it renders on top of the base layout rather than replacing it. The router exposes `router.overlay` so your root component can conditionally render the overlay without any separate bookkeeping.
|
|
134
102
|
|
|
135
|
-
|
|
136
|
-
|
|
103
|
+
```typescript
|
|
104
|
+
routes: [
|
|
105
|
+
{ name: 'cal', pattern: '/cal' },
|
|
106
|
+
{ name: 'event', pattern: '/event/:eventId', overlay: true },
|
|
107
|
+
{ name: 'edit', pattern: '/event/:eventId/edit', overlay: true },
|
|
108
|
+
]
|
|
137
109
|
```
|
|
138
110
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
Lower-level navigation to a raw path. Accepts an optional `{ replaceState: true }` option to replace instead of push.
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
import { goto } from 'svelte-navigator-lite';
|
|
111
|
+
```svelte
|
|
112
|
+
<!-- The base layout always renders -->
|
|
113
|
+
<AppShell />
|
|
147
114
|
|
|
148
|
-
|
|
149
|
-
|
|
115
|
+
<!-- Overlay components mount on top when their route is active -->
|
|
116
|
+
{#if router.overlay}
|
|
117
|
+
<svelte:component this={overlayMap[router.route]} />
|
|
118
|
+
{/if}
|
|
150
119
|
```
|
|
151
120
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
## Route Guards
|
|
121
|
+
`overlay` is purely a metadata flag — the router does not render anything itself. How overlays are displayed (modal, drawer, fullscreen panel, etc.) is entirely up to your components.
|
|
155
122
|
|
|
156
|
-
Guards
|
|
123
|
+
## Guards
|
|
157
124
|
|
|
158
|
-
|
|
159
|
-
import type { RouteGuard } from 'svelte-navigator-lite';
|
|
125
|
+
Guards are defined once in `createRouter` and referenced by name in route definitions.
|
|
160
126
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
127
|
+
```typescript
|
|
128
|
+
createRouter({
|
|
129
|
+
routes: [
|
|
130
|
+
{ name: 'cal', pattern: '/cal', guards: ['auth'] },
|
|
131
|
+
{ name: 'login', pattern: '/login', guards: ['unauth'] },
|
|
132
|
+
{ name: 'verify-email', pattern: '/verify-email', guards: ['unauth'] },
|
|
133
|
+
{ name: 'login-check', pattern: '/login', guards: ['unauth', 'emailCheck'] },
|
|
134
|
+
],
|
|
135
|
+
guards: {
|
|
136
|
+
auth: {
|
|
137
|
+
condition: () => !auth.isValid(),
|
|
138
|
+
redirectTo: 'login',
|
|
139
|
+
},
|
|
140
|
+
unauth: {
|
|
141
|
+
condition: () => auth.isValid(),
|
|
142
|
+
redirectTo: 'cal',
|
|
143
|
+
},
|
|
144
|
+
emailCheck: {
|
|
145
|
+
condition: () => auth.containsCreds() && !auth.user?.verified,
|
|
146
|
+
redirectTo: 'verify-email',
|
|
147
|
+
},
|
|
182
148
|
},
|
|
183
|
-
|
|
149
|
+
fallback: 'cal',
|
|
150
|
+
});
|
|
184
151
|
```
|
|
185
152
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
---
|
|
189
|
-
|
|
190
|
-
## API Reference
|
|
191
|
-
|
|
192
|
-
### `createRouter(routes, defaultRoute)`
|
|
193
|
-
|
|
194
|
-
Registers all routes and sets the fallback route. Parses the current URL immediately. Must be called once before using `router`.
|
|
195
|
-
|
|
196
|
-
### `router.route`
|
|
197
|
-
|
|
198
|
-
The label of the currently matched route. Falls back to `defaultRoute` if no route matches.
|
|
199
|
-
|
|
200
|
-
### `router.params`
|
|
153
|
+
**`condition`** — called before entering the route. Return `true` to trigger the redirect.
|
|
201
154
|
|
|
202
|
-
|
|
155
|
+
**`redirectTo`** — route name to navigate to instead.
|
|
203
156
|
|
|
204
|
-
|
|
157
|
+
Multiple guards on a route are checked in order; the first one that fires wins. Guard redirects are also checked for guards (recursively), with cycle detection to prevent infinite loops.
|
|
205
158
|
|
|
206
|
-
|
|
159
|
+
## `page` store
|
|
207
160
|
|
|
208
|
-
|
|
161
|
+
A Svelte readable store that emits `{ url: URL }` on every navigation. Compatible with SvelteKit's `$app/stores` page store shape.
|
|
209
162
|
|
|
210
|
-
|
|
163
|
+
```typescript
|
|
164
|
+
import { page } from 'svelte-navigator-lite';
|
|
211
165
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{:else if router.is('dashboard')}
|
|
216
|
-
<Dashboard />
|
|
217
|
-
{/if}
|
|
166
|
+
page.subscribe(({ url }) => {
|
|
167
|
+
console.log(url.pathname);
|
|
168
|
+
});
|
|
218
169
|
```
|
|
219
170
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
Navigate to a named route, applying guards and building the URL from the route definition.
|
|
223
|
-
|
|
224
|
-
### `router.is(route)`
|
|
171
|
+
## `goto`
|
|
225
172
|
|
|
226
|
-
|
|
173
|
+
Low-level path navigation. Prefer `router.navigate` for named routes.
|
|
227
174
|
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
### `router.matches(routes)`
|
|
233
|
-
|
|
234
|
-
Returns `true` if `router.route` is any of the provided route names.
|
|
175
|
+
```typescript
|
|
176
|
+
import { goto } from 'svelte-navigator-lite';
|
|
235
177
|
|
|
236
|
-
|
|
237
|
-
|
|
178
|
+
await goto('/some/path');
|
|
179
|
+
await goto('/some/path', { replaceState: true }); // replace instead of push
|
|
238
180
|
```
|
|
239
181
|
|
|
240
|
-
|
|
182
|
+
## Migrating from v1
|
|
241
183
|
|
|
242
|
-
|
|
243
|
-
```ts
|
|
244
|
-
registerRoute('settings', {
|
|
245
|
-
rootPath: 'settings',
|
|
246
|
-
segments: [],
|
|
247
|
-
routeGuards: [guards.authenticated],
|
|
248
|
-
})
|
|
184
|
+
The `rootPath` + `segments` route definition is replaced by a single `pattern` string, `routeGuards` inline on each route are replaced by named guards defined once, and the new `overlay` flag replaces any separate modal-route maps you maintained manually.
|
|
249
185
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
186
|
+
```typescript
|
|
187
|
+
// v1
|
|
188
|
+
'edit': {
|
|
189
|
+
rootPath: 'event',
|
|
190
|
+
segments: [{ name: 'eventId' }, { enforceVal: 'edit' }],
|
|
191
|
+
routeGuards: [{ fn: () => !auth.isValid(), redirectTo: 'login' }],
|
|
192
|
+
}
|
|
256
193
|
|
|
257
|
-
|
|
194
|
+
// v2
|
|
195
|
+
{ name: 'edit', pattern: '/event/:eventId/edit', guards: ['auth'], overlay: true }
|
|
196
|
+
// guard defined once: auth: { condition: () => !auth.isValid(), redirectTo: 'login' }
|
|
258
197
|
```
|
|
259
|
-
|
|
260
|
-
---
|
|
261
|
-
|
|
262
|
-
## Matching Rules
|
|
263
|
-
|
|
264
|
-
- Routes are matched in the order they are defined — first match wins.
|
|
265
|
-
- `rootPath` is always required and must match the first URL segment exactly.
|
|
266
|
-
- Segment count must be between `required segments + 1` and `total segments + 1` (the `+1` accounts for `rootPath`).
|
|
267
|
-
- A route with no segments only matches its `rootPath` with nothing after it (e.g. `/login` not `/login/extra`).
|
|
268
|
-
- Unmatched URLs fall back to the `defaultRoute`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createRouter, router, page, goto } from './router.svelte';
|
|
2
|
-
export type {
|
|
1
|
+
export { createRouter, router, page, goto, _createRouter } from './router.svelte.js';
|
|
2
|
+
export type { RouteDefinition, Guard, RouterConfig } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { createRouter, router, page, goto } from './router.svelte';
|
|
1
|
+
export { createRouter, router, page, goto, _createRouter } from './router.svelte.js';
|
package/dist/match.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RouteDefinition } from './types.js';
|
|
2
|
+
type CompiledRoute = {
|
|
3
|
+
name: string;
|
|
4
|
+
pattern: string;
|
|
5
|
+
regex: RegExp;
|
|
6
|
+
paramNames: string[];
|
|
7
|
+
guards: string[];
|
|
8
|
+
overlay: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type RouteMatch = {
|
|
11
|
+
name: string;
|
|
12
|
+
params: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
export declare function compileRoutes(routes: RouteDefinition[]): CompiledRoute[];
|
|
15
|
+
export declare function matchPath(compiled: CompiledRoute[], pathname: string): RouteMatch | null;
|
|
16
|
+
export declare function buildPath(pattern: string, params?: Record<string, string>): string;
|
|
17
|
+
export {};
|
package/dist/match.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function compileRoutes(routes) {
|
|
2
|
+
return routes.map(route => {
|
|
3
|
+
const paramNames = [];
|
|
4
|
+
const regexStr = route.pattern
|
|
5
|
+
// Escape regex special chars (except : and /)
|
|
6
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
7
|
+
// Optional params /:name? — make the whole slash+segment optional together
|
|
8
|
+
.replace(/\/:([a-zA-Z_][a-zA-Z0-9_]*)\?/g, (_, name) => {
|
|
9
|
+
paramNames.push(name);
|
|
10
|
+
return '(?:/([^/]*))?';
|
|
11
|
+
})
|
|
12
|
+
// Required params :name
|
|
13
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
14
|
+
paramNames.push(name);
|
|
15
|
+
return '([^/]+)';
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
name: route.name,
|
|
19
|
+
pattern: route.pattern,
|
|
20
|
+
regex: new RegExp(`^${regexStr}/?$`),
|
|
21
|
+
paramNames,
|
|
22
|
+
guards: route.guards ?? [],
|
|
23
|
+
overlay: route.overlay ?? false,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function matchPath(compiled, pathname) {
|
|
28
|
+
for (const route of compiled) {
|
|
29
|
+
const m = pathname.match(route.regex);
|
|
30
|
+
if (!m)
|
|
31
|
+
continue;
|
|
32
|
+
const params = {};
|
|
33
|
+
route.paramNames.forEach((name, i) => {
|
|
34
|
+
const val = m[i + 1];
|
|
35
|
+
if (val)
|
|
36
|
+
params[name] = decodeURIComponent(val);
|
|
37
|
+
});
|
|
38
|
+
return { name: route.name, params };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
// Build a URL path from a pattern and params.
|
|
43
|
+
// Optional params omitted from params are dropped cleanly.
|
|
44
|
+
export function buildPath(pattern, params = {}) {
|
|
45
|
+
const path = pattern
|
|
46
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)\?/g, (_, name) => name in params ? encodeURIComponent(params[name]) : '')
|
|
47
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
|
|
48
|
+
if (!(name in params))
|
|
49
|
+
throw new Error(`[router] missing required param "${name}" for pattern "${pattern}"`);
|
|
50
|
+
return encodeURIComponent(params[name]);
|
|
51
|
+
})
|
|
52
|
+
.replace(/\/+/g, '/') // collapse double slashes left by dropped optional params
|
|
53
|
+
.replace(/\/$/, ''); // strip trailing slash
|
|
54
|
+
return path || '/';
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { compileRoutes, matchPath, buildPath } from './match';
|
|
3
|
+
// ─── matchPath ────────────────────────────────────────────────────────────────
|
|
4
|
+
describe('matchPath — static routes', () => {
|
|
5
|
+
it('matches an exact path', () => {
|
|
6
|
+
const compiled = compileRoutes([{ name: 'login', pattern: '/login' }]);
|
|
7
|
+
expect(matchPath(compiled, '/login')).toEqual({ name: 'login', params: {} });
|
|
8
|
+
});
|
|
9
|
+
it('does not match a different path', () => {
|
|
10
|
+
const compiled = compileRoutes([{ name: 'login', pattern: '/login' }]);
|
|
11
|
+
expect(matchPath(compiled, '/signup')).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
it('returns null when no routes match', () => {
|
|
14
|
+
const compiled = compileRoutes([{ name: 'login', pattern: '/login' }]);
|
|
15
|
+
expect(matchPath(compiled, '/unknown/path')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
it('returns the first matching route', () => {
|
|
18
|
+
const compiled = compileRoutes([
|
|
19
|
+
{ name: 'first', pattern: '/cal' },
|
|
20
|
+
{ name: 'second', pattern: '/cal' },
|
|
21
|
+
]);
|
|
22
|
+
expect(matchPath(compiled, '/cal')?.name).toBe('first');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('matchPath — required params', () => {
|
|
26
|
+
it('captures a single param', () => {
|
|
27
|
+
const compiled = compileRoutes([{ name: 'event', pattern: '/event/:eventId' }]);
|
|
28
|
+
expect(matchPath(compiled, '/event/abc-123')).toEqual({
|
|
29
|
+
name: 'event',
|
|
30
|
+
params: { eventId: 'abc-123' },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
it('does not match when a required param is absent', () => {
|
|
34
|
+
const compiled = compileRoutes([{ name: 'event', pattern: '/event/:eventId' }]);
|
|
35
|
+
expect(matchPath(compiled, '/event')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('captures multiple params', () => {
|
|
38
|
+
const compiled = compileRoutes([{ name: 'cal-date', pattern: '/cal/:mm/:dd/:yyyy' }]);
|
|
39
|
+
expect(matchPath(compiled, '/cal/05/13/2026')).toEqual({
|
|
40
|
+
name: 'cal-date',
|
|
41
|
+
params: { mm: '05', dd: '13', yyyy: '2026' },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('captures params mixed with literal segments', () => {
|
|
45
|
+
const compiled = compileRoutes([{ name: 'edit', pattern: '/event/:eventId/edit' }]);
|
|
46
|
+
expect(matchPath(compiled, '/event/99/edit')).toEqual({
|
|
47
|
+
name: 'edit',
|
|
48
|
+
params: { eventId: '99' },
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('does not match when a literal segment differs', () => {
|
|
52
|
+
const compiled = compileRoutes([{ name: 'edit', pattern: '/event/:eventId/edit' }]);
|
|
53
|
+
expect(matchPath(compiled, '/event/99/delete')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
it('does not match when there are extra segments', () => {
|
|
56
|
+
const compiled = compileRoutes([{ name: 'event', pattern: '/event/:eventId' }]);
|
|
57
|
+
expect(matchPath(compiled, '/event/99/unexpected')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
it('differentiates routes with the same prefix by segment count', () => {
|
|
60
|
+
const compiled = compileRoutes([
|
|
61
|
+
{ name: 'event', pattern: '/event/:eventId' },
|
|
62
|
+
{ name: 'edit', pattern: '/event/:eventId/edit' },
|
|
63
|
+
]);
|
|
64
|
+
expect(matchPath(compiled, '/event/99')?.name).toBe('event');
|
|
65
|
+
expect(matchPath(compiled, '/event/99/edit')?.name).toBe('edit');
|
|
66
|
+
});
|
|
67
|
+
it('URL-decodes param values', () => {
|
|
68
|
+
const compiled = compileRoutes([{ name: 'search', pattern: '/search/:query' }]);
|
|
69
|
+
expect(matchPath(compiled, '/search/hello%20world')?.params.query).toBe('hello world');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('matchPath — optional params', () => {
|
|
73
|
+
const compiled = compileRoutes([{ name: 'settings', pattern: '/settings/:page?' }]);
|
|
74
|
+
it('matches when the optional param is present', () => {
|
|
75
|
+
expect(matchPath(compiled, '/settings/account')).toEqual({
|
|
76
|
+
name: 'settings',
|
|
77
|
+
params: { page: 'account' },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
it('matches when the optional param is absent', () => {
|
|
81
|
+
const result = matchPath(compiled, '/settings');
|
|
82
|
+
expect(result?.name).toBe('settings');
|
|
83
|
+
expect(result?.params).not.toHaveProperty('page');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
// ─── buildPath ────────────────────────────────────────────────────────────────
|
|
87
|
+
describe('buildPath — static patterns', () => {
|
|
88
|
+
it('returns the pattern as-is', () => {
|
|
89
|
+
expect(buildPath('/login')).toBe('/login');
|
|
90
|
+
});
|
|
91
|
+
it('returns / for an empty pattern', () => {
|
|
92
|
+
expect(buildPath('')).toBe('/');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('buildPath — required params', () => {
|
|
96
|
+
it('substitutes a single param', () => {
|
|
97
|
+
expect(buildPath('/event/:eventId', { eventId: '42' })).toBe('/event/42');
|
|
98
|
+
});
|
|
99
|
+
it('substitutes multiple params', () => {
|
|
100
|
+
expect(buildPath('/cal/:mm/:dd/:yyyy', { mm: '05', dd: '13', yyyy: '2026' }))
|
|
101
|
+
.toBe('/cal/05/13/2026');
|
|
102
|
+
});
|
|
103
|
+
it('substitutes params among literal segments', () => {
|
|
104
|
+
expect(buildPath('/event/:eventId/edit', { eventId: '99' })).toBe('/event/99/edit');
|
|
105
|
+
});
|
|
106
|
+
it('URL-encodes param values', () => {
|
|
107
|
+
expect(buildPath('/search/:query', { query: 'hello world' })).toBe('/search/hello%20world');
|
|
108
|
+
});
|
|
109
|
+
it('throws when a required param is missing', () => {
|
|
110
|
+
expect(() => buildPath('/event/:eventId', {})).toThrow('missing required param "eventId"');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('buildPath — optional params', () => {
|
|
114
|
+
it('inserts the value when the optional param is provided', () => {
|
|
115
|
+
expect(buildPath('/settings/:page?', { page: 'account' })).toBe('/settings/account');
|
|
116
|
+
});
|
|
117
|
+
it('drops the segment cleanly when the optional param is absent', () => {
|
|
118
|
+
expect(buildPath('/settings/:page?', {})).toBe('/settings');
|
|
119
|
+
});
|
|
120
|
+
});
|