@buzola/router 0.0.1

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 (114) hide show
  1. package/dist/__tests__/create-page.test.d.ts +2 -0
  2. package/dist/__tests__/create-page.test.d.ts.map +1 -0
  3. package/dist/__tests__/create-page.test.js +457 -0
  4. package/dist/__tests__/create-page.test.js.map +1 -0
  5. package/dist/__tests__/define-routes.test.d.ts +2 -0
  6. package/dist/__tests__/define-routes.test.d.ts.map +1 -0
  7. package/dist/__tests__/define-routes.test.js +42 -0
  8. package/dist/__tests__/define-routes.test.js.map +1 -0
  9. package/dist/__tests__/matcher.test.d.ts +2 -0
  10. package/dist/__tests__/matcher.test.d.ts.map +1 -0
  11. package/dist/__tests__/matcher.test.js +108 -0
  12. package/dist/__tests__/matcher.test.js.map +1 -0
  13. package/dist/__tests__/router.test.d.ts +2 -0
  14. package/dist/__tests__/router.test.d.ts.map +1 -0
  15. package/dist/__tests__/router.test.js +327 -0
  16. package/dist/__tests__/router.test.js.map +1 -0
  17. package/dist/__tests__/type-safety.test.d.ts +15 -0
  18. package/dist/__tests__/type-safety.test.d.ts.map +1 -0
  19. package/dist/__tests__/type-safety.test.js +163 -0
  20. package/dist/__tests__/type-safety.test.js.map +1 -0
  21. package/dist/__tests__/types.test.d.ts +2 -0
  22. package/dist/__tests__/types.test.d.ts.map +1 -0
  23. package/dist/__tests__/types.test.js +22 -0
  24. package/dist/__tests__/types.test.js.map +1 -0
  25. package/dist/define/create-page.d.ts +87 -0
  26. package/dist/define/create-page.d.ts.map +1 -0
  27. package/dist/define/create-page.js +305 -0
  28. package/dist/define/create-page.js.map +1 -0
  29. package/dist/define/define-routes.d.ts +34 -0
  30. package/dist/define/define-routes.d.ts.map +1 -0
  31. package/dist/define/define-routes.js +53 -0
  32. package/dist/define/define-routes.js.map +1 -0
  33. package/dist/engine/matcher.d.ts +8 -0
  34. package/dist/engine/matcher.d.ts.map +1 -0
  35. package/dist/engine/matcher.js +79 -0
  36. package/dist/engine/matcher.js.map +1 -0
  37. package/dist/engine/navigation-adapter.d.ts +13 -0
  38. package/dist/engine/navigation-adapter.d.ts.map +1 -0
  39. package/dist/engine/navigation-adapter.js +156 -0
  40. package/dist/engine/navigation-adapter.js.map +1 -0
  41. package/dist/engine/route-tree.d.ts +21 -0
  42. package/dist/engine/route-tree.d.ts.map +1 -0
  43. package/dist/engine/route-tree.js +95 -0
  44. package/dist/engine/route-tree.js.map +1 -0
  45. package/dist/engine/router.d.ts +162 -0
  46. package/dist/engine/router.d.ts.map +1 -0
  47. package/dist/engine/router.js +400 -0
  48. package/dist/engine/router.js.map +1 -0
  49. package/dist/engine/schema.d.ts +46 -0
  50. package/dist/engine/schema.d.ts.map +1 -0
  51. package/dist/engine/schema.js +110 -0
  52. package/dist/engine/schema.js.map +1 -0
  53. package/dist/engine/types.d.ts +183 -0
  54. package/dist/engine/types.d.ts.map +1 -0
  55. package/dist/engine/types.js +2 -0
  56. package/dist/engine/types.js.map +1 -0
  57. package/dist/engine/utils.d.ts +7 -0
  58. package/dist/engine/utils.d.ts.map +1 -0
  59. package/dist/engine/utils.js +17 -0
  60. package/dist/engine/utils.js.map +1 -0
  61. package/dist/index.d.ts +14 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +12 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/react/context.d.ts +21 -0
  66. package/dist/react/context.d.ts.map +1 -0
  67. package/dist/react/context.js +7 -0
  68. package/dist/react/context.js.map +1 -0
  69. package/dist/react/error-boundary.d.ts +24 -0
  70. package/dist/react/error-boundary.d.ts.map +1 -0
  71. package/dist/react/error-boundary.js +37 -0
  72. package/dist/react/error-boundary.js.map +1 -0
  73. package/dist/react/hooks.d.ts +85 -0
  74. package/dist/react/hooks.d.ts.map +1 -0
  75. package/dist/react/hooks.js +161 -0
  76. package/dist/react/hooks.js.map +1 -0
  77. package/dist/react/index.d.ts +12 -0
  78. package/dist/react/index.d.ts.map +1 -0
  79. package/dist/react/index.js +7 -0
  80. package/dist/react/index.js.map +1 -0
  81. package/dist/react/link.d.ts +42 -0
  82. package/dist/react/link.d.ts.map +1 -0
  83. package/dist/react/link.js +38 -0
  84. package/dist/react/link.js.map +1 -0
  85. package/dist/react/outlet.d.ts +13 -0
  86. package/dist/react/outlet.d.ts.map +1 -0
  87. package/dist/react/outlet.js +46 -0
  88. package/dist/react/outlet.js.map +1 -0
  89. package/dist/react/provider.d.ts +22 -0
  90. package/dist/react/provider.d.ts.map +1 -0
  91. package/dist/react/provider.js +37 -0
  92. package/dist/react/provider.js.map +1 -0
  93. package/package.json +29 -0
  94. package/src/__tests__/create-page.test.ts +588 -0
  95. package/src/__tests__/matcher.test.ts +127 -0
  96. package/src/__tests__/router.test.ts +424 -0
  97. package/src/__tests__/type-safety.test.tsx +207 -0
  98. package/src/define/create-page.tsx +472 -0
  99. package/src/engine/matcher.ts +88 -0
  100. package/src/engine/navigation-adapter.ts +191 -0
  101. package/src/engine/route-tree.ts +129 -0
  102. package/src/engine/router.ts +471 -0
  103. package/src/engine/schema.ts +130 -0
  104. package/src/engine/types.ts +203 -0
  105. package/src/engine/utils.ts +16 -0
  106. package/src/env.d.ts +120 -0
  107. package/src/index.ts +61 -0
  108. package/src/react/context.ts +24 -0
  109. package/src/react/error-boundary.tsx +56 -0
  110. package/src/react/hooks.ts +211 -0
  111. package/src/react/index.ts +16 -0
  112. package/src/react/link.tsx +93 -0
  113. package/src/react/outlet.tsx +67 -0
  114. package/src/react/provider.tsx +60 -0
@@ -0,0 +1,424 @@
1
+ import { describe, expect, it, mock } from 'bun:test'
2
+ import { createMemoryNavigationAdapter } from '../engine/navigation-adapter'
3
+ import { buildRouteTree } from '../engine/route-tree'
4
+ import { Router } from '../engine/router'
5
+ import type { GuardContext, RouteConfig } from '../engine/types'
6
+
7
+ function dummyComponent() {
8
+ return null
9
+ }
10
+
11
+ function createRouter(
12
+ configs: RouteConfig[],
13
+ initialURL = 'http://localhost/',
14
+ options?: { persistentParams?: () => Record<string, string> },
15
+ ) {
16
+ const routes = buildRouteTree(configs)
17
+ const adapter = createMemoryNavigationAdapter({ initialURL })
18
+ const router = new Router({ routes, adapter, ...options })
19
+ router.start()
20
+ return { router, adapter }
21
+ }
22
+
23
+ describe('Router', () => {
24
+ it('initializes with the current URL state', () => {
25
+ const { router } = createRouter([
26
+ { path: '/', component: dummyComponent, isIndex: true },
27
+ ])
28
+
29
+ const state = router.getState()
30
+ expect(state.location.pathname).toBe('/')
31
+ expect(state.matches).toHaveLength(1)
32
+ expect(state.isPending).toBe(false)
33
+ })
34
+
35
+ it('navigates to a new route', () => {
36
+ const { router } = createRouter([
37
+ { path: '/', component: dummyComponent, isIndex: true },
38
+ { path: '/about', component: dummyComponent },
39
+ ])
40
+
41
+ router.navigate('/about')
42
+ const state = router.getState()
43
+ expect(state.location.pathname).toBe('/about')
44
+ expect(state.matches).toHaveLength(1)
45
+ })
46
+
47
+ it('notifies subscribers on navigation', () => {
48
+ const { router } = createRouter([
49
+ { path: '/', component: dummyComponent, isIndex: true },
50
+ { path: '/about', component: dummyComponent },
51
+ ])
52
+
53
+ let lastPathname = ''
54
+ const subscriber = mock((state: { location: URL }) => {
55
+ lastPathname = state.location.pathname
56
+ })
57
+ router.subscribe(subscriber)
58
+
59
+ router.navigate('/about')
60
+ expect(subscriber).toHaveBeenCalled()
61
+ expect(lastPathname).toBe('/about')
62
+ })
63
+
64
+ it('unsubscribes correctly', () => {
65
+ const { router } = createRouter([
66
+ { path: '/', component: dummyComponent, isIndex: true },
67
+ { path: '/about', component: dummyComponent },
68
+ ])
69
+
70
+ const subscriber = mock(() => {})
71
+ const unsub = router.subscribe(subscriber)
72
+ unsub()
73
+
74
+ router.navigate('/about')
75
+ expect(subscriber).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it('extracts params from dynamic routes', () => {
79
+ const { router } = createRouter([
80
+ { path: '/users/:userId', component: dummyComponent },
81
+ ])
82
+
83
+ router.navigate('/users/42')
84
+ const state = router.getState()
85
+ expect(state.matches[0].params).toEqual({ userId: '42' })
86
+ })
87
+
88
+ it('builds paths from patterns and params', () => {
89
+ const { router } = createRouter([])
90
+ const path = router.buildPath('/users/:userId/posts/:postId', {
91
+ userId: '42',
92
+ postId: '7',
93
+ })
94
+ expect(path).toBe('/users/42/posts/7')
95
+ })
96
+
97
+ it('buildPath throws on missing params', () => {
98
+ const { router } = createRouter([])
99
+ expect(() => router.buildPath('/users/:userId')).toThrow('Missing params')
100
+ expect(() => router.buildPath('/users/:userId', {})).toThrow('Missing param "userId"')
101
+ expect(() => router.buildPath('/users/:userId/posts/:postId', { userId: '1' })).toThrow('Missing param "postId"')
102
+ })
103
+
104
+ it('buildPath works for static paths without params', () => {
105
+ const { router } = createRouter([])
106
+ expect(router.buildPath('/about')).toBe('/about')
107
+ expect(router.buildPath('/')).toBe('/')
108
+ })
109
+
110
+ it('handles route guards', async () => {
111
+ const guard = mock(() => false) // Block navigation
112
+
113
+ const { router } = createRouter([
114
+ { path: '/', component: dummyComponent, isIndex: true },
115
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
116
+ ])
117
+
118
+ router.navigate('/protected')
119
+ // Guard blocked it, should stay at /
120
+ expect(router.getState().location.pathname).toBe('/')
121
+ expect(guard).toHaveBeenCalled()
122
+ })
123
+
124
+ it('handles async route guards that allow navigation', async () => {
125
+ const guard = mock(() => Promise.resolve(true))
126
+
127
+ const { router } = createRouter([
128
+ { path: '/', component: dummyComponent, isIndex: true },
129
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
130
+ ])
131
+
132
+ router.navigate('/protected')
133
+ // Async guard — need to wait for microtask
134
+ await new Promise(resolve => setTimeout(resolve, 10))
135
+ expect(router.getState().location.pathname).toBe('/protected')
136
+ expect(guard).toHaveBeenCalled()
137
+ })
138
+
139
+ it('handles async route guards that block navigation', async () => {
140
+ const guard = mock(() => Promise.resolve(false))
141
+
142
+ const { router } = createRouter([
143
+ { path: '/', component: dummyComponent, isIndex: true },
144
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
145
+ ])
146
+
147
+ router.navigate('/protected')
148
+ await new Promise(resolve => setTimeout(resolve, 10))
149
+ expect(router.getState().location.pathname).toBe('/')
150
+ expect(guard).toHaveBeenCalled()
151
+ })
152
+
153
+ it('handles route guards that redirect', async () => {
154
+ const guard = mock(() => ({ redirect: '/login' }))
155
+
156
+ const { router } = createRouter([
157
+ { path: '/', component: dummyComponent, isIndex: true },
158
+ { path: '/login', component: dummyComponent },
159
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
160
+ ])
161
+
162
+ router.navigate('/protected')
163
+ await new Promise(resolve => setTimeout(resolve, 10))
164
+ expect(router.getState().location.pathname).toBe('/login')
165
+ expect(guard).toHaveBeenCalled()
166
+ })
167
+
168
+ it('handles async route guards that redirect', async () => {
169
+ const guard = mock(() => Promise.resolve({ redirect: '/login' }))
170
+
171
+ const { router } = createRouter([
172
+ { path: '/', component: dummyComponent, isIndex: true },
173
+ { path: '/login', component: dummyComponent },
174
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
175
+ ])
176
+
177
+ router.navigate('/protected')
178
+ await new Promise(resolve => setTimeout(resolve, 10))
179
+ expect(router.getState().location.pathname).toBe('/login')
180
+ expect(guard).toHaveBeenCalled()
181
+ })
182
+
183
+ it('dispose stops intercepting navigations', () => {
184
+ const { router } = createRouter([
185
+ { path: '/', component: dummyComponent, isIndex: true },
186
+ { path: '/about', component: dummyComponent },
187
+ ])
188
+
189
+ const subscriber = mock(() => {})
190
+ router.subscribe(subscriber)
191
+ router.dispose()
192
+
193
+ router.navigate('/about')
194
+ expect(subscriber).not.toHaveBeenCalled()
195
+ })
196
+
197
+ it('updates routes for HMR', () => {
198
+ const { router } = createRouter([
199
+ { path: '/', component: dummyComponent, isIndex: true },
200
+ ])
201
+
202
+ const newRoutes = buildRouteTree([
203
+ { path: '/', component: dummyComponent, isIndex: true },
204
+ { path: '/new-route', component: dummyComponent },
205
+ ])
206
+
207
+ router.updateRoutes(newRoutes)
208
+ router.navigate('/new-route')
209
+ expect(router.getState().location.pathname).toBe('/new-route')
210
+ })
211
+
212
+ it('dispose clears all subscribers', () => {
213
+ const { router } = createRouter([
214
+ { path: '/', component: dummyComponent, isIndex: true },
215
+ { path: '/about', component: dummyComponent },
216
+ ])
217
+
218
+ const subscriber = mock(() => {})
219
+ router.subscribe(subscriber)
220
+ router.dispose()
221
+
222
+ router.navigate('/about')
223
+ expect(subscriber).not.toHaveBeenCalled()
224
+ })
225
+
226
+ it('passes GuardContext with AbortSignal to guards', async () => {
227
+ let receivedContext: GuardContext | undefined
228
+ const guard = mock((_match: unknown, context: GuardContext) => {
229
+ receivedContext = context
230
+ return true
231
+ })
232
+
233
+ const { router } = createRouter([
234
+ { path: '/', component: dummyComponent, isIndex: true },
235
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
236
+ ])
237
+
238
+ router.navigate('/protected')
239
+ await new Promise(resolve => setTimeout(resolve, 10))
240
+ expect(guard).toHaveBeenCalled()
241
+ expect(receivedContext).toBeDefined()
242
+ expect(receivedContext!.signal).toBeInstanceOf(AbortSignal)
243
+ expect(receivedContext!.signal.aborted).toBe(false)
244
+ })
245
+
246
+ it('aborts previous guard signal on new navigation', async () => {
247
+ let firstSignal: AbortSignal | undefined
248
+ let resolveFirst: (() => void) | undefined
249
+
250
+ const slowGuard = mock((_match: unknown, context: GuardContext) => {
251
+ firstSignal = context.signal
252
+ return new Promise<boolean>(resolve => {
253
+ resolveFirst = () => resolve(true)
254
+ })
255
+ })
256
+
257
+ const { router } = createRouter([
258
+ { path: '/', component: dummyComponent, isIndex: true },
259
+ { path: '/slow', component: dummyComponent, beforeEnter: slowGuard },
260
+ { path: '/fast', component: dummyComponent },
261
+ ])
262
+
263
+ // Start navigation to /slow (guard will hang)
264
+ router.navigate('/slow')
265
+ await new Promise(resolve => setTimeout(resolve, 10))
266
+ expect(firstSignal).toBeDefined()
267
+ expect(firstSignal!.aborted).toBe(false)
268
+
269
+ // Start a new navigation — should abort the previous signal
270
+ router.navigate('/fast')
271
+ await new Promise(resolve => setTimeout(resolve, 10))
272
+ expect(firstSignal!.aborted).toBe(true)
273
+
274
+ // Clean up
275
+ resolveFirst?.()
276
+ })
277
+
278
+ it('throws on guard redirect to non-existent route', async () => {
279
+ const guard = mock(() => ({ redirect: '/nowhere' }))
280
+
281
+ const routes = buildRouteTree([
282
+ { path: '/', component: dummyComponent, isIndex: true },
283
+ { path: '/protected', component: dummyComponent, beforeEnter: guard },
284
+ ])
285
+ const adapter = createMemoryNavigationAdapter({ initialURL: 'http://localhost/' })
286
+
287
+ // Intercept the navigate event to capture the handler promise for assertions
288
+ // while converting the error to NavigationAbortedError so the adapter swallows it.
289
+ let handlerPromise: Promise<void> | undefined
290
+ const origAddEventListener = adapter.addEventListener
291
+ adapter.addEventListener = (_type, listener) => {
292
+ origAddEventListener.call(adapter, _type, (event) => {
293
+ const origEventIntercept = event.intercept.bind(event)
294
+ event.intercept = (opts) => {
295
+ handlerPromise = opts.handler()
296
+ origEventIntercept({
297
+ ...opts,
298
+ handler: () =>
299
+ handlerPromise!.catch(() => {
300
+ // Convert to NavigationAbortedError so the adapter swallows it
301
+ const err = new Error()
302
+ err.name = 'NavigationAbortedError'
303
+ throw err
304
+ }),
305
+ })
306
+ }
307
+ listener(event)
308
+ })
309
+ }
310
+
311
+ const router = new Router({ routes, adapter })
312
+ router.start()
313
+
314
+ router.navigate('/protected')
315
+ expect(handlerPromise).toBeDefined()
316
+ await expect(handlerPromise!).rejects.toThrow('does not match any route')
317
+ expect(router.getState().location.pathname).toBe('/')
318
+ })
319
+
320
+ it('generates deterministic path-based IDs', () => {
321
+ const routes = buildRouteTree([
322
+ {
323
+ path: '/',
324
+ component: dummyComponent,
325
+ isLayout: true,
326
+ children: [
327
+ { path: '/', component: dummyComponent, isIndex: true },
328
+ { path: '/about', component: dummyComponent },
329
+ ],
330
+ },
331
+ ])
332
+
333
+ expect(routes[0].id).toBe('route:/:layout')
334
+ expect(routes[0].children[0].id).toBe('route:/')
335
+ expect(routes[0].children[1].id).toBe('route:/about')
336
+ })
337
+ })
338
+
339
+ describe('Router.resolveParams', () => {
340
+ it('returns explicit params only when no persistent params configured', () => {
341
+ const { router } = createRouter([
342
+ { path: '/users/:userId', component: dummyComponent },
343
+ ])
344
+
345
+ const result = router.resolveParams('/users/:userId', { userId: '42' })
346
+ expect(result).toEqual({ userId: '42' })
347
+ })
348
+
349
+ it('resolves params from current URL match', () => {
350
+ const { router } = createRouter([
351
+ { path: '/:lang/users', component: dummyComponent },
352
+ ], 'http://localhost/en/users')
353
+
354
+ const result = router.resolveParams('/:lang/users')
355
+ expect(result).toEqual({ lang: 'en' })
356
+ })
357
+
358
+ it('explicit params override current match', () => {
359
+ const { router } = createRouter([
360
+ { path: '/:lang/users', component: dummyComponent },
361
+ ], 'http://localhost/en/users')
362
+
363
+ const result = router.resolveParams('/:lang/users', { lang: 'cs' })
364
+ expect(result).toEqual({ lang: 'cs' })
365
+ })
366
+
367
+ it('falls back to persistentParams callback', () => {
368
+ const { router } = createRouter(
369
+ [
370
+ { path: '/admin', component: dummyComponent },
371
+ { path: '/:lang/users', component: dummyComponent },
372
+ ],
373
+ 'http://localhost/admin',
374
+ { persistentParams: () => ({ lang: 'en' }) },
375
+ )
376
+
377
+ const result = router.resolveParams('/:lang/users')
378
+ expect(result).toEqual({ lang: 'en' })
379
+ })
380
+
381
+ it('current match overrides callback', () => {
382
+ const { router } = createRouter(
383
+ [{ path: '/:lang/users', component: dummyComponent }],
384
+ 'http://localhost/cs/users',
385
+ { persistentParams: () => ({ lang: 'en' }) },
386
+ )
387
+
388
+ const result = router.resolveParams('/:lang/users')
389
+ expect(result).toEqual({ lang: 'cs' })
390
+ })
391
+
392
+ it('only fills params present in target pattern', () => {
393
+ const { router } = createRouter(
394
+ [{ path: '/:lang/users/:userId', component: dummyComponent }],
395
+ 'http://localhost/en/users/42',
396
+ { persistentParams: () => ({ lang: 'en' }) },
397
+ )
398
+
399
+ // Target pattern only has :lang, not :userId
400
+ const result = router.resolveParams('/:lang/about')
401
+ expect(result).toEqual({ lang: 'en' })
402
+ })
403
+
404
+ it('handles mixed persistent and non-persistent params', () => {
405
+ const { router } = createRouter(
406
+ [{ path: '/:lang/users', component: dummyComponent }],
407
+ 'http://localhost/en/users',
408
+ { persistentParams: () => ({ lang: 'en' }) },
409
+ )
410
+
411
+ const result = router.resolveParams('/:lang/users/:userId', { userId: '42' })
412
+ expect(result).toEqual({ lang: 'en', userId: '42' })
413
+ })
414
+
415
+ it('returns empty object when pattern has no params', () => {
416
+ const { router } = createRouter(
417
+ [{ path: '/about', component: dummyComponent }],
418
+ 'http://localhost/about',
419
+ )
420
+
421
+ const result = router.resolveParams('/about')
422
+ expect(result).toEqual({})
423
+ })
424
+ })
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import React from 'react'
3
+ import { createPage } from '../define/create-page.js'
4
+ import type { StandardSchema } from '../engine/types.js'
5
+ import type { LinkProps } from '../react/link.js'
6
+
7
+ // Register pages for type-safe testing
8
+ declare module '../engine/types.js' {
9
+ interface BuzolaPageMap {
10
+ 'home': {}
11
+ 'about': {}
12
+ 'users/detail': { userId: string }
13
+ 'users/posts': { userId: string; postId: string }
14
+ }
15
+ }
16
+
17
+ // ─── Helper ─────────────────────────────────────────────────────────────────
18
+
19
+ function mockSchema<T>(): StandardSchema<T> {
20
+ return { '~standard': { version: 1, vendor: 'test', validate: (v) => ({ value: v as T }) } }
21
+ }
22
+
23
+ // ─── createPage().route() type safety ───────────────────────────────────────
24
+
25
+ describe('createPage route type safety', () => {
26
+ it('allows static route without params', () => {
27
+ const _page = createPage().route('/about').render(() => null)
28
+ expect(true).toBe(true)
29
+ })
30
+
31
+ it('rejects dynamic route without params', () => {
32
+ // @ts-expect-error — :id in route but no .params() defined
33
+ const _page = createPage().route('/users/:id').render(() => null)
34
+ expect(true).toBe(true)
35
+ })
36
+
37
+ it('allows dynamic route when param is in schema', () => {
38
+ const _page = createPage()
39
+ .params(mockSchema<{ id: string }>())
40
+ .route('/users/:id')
41
+ .render(({ params }) => null)
42
+ expect(true).toBe(true)
43
+ })
44
+
45
+ it('rejects dynamic route when param is not in schema', () => {
46
+ const _page = createPage()
47
+ .params(mockSchema<{ userId: string }>())
48
+ // @ts-expect-error — :id is not a key of { userId: string }
49
+ .route('/users/:id')
50
+ .render(({ params }) => null)
51
+ expect(true).toBe(true)
52
+ })
53
+
54
+ it('allows multiple route params when all are in schema', () => {
55
+ const _page = createPage()
56
+ .params(mockSchema<{ userId: string; postId: string }>())
57
+ .route('/users/:userId/posts/:postId')
58
+ .render(({ params }) => null)
59
+ expect(true).toBe(true)
60
+ })
61
+
62
+ it('rejects when one route param is missing from schema', () => {
63
+ const _page = createPage()
64
+ .params(mockSchema<{ userId: string }>())
65
+ // @ts-expect-error — :postId is not in { userId: string }
66
+ .route('/users/:userId/posts/:postId')
67
+ .render(({ params }) => null)
68
+ expect(true).toBe(true)
69
+ })
70
+
71
+ it('allows schema params that are not in route (they become query params)', () => {
72
+ const _page = createPage()
73
+ .params(mockSchema<{ userId: string; tab: string }>())
74
+ .route('/users/:userId')
75
+ .render(({ params }) => null)
76
+ expect(true).toBe(true)
77
+ })
78
+ })
79
+
80
+ // ─── createPage().catch() type safety ────────────────────────────────────────
81
+
82
+ describe('createPage catch type safety', () => {
83
+ it('.catch() is available before .render()', () => {
84
+ const _page = createPage()
85
+ .catch(({ error }) => <div>{error.message}</div>)
86
+ .render(() => null)
87
+ expect(true).toBe(true)
88
+ })
89
+
90
+ it('.catch() is available after .route()', () => {
91
+ const _page = createPage()
92
+ .route('/about')
93
+ .catch(({ error }) => <div>{error.message}</div>)
94
+ .render(() => null)
95
+ expect(true).toBe(true)
96
+ })
97
+
98
+ it('no .catch() after .catch()', () => {
99
+ const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
100
+ // @ts-expect-error — .catch() should not be available after .catch()
101
+ withCatch.catch
102
+ expect(true).toBe(true)
103
+ })
104
+
105
+ it('no .loader() after .catch()', () => {
106
+ const withCatch = createPage().catch(({ error }) => <div>{error.message}</div>)
107
+ // @ts-expect-error — .loader() should not be available after .catch()
108
+ withCatch.loader
109
+ expect(true).toBe(true)
110
+ })
111
+ })
112
+
113
+ // ─── LinkProps ──────────────────────────────────────────────────────────────
114
+
115
+ describe('LinkProps type safety', () => {
116
+ it('static page does not require params', () => {
117
+ const _a: LinkProps<'home'> = { to: 'home' }
118
+ const _b: LinkProps<'about'> = { to: 'about' }
119
+ expect(true).toBe(true)
120
+ })
121
+
122
+ it('static page rejects params', () => {
123
+ // @ts-expect-error — static page should not accept params
124
+ const _a: LinkProps<'about'> = { to: 'about', params: { foo: 'bar' } }
125
+ expect(true).toBe(true)
126
+ })
127
+
128
+ it('dynamic page requires params', () => {
129
+ const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { userId: '1' } }
130
+
131
+ // @ts-expect-error — missing required params
132
+ const _b: LinkProps<'users/detail'> = { to: 'users/detail' }
133
+ expect(true).toBe(true)
134
+ })
135
+
136
+ it('dynamic page requires correct param names', () => {
137
+ // @ts-expect-error — wrong param name
138
+ const _a: LinkProps<'users/detail'> = { to: 'users/detail', params: { id: '1' } }
139
+ expect(true).toBe(true)
140
+ })
141
+
142
+ it('multi-param page requires all params', () => {
143
+ const _a: LinkProps<'users/posts'> = {
144
+ to: 'users/posts',
145
+ params: { userId: '1', postId: '2' },
146
+ }
147
+
148
+ const _b: LinkProps<'users/posts'> = {
149
+ to: 'users/posts',
150
+ // @ts-expect-error — missing postId
151
+ params: { userId: '1' },
152
+ }
153
+ expect(true).toBe(true)
154
+ })
155
+
156
+ it('rejects unregistered pages', () => {
157
+ // @ts-expect-error — page not in BuzolaPageMap
158
+ const _a: LinkProps<'unknown'> = { to: 'unknown' }
159
+ expect(true).toBe(true)
160
+ })
161
+ })
162
+
163
+ // ─── Link JSX usage (generic inference) ─────────────────────────────────────
164
+
165
+ import { Link } from '../react/link.js'
166
+
167
+ describe('Link JSX generic inference', () => {
168
+ it('accepts static page without params', () => {
169
+ const _el = <Link to="home">Home</Link>
170
+ const _el2 = <Link to="about">About</Link>
171
+ expect(true).toBe(true)
172
+ })
173
+
174
+ it('rejects params on static page', () => {
175
+ // @ts-expect-error — static page should not accept params
176
+ const _el = <Link to="about" params={{ foo: 'bar' }}>About</Link>
177
+ expect(true).toBe(true)
178
+ })
179
+
180
+ it('requires params on dynamic page', () => {
181
+ const _el = <Link to="users/detail" params={{ userId: '1' }}>User</Link>
182
+
183
+ // @ts-expect-error — missing required params
184
+ const _el2 = <Link to="users/detail">User</Link>
185
+ expect(true).toBe(true)
186
+ })
187
+
188
+ it('rejects wrong param names', () => {
189
+ // @ts-expect-error — wrong param name
190
+ const _el = <Link to="users/detail" params={{ id: '1' }}>User</Link>
191
+ expect(true).toBe(true)
192
+ })
193
+
194
+ it('requires all params for multi-param page', () => {
195
+ const _ok = <Link to="users/posts" params={{ userId: '1', postId: '2' }}>Post</Link>
196
+
197
+ // @ts-expect-error — missing postId
198
+ const _bad = <Link to="users/posts" params={{ userId: '1' }}>Post</Link>
199
+ expect(true).toBe(true)
200
+ })
201
+
202
+ it('rejects unregistered pages', () => {
203
+ // @ts-expect-error — page not in BuzolaPageMap
204
+ const _el = <Link to="nope">Nope</Link>
205
+ expect(true).toBe(true)
206
+ })
207
+ })