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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # svelte-navigator-lite
2
2
 
3
- A lightweight, label-based router for Svelte 5. Define named routes that map to URL patterns, then use `router.route` reactively — not just to show components, but for any logic that depends on where you are.
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
- ## Setup
11
+ ## Quick start
12
12
 
13
- Call `createRouter` once at your app's entry point (e.g. `App.svelte`):
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
- {#if router.is('login')}
37
- <Login />
38
- {:else if router.is('dashboard')}
39
- <Dashboard />
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
- ### Static routes
53
+ Patterns follow standard URL conventions.
50
54
 
51
- ```ts
52
- 'login': {
53
- rootPath: 'login', // matches /login
54
- segments: [],
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
- Use `name` to capture a URL segment into `router.params`:
63
+ Append `?` to a param name to make it optional. Optional params must come at the end of the pattern.
61
64
 
62
- ```ts
63
- 'event': {
64
- rootPath: 'event',
65
- segments: [{ name: 'eventId' }], // matches /event/123
66
- }
67
- // router.params.eventId === '123'
68
- ```
65
+ ## Navigating
69
66
 
70
- ### Enforced static segments
67
+ ```typescript
68
+ import { router } from 'svelte-navigator-lite';
71
69
 
72
- Use `enforceVal` to require a literal string at that position:
70
+ // Static route
71
+ router.navigate('login');
73
72
 
74
- ```ts
75
- 'create-calendar': {
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
- ```ts
86
- 'edit-event': {
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
- ### Optional segments
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
- Add `optional: true` to make a trailing segment optional. Optional segments must come after all required segments.
82
+ ## Reading state
98
83
 
99
- ```ts
100
- 'event': {
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
- ## Search Params
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
- Search params are automatically captured into `router.searchParams` when present in the URL. No configuration is needed they never affect whether a route matches.
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
- Navigate to a named route. Path params fill dynamic segments; pass search params separately as the third argument.
127
-
128
- ```ts
129
- router.navigate('event', { eventId: '123' });
130
- // → /event/123
99
+ ## Overlays
131
100
 
132
- router.navigate('password-reset', undefined, { token: 'abc123' });
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
- router.navigate('event', { eventId: '123' }, { tab: 'details' });
136
- // → /event/123?tab=details
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
- Throws if a required param is missing.
140
-
141
- ### `goto(path)`
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
- goto('/login');
149
- goto('/login', { replaceState: true });
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 run before navigation and redirect if their condition is met. `fn` returning `true` triggers the redirect.
123
+ ## Guards
157
124
 
158
- ```ts
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
- const guards = {
162
- authenticated: {
163
- fn: () => !auth.isValid(), // redirect if NOT authenticated
164
- redirectTo: 'login',
165
- } satisfies RouteGuard,
166
- unauthenticated: {
167
- fn: () => auth.isValid(), // redirect if already authenticated
168
- redirectTo: 'dashboard',
169
- } satisfies RouteGuard,
170
- };
171
-
172
- const routes: RouteList = {
173
- 'login': {
174
- rootPath: 'login',
175
- segments: [],
176
- routeGuards: [guards.unauthenticated],
177
- },
178
- 'dashboard': {
179
- rootPath: 'dashboard',
180
- segments: [],
181
- routeGuards: [guards.authenticated],
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
- Guards run in order and stop at the first redirect.
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
- An object containing the captured path param values for the current route.
155
+ **`redirectTo`** route name to navigate to instead.
203
156
 
204
- ### `router.searchParams`
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
- An object containing the search params present in the current URL. Empty object when none are present.
159
+ ## `page` store
207
160
 
208
- ### `router.notFound`
161
+ A Svelte readable store that emits `{ url: URL }` on every navigation. Compatible with SvelteKit's `$app/stores` page store shape.
209
162
 
210
- `true` when the current URL did not match any defined route and the router fell back to `defaultRoute`. Use this to render a 404 state without needing a dedicated catch-all route.
163
+ ```typescript
164
+ import { page } from 'svelte-navigator-lite';
211
165
 
212
- ```svelte
213
- {#if router.notFound}
214
- <NotFound />
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
- ### `router.navigate(route, params?, searchParams?)`
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
- Returns `true` if `router.route === route`. Useful for active link styling.
173
+ Low-level path navigation. Prefer `router.navigate` for named routes.
227
174
 
228
- ```ts
229
- class:active={router.is('dashboard')}
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
- ```ts
237
- class:active={router.matches(['dashboard', 'settings'])}
178
+ await goto('/some/path');
179
+ await goto('/some/path', { replaceState: true }); // replace instead of push
238
180
  ```
239
181
 
240
- ### `router.registerRoute(name, route)`
182
+ ## Migrating from v1
241
183
 
242
- Register a new route.
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
- ### `page`
251
-
252
- A readable Svelte store that emits `{ url: URL }` and updates on every navigation. Useful when you need the raw URL.
253
-
254
- ```ts
255
- import { page } from 'svelte-navigator-lite';
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
- $page.url.pathname;
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 { Route, RouteList, Router, RouteGuard } from './router.svelte';
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
- // svelte-navigator-lite
2
- export { createRouter, router, page, goto } from './router.svelte';
1
+ export { createRouter, router, page, goto, _createRouter } from './router.svelte.js';
@@ -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
+ });