@esportsplus/routing 0.7.2 → 0.7.4

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
@@ -88,26 +88,22 @@ const loggerMiddleware: Middleware<Response> = (req, next) => {
88
88
  return next(req);
89
89
  };
90
90
 
91
- // Apply global middleware and dispatch
92
- app.middleware(loggerMiddleware).dispatch;
91
+ // Apply middleware chain — match route, log, then dispatch to route handler
92
+ let matchMiddleware = app.middleware.match(notFound);
93
+
94
+ app.middleware(matchMiddleware, loggerMiddleware, app.middleware.dispatch);
93
95
  ```
94
96
 
95
- ### Reactive Matching
97
+ ### Fallback Route
96
98
 
97
99
  ```typescript
98
- // Create fallback route
100
+ // Create fallback route for unmatched paths
99
101
  const notFound: Route<Response> = {
100
102
  name: 'not-found',
101
103
  path: null,
102
- pipeline: pipeline<Request<Response>, Response>(),
104
+ middleware: (req) => renderNotFound(),
103
105
  subdomain: null
104
106
  };
105
-
106
- // Middleware that reactively matches routes
107
- const matchMiddleware = app.middleware.match(notFound);
108
-
109
- // Compose and dispatch
110
- app.middleware(matchMiddleware, loggerMiddleware).dispatch;
111
107
  ```
112
108
 
113
109
  ### Route Groups
@@ -138,8 +134,8 @@ const apiRoutes: RouteFactory<Response> = (r) => r
138
134
  // Required parameter
139
135
  .get({ name: 'user', path: '/users/:id', responder })
140
136
 
141
- // Optional parameter (prefix with ?)
142
- .get({ name: 'archive', path: '/posts/?:year/?:month', responder })
137
+ // Optional parameter (prefix with ?:, no preceding /)
138
+ .get({ name: 'archive', path: '/posts?:year?:month', responder })
143
139
 
144
140
  // Wildcard (captures rest of path)
145
141
  .get({ name: 'files', path: '/files/*:path', responder })
@@ -187,7 +183,7 @@ type Request<T> = {
187
183
  type Route<T> = {
188
184
  name: string | null;
189
185
  path: string | null;
190
- pipeline: Pipeline<Request<T>, T>;
186
+ middleware: Middleware<T>[] | Next<T>;
191
187
  subdomain: string | null;
192
188
  };
193
189
  ```
@@ -10,7 +10,6 @@ declare const router: <const Factories extends readonly RouteFactory<any>[]>(...
10
10
  match(fallback: Route<InferOutput<Factories[number]>>): (request: Request<InferOutput<Factories[number]>>, next: Next<InferOutput<Factories[number]>>) => InferOutput<Factories[number]>;
11
11
  };
12
12
  redirect: <RouteName extends keyof AccumulateRoutes<Factories>>(name: RouteName, ...values: ExtractRequiredParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? ExtractOptionalParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? [] : [params?: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>] : [params: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>]) => void;
13
- routes: Record<string, Route<InferOutput<Factories[number]>>>;
14
13
  uri: <RouteName extends keyof AccumulateRoutes<Factories>>(name: RouteName, ...values: ExtractRequiredParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? ExtractOptionalParams<RoutePath<AccumulateRoutes<Factories>, RouteName>> extends never ? [] : [params?: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>] : [params: PathParamsObject<RoutePath<AccumulateRoutes<Factories>, RouteName>>]) => string;
15
14
  };
16
15
  export { router };
@@ -1,26 +1,26 @@
1
- import * as reactivity_ffa278ace0bb4e959ff55da8933387190 from '@esportsplus/reactivity';
1
+ import * as reactivity_b6d4b87c525e4841a152a715c8109b3e0 from '@esportsplus/reactivity';
2
2
  import { effect, root } from '@esportsplus/reactivity';
3
3
  import { Router } from './router/index.js';
4
4
  import { PACKAGE_NAME } from './constants.js';
5
- class ReactiveObject_ffa278ace0bb4e959ff55da8933387191 extends reactivity_ffa278ace0bb4e959ff55da8933387190.ReactiveObject {
5
+ class ReactiveObject_b6d4b87c525e4841a152a715c8109b3e1 extends reactivity_b6d4b87c525e4841a152a715c8109b3e0.ReactiveObject {
6
6
  #parameters;
7
7
  #route;
8
8
  constructor(_p0, _p1) {
9
9
  super(null);
10
- this.#parameters = this[reactivity_ffa278ace0bb4e959ff55da8933387190.SIGNAL](_p0);
11
- this.#route = this[reactivity_ffa278ace0bb4e959ff55da8933387190.SIGNAL](_p1);
10
+ this.#parameters = this[reactivity_b6d4b87c525e4841a152a715c8109b3e0.SIGNAL](_p0);
11
+ this.#route = this[reactivity_b6d4b87c525e4841a152a715c8109b3e0.SIGNAL](_p1);
12
12
  }
13
13
  get parameters() {
14
- return reactivity_ffa278ace0bb4e959ff55da8933387190.read(this.#parameters);
14
+ return reactivity_b6d4b87c525e4841a152a715c8109b3e0.read(this.#parameters);
15
15
  }
16
16
  set parameters(_v0) {
17
- reactivity_ffa278ace0bb4e959ff55da8933387190.write(this.#parameters, _v0);
17
+ reactivity_b6d4b87c525e4841a152a715c8109b3e0.write(this.#parameters, _v0);
18
18
  }
19
19
  get route() {
20
- return reactivity_ffa278ace0bb4e959ff55da8933387190.read(this.#route);
20
+ return reactivity_b6d4b87c525e4841a152a715c8109b3e0.read(this.#route);
21
21
  }
22
22
  set route(_v1) {
23
- reactivity_ffa278ace0bb4e959ff55da8933387190.write(this.#route, _v1);
23
+ reactivity_b6d4b87c525e4841a152a715c8109b3e0.write(this.#route, _v1);
24
24
  }
25
25
  }
26
26
  let cache = [], location = window.location;
@@ -90,7 +90,7 @@ function middleware(request, router) {
90
90
  return route.middleware(request);
91
91
  };
92
92
  host.match = (fallback) => {
93
- let state = new ReactiveObject_ffa278ace0bb4e959ff55da8933387191(undefined, undefined);
93
+ let state = new ReactiveObject_b6d4b87c525e4841a152a715c8109b3e1(undefined, undefined);
94
94
  if (fallback === undefined) {
95
95
  throw new Error(`${PACKAGE_NAME}: fallback route does not exist`);
96
96
  }
@@ -130,7 +130,7 @@ function onpopstate() {
130
130
  }
131
131
  }
132
132
  const router = (...factories) => {
133
- let instance = factories.reduce((r, factory) => factory(r), new Router()), request = reactivity_ffa278ace0bb4e959ff55da8933387190.reactive(Object.assign(href(), { data: {} }));
133
+ let instance = factories.reduce((r, factory) => factory(r), new Router()), request = reactivity_b6d4b87c525e4841a152a715c8109b3e0.reactive(Object.assign(href(), { data: {} }));
134
134
  if (cache.push(request) === 1) {
135
135
  window.addEventListener('hashchange', onpopstate);
136
136
  }
@@ -144,7 +144,6 @@ const router = (...factories) => {
144
144
  }
145
145
  window.location.hash = normalize(instance.uri(name, values));
146
146
  },
147
- routes: instance.routes,
148
147
  uri: (name, ...values) => {
149
148
  return normalize(instance.uri(name, values));
150
149
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,245 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Router } from './index.js';
3
+ function responder(label) {
4
+ return () => label;
5
+ }
6
+ function mw(label) {
7
+ return (_input, next) => label + ':' + next(_input);
8
+ }
9
+ describe('Router', () => {
10
+ describe('match()', () => {
11
+ it('matches static GET path', () => {
12
+ let router = new Router();
13
+ router.get({ path: '/home', responder: responder('home') });
14
+ let result = router.match('GET', '/home');
15
+ expect(result.route).toBeDefined();
16
+ expect(result.route.path).toBe('/home');
17
+ });
18
+ it('matches dynamic path with parameters', () => {
19
+ let router = new Router();
20
+ router.get({ path: '/users/:id', responder: responder('user') });
21
+ let result = router.match('GET', '/users/42');
22
+ expect(result.route).toBeDefined();
23
+ expect(result.parameters).toEqual({ id: '42' });
24
+ });
25
+ it('returns empty for unregistered method', () => {
26
+ let router = new Router();
27
+ router.get({ path: '/home', responder: responder('home') });
28
+ let result = router.match('POST', '/home');
29
+ expect(result.route).toBeUndefined();
30
+ });
31
+ it('returns empty for unregistered path', () => {
32
+ let router = new Router();
33
+ router.get({ path: '/home', responder: responder('home') });
34
+ let result = router.match('GET', '/missing');
35
+ expect(result.route).toBeUndefined();
36
+ });
37
+ it('normalizes path (adds leading /, strips trailing /)', () => {
38
+ let router = new Router();
39
+ router.get({ path: '/users', responder: responder('users') });
40
+ let noLeading = router.match('GET', 'users'), trailing = router.match('GET', '/users/');
41
+ expect(noLeading.route).toBeDefined();
42
+ expect(noLeading.route.path).toBe('/users');
43
+ expect(trailing.route).toBeDefined();
44
+ expect(trailing.route.path).toBe('/users');
45
+ });
46
+ it('matches with subdomain bucketing', () => {
47
+ let router = new Router();
48
+ router.get({ path: '/api', responder: responder('api'), subdomain: 'api' });
49
+ let withSubdomain = router.match('GET', '/api', 'api'), withoutSubdomain = router.match('GET', '/api');
50
+ expect(withSubdomain.route).toBeDefined();
51
+ expect(withoutSubdomain.route).toBeUndefined();
52
+ });
53
+ it('static match takes priority over tree match', () => {
54
+ let router = new Router(), dynamicResponder = responder('dynamic'), staticResponder = responder('static');
55
+ router.get({ path: '/users/:id', responder: dynamicResponder });
56
+ router.get({ path: '/users/all', responder: staticResponder });
57
+ let result = router.match('GET', '/users/all');
58
+ expect(result.route).toBeDefined();
59
+ expect(result.route.path).toBe('/users/all');
60
+ expect(result.route.middleware).toContain(staticResponder);
61
+ });
62
+ });
63
+ describe('on()', () => {
64
+ it('registers route for single method', () => {
65
+ let router = new Router();
66
+ router.on(['GET'], { path: '/test', responder: responder('test') });
67
+ let result = router.match('GET', '/test');
68
+ expect(result.route).toBeDefined();
69
+ });
70
+ it('registers route for multiple methods', () => {
71
+ let router = new Router();
72
+ router.on(['GET', 'POST'], { path: '/test', responder: responder('test') });
73
+ let getResult = router.match('GET', '/test'), postResult = router.match('POST', '/test');
74
+ expect(getResult.route).toBeDefined();
75
+ expect(postResult.route).toBeDefined();
76
+ });
77
+ it('throws on duplicate route name', () => {
78
+ let router = new Router();
79
+ router.on(['GET'], { name: 'home', path: '/home', responder: responder('home') });
80
+ expect(() => {
81
+ router.on(['GET'], { name: 'home', path: '/home2', responder: responder('home2') });
82
+ }).toThrow("@esportsplus/routing: 'home' is already in use");
83
+ });
84
+ it('throws on duplicate static path', () => {
85
+ let router = new Router();
86
+ router.on(['GET'], { path: '/home', responder: responder('home1') });
87
+ expect(() => {
88
+ router.on(['GET'], { path: '/home', responder: responder('home2') });
89
+ }).toThrow("@esportsplus/routing: static path '/home' is already in use");
90
+ });
91
+ it('expands optional parameters', () => {
92
+ let router = new Router();
93
+ router.on(['GET'], { path: '/users?:id', responder: responder('users') });
94
+ let base = router.match('GET', '/users'), withParam = router.match('GET', '/users/42');
95
+ expect(base.route).toBeDefined();
96
+ expect(withParam.route).toBeDefined();
97
+ expect(withParam.parameters).toEqual({ id: '42' });
98
+ });
99
+ it('registers subdomain (lowercased)', () => {
100
+ let router = new Router();
101
+ router.on(['GET'], { path: '/test', responder: responder('test'), subdomain: 'API' });
102
+ expect(router.subdomains).toContain('api');
103
+ });
104
+ it('normalizes www subdomain to empty string', () => {
105
+ let router = new Router();
106
+ router.on(['GET'], { path: '/test', responder: responder('test'), subdomain: 'www' });
107
+ let result = router.match('GET', '/test', '');
108
+ expect(result.route).toBeDefined();
109
+ expect(router.subdomains).toBeNull();
110
+ });
111
+ it('expands multiple optional parameters', () => {
112
+ let router = new Router();
113
+ router.on(['GET'], { path: '/items?:a?:b', responder: responder('items') });
114
+ let base = router.match('GET', '/items'), oneParam = router.match('GET', '/items/x'), twoParams = router.match('GET', '/items/x/y');
115
+ expect(base.route).toBeDefined();
116
+ expect(oneParam.route).toBeDefined();
117
+ expect(oneParam.parameters).toEqual({ a: 'x' });
118
+ expect(twoParams.route).toBeDefined();
119
+ expect(twoParams.parameters).toEqual({ a: 'x', b: 'y' });
120
+ });
121
+ it('stores named route in routes registry', () => {
122
+ let router = new Router();
123
+ router.on(['GET'], { name: 'dashboard', path: '/dashboard', responder: responder('dash') });
124
+ expect(router.routes['dashboard']).toBeDefined();
125
+ expect(router.routes['dashboard'].path).toBe('/dashboard');
126
+ });
127
+ });
128
+ describe('uri()', () => {
129
+ it('generates static URI (no params)', () => {
130
+ let router = new Router();
131
+ router.get({ name: 'home', path: '/home', responder: responder('home') });
132
+ let uri = router.uri('home');
133
+ expect(uri).toBe('/home');
134
+ });
135
+ it('generates URI with required params', () => {
136
+ let router = new Router();
137
+ router.get({ name: 'user', path: '/users/:id', responder: responder('user') });
138
+ let uri = router.uri('user', [42]);
139
+ expect(uri).toBe('/users/42');
140
+ });
141
+ it('generates URI with optional params present', () => {
142
+ let router = new Router();
143
+ router.get({ name: 'users', path: '/users/?:id', responder: responder('users') });
144
+ let uri = router.uri('users', [7]);
145
+ expect(uri).toBe('/users/7');
146
+ });
147
+ it('generates URI with optional params absent (stops at first missing)', () => {
148
+ let router = new Router();
149
+ router.get({ name: 'users', path: '/users/?:id', responder: responder('users') });
150
+ let uri = router.uri('users');
151
+ expect(uri).toBe('/users');
152
+ });
153
+ it('generates URI with wildcard params', () => {
154
+ let router = new Router();
155
+ router.get({ name: 'files', path: '/files/*:path', responder: responder('files') });
156
+ let uri = router.uri('files', ['docs', 'readme.txt']);
157
+ expect(uri).toBe('/files/docs/readme.txt');
158
+ });
159
+ it('throws for non-existent route name', () => {
160
+ let router = new Router();
161
+ expect(() => {
162
+ router.uri('missing');
163
+ }).toThrow("@esportsplus/routing: route name 'missing' does not exist or it does not provide a path");
164
+ });
165
+ });
166
+ describe('group()', () => {
167
+ it('prefixes path to child routes', () => {
168
+ let router = new Router();
169
+ router.group({ path: '/api' }).routes((r) => {
170
+ r.get({ path: '/users', responder: responder('users') });
171
+ });
172
+ let result = router.match('GET', '/api/users');
173
+ expect(result.route).toBeDefined();
174
+ });
175
+ it('cascades middleware to child routes', () => {
176
+ let router = new Router(), authMw = mw('auth');
177
+ router.group({ middleware: [authMw] }).routes((r) => {
178
+ r.get({ path: '/protected', responder: responder('protected') });
179
+ });
180
+ let result = router.match('GET', '/protected');
181
+ expect(result.route).toBeDefined();
182
+ expect(result.route.middleware).toContain(authMw);
183
+ });
184
+ it('applies subdomain to child routes', () => {
185
+ let router = new Router();
186
+ router.group({ subdomain: 'api' }).routes((r) => {
187
+ r.get({ path: '/data', responder: responder('data') });
188
+ });
189
+ let withSubdomain = router.match('GET', '/data', 'api'), withoutSubdomain = router.match('GET', '/data');
190
+ expect(withSubdomain.route).toBeDefined();
191
+ expect(withoutSubdomain.route).toBeUndefined();
192
+ });
193
+ it('cleans up group stack after callback', () => {
194
+ let router = new Router();
195
+ router.group({ path: '/api' }).routes((r) => {
196
+ r.get({ path: '/inner', responder: responder('inner') });
197
+ });
198
+ router.get({ path: '/outer', responder: responder('outer') });
199
+ let inner = router.match('GET', '/api/inner'), outer = router.match('GET', '/outer'), wrongOuter = router.match('GET', '/api/outer');
200
+ expect(inner.route).toBeDefined();
201
+ expect(outer.route).toBeDefined();
202
+ expect(wrongOuter.route).toBeUndefined();
203
+ });
204
+ it('handles nested groups (path accumulation)', () => {
205
+ let router = new Router();
206
+ router.group({ path: '/api' }).routes((r) => {
207
+ r.group({ path: '/v1' }).routes((r2) => {
208
+ r2.get({ path: '/users', responder: responder('users') });
209
+ });
210
+ });
211
+ let result = router.match('GET', '/api/v1/users');
212
+ expect(result.route).toBeDefined();
213
+ });
214
+ });
215
+ describe('HTTP method shortcuts', () => {
216
+ it('get() registers for GET method', () => {
217
+ let router = new Router();
218
+ router.get({ path: '/test', responder: responder('test') });
219
+ let getResult = router.match('GET', '/test'), postResult = router.match('POST', '/test');
220
+ expect(getResult.route).toBeDefined();
221
+ expect(postResult.route).toBeUndefined();
222
+ });
223
+ it('post() registers for POST method', () => {
224
+ let router = new Router();
225
+ router.post({ path: '/test', responder: responder('test') });
226
+ let getResult = router.match('GET', '/test'), postResult = router.match('POST', '/test');
227
+ expect(postResult.route).toBeDefined();
228
+ expect(getResult.route).toBeUndefined();
229
+ });
230
+ it('put() registers for PUT method', () => {
231
+ let router = new Router();
232
+ router.put({ path: '/test', responder: responder('test') });
233
+ let putResult = router.match('PUT', '/test'), getResult = router.match('GET', '/test');
234
+ expect(putResult.route).toBeDefined();
235
+ expect(getResult.route).toBeUndefined();
236
+ });
237
+ it('delete() registers for DELETE method', () => {
238
+ let router = new Router();
239
+ router.delete({ path: '/test', responder: responder('test') });
240
+ let deleteResult = router.match('DELETE', '/test'), getResult = router.match('GET', '/test');
241
+ expect(deleteResult.route).toBeDefined();
242
+ expect(getResult.route).toBeUndefined();
243
+ });
244
+ });
245
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { PARAMETER, STATIC, WILDCARD } from '../constants.js';
3
+ import { Node } from './node.js';
4
+ function route(name) {
5
+ return { name, path: null, middleware: [], subdomain: null };
6
+ }
7
+ describe('Node', () => {
8
+ describe('add', () => {
9
+ it('adds static path (single segment)', () => {
10
+ let root = new Node(), r = route('home');
11
+ root.add('home', r);
12
+ expect(root.static?.get('home')).toBeDefined();
13
+ expect(root.static?.get('home')?.route).toBe(r);
14
+ });
15
+ it('adds multi-segment static path', () => {
16
+ let root = new Node(), r = route('about-team');
17
+ root.add('about/team', r);
18
+ let aboutNode = root.static?.get('about');
19
+ expect(aboutNode).toBeDefined();
20
+ expect(aboutNode?.static?.get('team')?.route).toBe(r);
21
+ });
22
+ it('adds parameter path and creates parameter child with correct name', () => {
23
+ let root = new Node(), r = route('user');
24
+ root.add('users/:id', r);
25
+ let usersNode = root.static?.get('users');
26
+ expect(usersNode).toBeDefined();
27
+ expect(usersNode?.parameter).toBeDefined();
28
+ expect(usersNode?.parameter?.name).toBe('id');
29
+ expect(usersNode?.parameter?.route).toBe(r);
30
+ });
31
+ it('adds wildcard path and creates wildcard child with correct name', () => {
32
+ let root = new Node(), r = route('catchall');
33
+ root.add('files/*:path', r);
34
+ let filesNode = root.static?.get('files');
35
+ expect(filesNode).toBeDefined();
36
+ expect(filesNode?.wildcard).toBeDefined();
37
+ expect(filesNode?.wildcard?.name).toBe('path');
38
+ expect(filesNode?.wildcard?.route).toBe(r);
39
+ });
40
+ it('adds mixed path (static + parameter segments)', () => {
41
+ let root = new Node(), r = route('user-post');
42
+ root.add('users/:id/posts', r);
43
+ let usersNode = root.static?.get('users');
44
+ expect(usersNode?.parameter?.name).toBe('id');
45
+ expect(usersNode?.parameter?.static?.get('posts')?.route).toBe(r);
46
+ });
47
+ it('reuses existing static nodes for shared prefixes', () => {
48
+ let root = new Node(), r1 = route('team'), r2 = route('contact');
49
+ root.add('about/team', r1);
50
+ root.add('about/contact', r2);
51
+ let aboutNode = root.static?.get('about');
52
+ expect(aboutNode?.static?.get('team')?.route).toBe(r1);
53
+ expect(aboutNode?.static?.get('contact')?.route).toBe(r2);
54
+ });
55
+ it('reuses existing parameter node', () => {
56
+ let root = new Node(), r1 = route('user-posts'), r2 = route('user-comments');
57
+ root.add('users/:id/posts', r1);
58
+ root.add('users/:id/comments', r2);
59
+ let paramNode = root.static?.get('users')?.parameter;
60
+ expect(paramNode?.name).toBe('id');
61
+ expect(paramNode?.static?.get('posts')?.route).toBe(r1);
62
+ expect(paramNode?.static?.get('comments')?.route).toBe(r2);
63
+ });
64
+ it('handles unnamed parameters with auto-increment naming', () => {
65
+ let root = new Node(), r = route('unnamed');
66
+ root.add(':/:', r);
67
+ expect(root.parameter?.name).toBe('0');
68
+ expect(root.parameter?.parameter?.name).toBe('1');
69
+ });
70
+ it('handles unnamed wildcard parameters', () => {
71
+ let root = new Node(), r = route('unnamed-wildcard');
72
+ root.add('files/*:', r);
73
+ let filesNode = root.static?.get('files');
74
+ expect(filesNode?.wildcard?.name).toBe('0');
75
+ });
76
+ it('sets path, route, and type on terminal node', () => {
77
+ let root = new Node(), r1 = route('static-route'), r2 = route('param-route'), r3 = route('wild-route');
78
+ let n1 = root.add('about', r1), n2 = root.add('users/:id', r2), n3 = root.add('files/*:path', r3);
79
+ expect(n1.path).toBe('about');
80
+ expect(n1.route).toBe(r1);
81
+ expect(n1.type).toBe(STATIC);
82
+ expect(n2.path).toBe('users/:id');
83
+ expect(n2.route).toBe(r2);
84
+ expect(n2.type).toBe(PARAMETER);
85
+ expect(n3.path).toBe('files/*:path');
86
+ expect(n3.route).toBe(r3);
87
+ expect(n3.type).toBe(WILDCARD);
88
+ });
89
+ it('reuses existing wildcard node', () => {
90
+ let root = new Node(), r1 = route('catchall-1'), r2 = route('catchall-2');
91
+ root.add('files/*:path', r1);
92
+ root.add('docs/*:path', r2);
93
+ let filesWildcard = root.static?.get('files')?.wildcard, docsWildcard = root.static?.get('docs')?.wildcard;
94
+ expect(filesWildcard?.route).toBe(r1);
95
+ expect(docsWildcard?.route).toBe(r2);
96
+ });
97
+ it('returns the terminal node', () => {
98
+ let root = new Node(), r = route('terminal');
99
+ let result = root.add('a/b/c', r);
100
+ expect(result).toBeInstanceOf(Node);
101
+ expect(result.route).toBe(r);
102
+ expect(result.path).toBe('a/b/c');
103
+ });
104
+ it('sets parent on child nodes', () => {
105
+ let root = new Node(), r = route('user');
106
+ root.add('users/:id', r);
107
+ let usersNode = root.static?.get('users');
108
+ expect(usersNode?.parent).toBe(root);
109
+ expect(usersNode?.parameter?.parent).toBe(usersNode);
110
+ });
111
+ });
112
+ describe('find', () => {
113
+ it('finds exact static path', () => {
114
+ let root = new Node(), r = route('home');
115
+ root.add('home', r);
116
+ let result = root.find('home');
117
+ expect(result.route).toBe(r);
118
+ expect(result.parameters).toBeUndefined();
119
+ });
120
+ it('returns empty object for no match', () => {
121
+ let root = new Node();
122
+ root.add('home', route('home'));
123
+ let result = root.find('about');
124
+ expect(result.route).toBeUndefined();
125
+ expect(result.parameters).toBeUndefined();
126
+ });
127
+ it('finds parameter path and extracts params', () => {
128
+ let root = new Node(), r = route('user');
129
+ root.add('users/:id', r);
130
+ let result = root.find('users/42');
131
+ expect(result.route).toBe(r);
132
+ expect(result.parameters).toEqual({ id: '42' });
133
+ });
134
+ it('static segment takes priority over parameter', () => {
135
+ let root = new Node(), rStatic = route('users-all'), rParam = route('user-by-id');
136
+ root.add('users/:id', rParam);
137
+ root.add('users/all', rStatic);
138
+ let result = root.find('users/all');
139
+ expect(result.route).toBe(rStatic);
140
+ expect(result.parameters).toBeUndefined();
141
+ });
142
+ it('falls back to wildcard when no match', () => {
143
+ let root = new Node(), r = route('catchall');
144
+ root.add('files/*:path', r);
145
+ let result = root.find('files/docs/readme.txt');
146
+ expect(result.route).toBe(r);
147
+ expect(result.parameters).toEqual({ path: 'docs/readme.txt' });
148
+ });
149
+ it('wildcard captures remaining segments joined with /', () => {
150
+ let root = new Node(), r = route('catchall');
151
+ root.add('api/*:rest', r);
152
+ let result = root.find('api/v1/users/42/posts');
153
+ expect(result.route).toBe(r);
154
+ expect(result.parameters).toEqual({ rest: 'v1/users/42/posts' });
155
+ });
156
+ it('finds mixed static + parameter paths', () => {
157
+ let root = new Node(), r = route('user-posts');
158
+ root.add('users/:id/posts', r);
159
+ let result = root.find('users/7/posts');
160
+ expect(result.route).toBe(r);
161
+ expect(result.parameters).toEqual({ id: '7' });
162
+ });
163
+ it('returns empty when no parameter node and no static match', () => {
164
+ let root = new Node();
165
+ root.add('users/list', route('users-list'));
166
+ let result = root.find('users/999');
167
+ expect(result.route).toBeUndefined();
168
+ expect(result.parameters).toBeUndefined();
169
+ });
170
+ it('finds root-level segments', () => {
171
+ let root = new Node(), r = route('root-param');
172
+ root.add(':slug', r);
173
+ let result = root.find('hello');
174
+ expect(result.route).toBe(r);
175
+ expect(result.parameters).toEqual({ slug: 'hello' });
176
+ });
177
+ it('wildcard fallback when terminal node has no route', () => {
178
+ let root = new Node(), rWild = route('catchall');
179
+ root.add('*:rest', rWild);
180
+ root.add('api/v1/endpoint', route('endpoint'));
181
+ let result = root.find('api/v1');
182
+ expect(result.route).toBe(rWild);
183
+ expect(result.parameters).toEqual({ rest: 'api/v1' });
184
+ });
185
+ it('multiple parameters in path', () => {
186
+ let root = new Node(), r = route('user-post');
187
+ root.add('users/:userId/posts/:postId', r);
188
+ let result = root.find('users/5/posts/99');
189
+ expect(result.route).toBe(r);
190
+ expect(result.parameters).toEqual({ userId: '5', postId: '99' });
191
+ });
192
+ it('finds multi-segment static path', () => {
193
+ let root = new Node(), r = route('deep');
194
+ root.add('a/b/c/d', r);
195
+ let result = root.find('a/b/c/d');
196
+ expect(result.route).toBe(r);
197
+ expect(result.parameters).toBeUndefined();
198
+ });
199
+ it('returns empty on empty root with no routes', () => {
200
+ let root = new Node();
201
+ let result = root.find('anything');
202
+ expect(result.route).toBeUndefined();
203
+ expect(result.parameters).toBeUndefined();
204
+ });
205
+ it('parameter fallback when static does not exist for segment', () => {
206
+ let root = new Node(), rParam = route('by-id'), rStatic = route('known');
207
+ root.add('items/:id', rParam);
208
+ root.add('items/known', rStatic);
209
+ let result = root.find('items/unknown-value');
210
+ expect(result.route).toBe(rParam);
211
+ expect(result.parameters).toEqual({ id: 'unknown-value' });
212
+ });
213
+ });
214
+ });
package/package.json CHANGED
@@ -28,10 +28,14 @@
28
28
  },
29
29
  "type": "module",
30
30
  "types": "./build/index.d.ts",
31
- "version": "0.7.2",
31
+ "devDependencies": {
32
+ "vitest": "^4.1.1"
33
+ },
34
+ "version": "0.7.4",
32
35
  "scripts": {
33
36
  "build": "tsc",
34
- "build:test": "vite build --config test/vite.config.ts",
37
+ "build:test": "vite build --config tests/vite.config.ts",
38
+ "test": "vitest run",
35
39
  "-": "-"
36
40
  }
37
41
  }
@@ -187,7 +187,6 @@ const router = <const Factories extends readonly RouteFactory<any>[]>(...factori
187
187
 
188
188
  window.location.hash = normalize(instance.uri(name as any, values as any));
189
189
  },
190
- routes: instance.routes,
191
190
  uri: <RouteName extends keyof Routes>(
192
191
  name: RouteName,
193
192
  ...values: ExtractRequiredParams<RoutePath<Routes, RouteName>> extends never