@buzola/router 0.0.3 → 0.0.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.
Files changed (95) hide show
  1. package/dist/define/create-page.d.ts +17 -5
  2. package/dist/define/create-page.d.ts.map +1 -1
  3. package/dist/define/create-page.js +78 -38
  4. package/dist/define/create-page.js.map +1 -1
  5. package/dist/engine/loader-cache.d.ts +18 -0
  6. package/dist/engine/loader-cache.d.ts.map +1 -0
  7. package/dist/engine/loader-cache.js +48 -0
  8. package/dist/engine/loader-cache.js.map +1 -0
  9. package/dist/engine/matcher.d.ts +1 -1
  10. package/dist/engine/matcher.d.ts.map +1 -1
  11. package/dist/engine/navigation-adapter.d.ts +1 -1
  12. package/dist/engine/navigation-adapter.d.ts.map +1 -1
  13. package/dist/engine/route-tree.d.ts +1 -2
  14. package/dist/engine/route-tree.d.ts.map +1 -1
  15. package/dist/engine/route-tree.js +5 -4
  16. package/dist/engine/route-tree.js.map +1 -1
  17. package/dist/engine/router.d.ts +7 -10
  18. package/dist/engine/router.d.ts.map +1 -1
  19. package/dist/engine/router.js +23 -67
  20. package/dist/engine/router.js.map +1 -1
  21. package/dist/engine/schema.d.ts +5 -1
  22. package/dist/engine/schema.d.ts.map +1 -1
  23. package/dist/engine/schema.js +11 -1
  24. package/dist/engine/schema.js.map +1 -1
  25. package/dist/engine/types.d.ts +2 -15
  26. package/dist/engine/types.d.ts.map +1 -1
  27. package/dist/engine/utils.d.ts +8 -0
  28. package/dist/engine/utils.d.ts.map +1 -1
  29. package/dist/engine/utils.js +10 -3
  30. package/dist/engine/utils.js.map +1 -1
  31. package/dist/index.d.ts +14 -13
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +8 -7
  34. package/dist/index.js.map +1 -1
  35. package/dist/react/context.d.ts +2 -2
  36. package/dist/react/context.d.ts.map +1 -1
  37. package/dist/react/hooks.d.ts +3 -3
  38. package/dist/react/hooks.d.ts.map +1 -1
  39. package/dist/react/hooks.js +11 -9
  40. package/dist/react/hooks.js.map +1 -1
  41. package/dist/react/index.d.ts +11 -11
  42. package/dist/react/index.d.ts.map +1 -1
  43. package/dist/react/index.js +6 -6
  44. package/dist/react/index.js.map +1 -1
  45. package/dist/react/link.d.ts +5 -0
  46. package/dist/react/link.d.ts.map +1 -1
  47. package/dist/react/link.js +10 -1
  48. package/dist/react/link.js.map +1 -1
  49. package/dist/react/outlet.d.ts +3 -1
  50. package/dist/react/outlet.d.ts.map +1 -1
  51. package/dist/react/outlet.js +25 -1
  52. package/dist/react/outlet.js.map +1 -1
  53. package/dist/react/provider.d.ts +15 -2
  54. package/dist/react/provider.d.ts.map +1 -1
  55. package/dist/react/provider.js +15 -4
  56. package/dist/react/provider.js.map +1 -1
  57. package/package.json +6 -3
  58. package/src/__tests__/create-page.test.ts +42 -6
  59. package/src/__tests__/loader-cache.test.ts +115 -0
  60. package/src/__tests__/matcher.test.ts +3 -3
  61. package/src/__tests__/react.test.tsx +1020 -0
  62. package/src/__tests__/router.test.ts +4 -214
  63. package/src/__tests__/setup-dom.ts +21 -0
  64. package/src/define/create-page.tsx +94 -46
  65. package/src/engine/loader-cache.ts +55 -0
  66. package/src/engine/matcher.ts +1 -1
  67. package/src/engine/navigation-adapter.ts +1 -1
  68. package/src/engine/route-tree.ts +4 -6
  69. package/src/engine/router.ts +27 -73
  70. package/src/engine/schema.ts +13 -2
  71. package/src/engine/types.ts +2 -18
  72. package/src/engine/utils.ts +11 -3
  73. package/src/index.ts +15 -17
  74. package/src/react/context.ts +2 -2
  75. package/src/react/hooks.ts +14 -13
  76. package/src/react/index.ts +21 -11
  77. package/src/react/link.tsx +16 -0
  78. package/src/react/outlet.tsx +32 -1
  79. package/src/react/provider.tsx +33 -5
  80. package/dist/__tests__/create-page.test.d.ts +0 -2
  81. package/dist/__tests__/create-page.test.d.ts.map +0 -1
  82. package/dist/__tests__/create-page.test.js +0 -457
  83. package/dist/__tests__/create-page.test.js.map +0 -1
  84. package/dist/__tests__/matcher.test.d.ts +0 -2
  85. package/dist/__tests__/matcher.test.d.ts.map +0 -1
  86. package/dist/__tests__/matcher.test.js +0 -108
  87. package/dist/__tests__/matcher.test.js.map +0 -1
  88. package/dist/__tests__/router.test.d.ts +0 -2
  89. package/dist/__tests__/router.test.d.ts.map +0 -1
  90. package/dist/__tests__/router.test.js +0 -327
  91. package/dist/__tests__/router.test.js.map +0 -1
  92. package/dist/__tests__/type-safety.test.d.ts +0 -15
  93. package/dist/__tests__/type-safety.test.d.ts.map +0 -1
  94. package/dist/__tests__/type-safety.test.js +0 -163
  95. package/dist/__tests__/type-safety.test.js.map +0 -1
@@ -1,17 +1,14 @@
1
- import { matchRoutes } from './matcher'
2
- import type { BlockerFn, GuardRedirect, NavigateOptions, NavigationAdapter, RouteMatch, RouteNode, RouterState } from './types'
3
- import { extractParamNames } from './utils'
4
-
5
- function isGuardRedirect(value: unknown): value is GuardRedirect {
6
- return typeof value === 'object' && value !== null && 'redirect' in value && typeof (value as GuardRedirect).redirect === 'string'
7
- }
1
+ import { LoaderCache } from './loader-cache.js'
2
+ import { matchRoutes } from './matcher.js'
3
+ import type { BlockerFn, NavigateOptions, NavigationAdapter, RouteNode, RouterState } from './types.js'
4
+ import { extractParamNames, parseParamSegment } from './utils.js'
8
5
 
9
6
  /**
10
7
  * Thrown when a route guard prevents navigation.
11
8
  */
12
9
  export class NavigationAbortedError extends Error {
13
10
  constructor() {
14
- super('Navigation aborted by route guard')
11
+ super('Navigation aborted by blocker')
15
12
  this.name = 'NavigationAbortedError'
16
13
  }
17
14
  }
@@ -42,7 +39,7 @@ export interface RouterOptions {
42
39
  */
43
40
  pageRegistry?: Record<string, string>
44
41
  /**
45
- * Number of previously visited URLs to keep cached per page component.
42
+ * Maximum number of entries in the global loader cache (shared across all pages).
46
43
  * When navigating back to a cached URL, stale data is shown immediately
47
44
  * while a background reload runs (SWR).
48
45
  * Set to 0 to always re-fetch on URL change (default).
@@ -65,11 +62,10 @@ export class Router {
65
62
  private invalidationListeners = new Set<() => void>()
66
63
  private state: RouterState
67
64
  private navigationId = 0
68
- private currentAbortController?: AbortController
69
65
  private stopFn: (() => void) | undefined
70
66
  private blockers = new Set<BlockerFn>()
71
67
  private pendingScrollBehavior: 'after-transition' | 'manual' = 'after-transition'
72
- private readonly _loaderCacheSize: number
68
+ private readonly _loaderCache: LoaderCache | undefined
73
69
 
74
70
  constructor(options: RouterOptions) {
75
71
  this.routes = options.routes
@@ -78,11 +74,16 @@ export class Router {
78
74
  this._basePath = options.basePath ? options.basePath.replace(/\/$/, '') : ''
79
75
  this.persistentParamsFn = options.persistentParams
80
76
  this.pageRegistry = options.pageRegistry ?? {}
81
- this._loaderCacheSize = options.loaderCacheSize ?? 0
77
+ this._loaderCache = options.loaderCacheSize ? new LoaderCache(options.loaderCacheSize) : undefined
82
78
 
83
79
  // Initialize state from current URL
84
80
  const url = this.adapter.getCurrentURL()
85
- const matches = matchRoutes(this.routes, this.createMatchUrl(url)) ?? []
81
+ const matchUrl = this.createMatchUrl(url)
82
+ const matches = matchRoutes(this.routes, matchUrl) ?? []
83
+
84
+ if (matches.length === 0) {
85
+ console.warn(`[buzola] No route matched URL "${url.pathname}". Define a catch-all 404 route to handle unmatched URLs.`)
86
+ }
86
87
 
87
88
  this.state = {
88
89
  location: url,
@@ -100,10 +101,11 @@ export class Router {
100
101
  }
101
102
 
102
103
  /**
103
- * Number of previously visited URLs to keep cached per page component.
104
+ * Global LRU cache for page loader data, shared across all page components.
105
+ * Undefined when loaderCacheSize is 0 (no caching).
104
106
  */
105
- get loaderCacheSize(): number {
106
- return this._loaderCacheSize
107
+ get loaderCache(): LoaderCache | undefined {
108
+ return this._loaderCache
107
109
  }
108
110
 
109
111
  /**
@@ -134,20 +136,16 @@ export class Router {
134
136
  const url = new URL(event.destination.url)
135
137
  const matchUrl = this.createMatchUrl(url)
136
138
  const matches = matchRoutes(this.routes, matchUrl)
137
- if (!matches) return
139
+ if (!matches) {
140
+ console.warn(`[buzola] No route matched URL "${url.pathname}". The navigation was not intercepted.`)
141
+ return
142
+ }
138
143
 
139
144
  const currentNavId = ++this.navigationId
140
145
  const useViewTransition = event.viewTransition || this.viewTransitions
141
146
  const scrollBehavior = this.pendingScrollBehavior
142
147
  this.pendingScrollBehavior = 'after-transition'
143
148
 
144
- // Abort any in-flight guard from a previous navigation
145
- if (this.currentAbortController) {
146
- this.currentAbortController.abort()
147
- }
148
- const controller = new AbortController()
149
- this.currentAbortController = controller
150
-
151
149
  event.intercept({
152
150
  scroll: scrollBehavior,
153
151
  handler: async () => {
@@ -166,39 +164,6 @@ export class Router {
166
164
  isPending: true,
167
165
  })
168
166
 
169
- // Run route guards (outer → inner)
170
- for (const match of matches) {
171
- // A newer navigation has started — abandon this one
172
- if (this.navigationId !== currentNavId) return
173
-
174
- if (match.node.beforeEnter) {
175
- const result = await match.node.beforeEnter(match, { signal: controller.signal })
176
- if (result === false) {
177
- // Guard prevented navigation
178
- if (this.navigationId === currentNavId) {
179
- this.setState({
180
- ...this.state,
181
- isPending: false,
182
- })
183
- }
184
- throw new NavigationAbortedError()
185
- }
186
- if (isGuardRedirect(result)) {
187
- // Guard requested redirect — don't reset isPending, the redirect
188
- // navigation will take over the pending state.
189
- if (this.navigationId === currentNavId) {
190
- const redirectURL = new URL(result.redirect, url)
191
- const redirectMatches = matchRoutes(this.routes, this.createMatchUrl(redirectURL))
192
- if (!redirectMatches) {
193
- throw new Error(`[buzola] Guard redirect target "${result.redirect}" does not match any route.`)
194
- }
195
- this.navigate(result.redirect, { replace: true })
196
- }
197
- throw new NavigationAbortedError()
198
- }
199
- }
200
- }
201
-
202
167
  // A newer navigation has started — abandon this one
203
168
  if (this.navigationId !== currentNavId) return
204
169
 
@@ -289,9 +254,7 @@ export class Router {
289
254
 
290
255
  for (const segment of segments) {
291
256
  if (segment.startsWith(':')) {
292
- // Catch-all param (:slug+)
293
- const isCatchAll = segment.endsWith('+')
294
- const paramName = isCatchAll ? segment.slice(1, -1) : segment.slice(1)
257
+ const { name: paramName, isCatchAll } = parseParamSegment(segment)
295
258
  const value = params?.[paramName]
296
259
  if (value === undefined) {
297
260
  missing.push(paramName)
@@ -310,9 +273,9 @@ export class Router {
310
273
 
311
274
  if (missing.length > 0) {
312
275
  if (!params) {
313
- throw new Error(`Missing params for path "${pattern}": ${missing.join(', ')}`)
276
+ throw new Error(`[buzola] Missing params for path "${pattern}": ${missing.join(', ')}`)
314
277
  }
315
- throw new Error(`Missing param "${missing[0]}" for path "${pattern}"`)
278
+ throw new Error(`[buzola] Missing param "${missing[0]}" for path "${pattern}"`)
316
279
  }
317
280
 
318
281
  // Extra params (not in path pattern) → query string
@@ -351,7 +314,7 @@ export class Router {
351
314
  * Only fills params that are present in the target pattern.
352
315
  */
353
316
  resolveParams(pattern: string, explicitParams?: Record<string, string>): Record<string, string> {
354
- const paramNames = this.extractParamNames(pattern)
317
+ const paramNames = extractParamNames(pattern)
355
318
  const currentParams = this.getCurrentParams()
356
319
  const fallbackParams = this.persistentParamsFn?.() ?? {}
357
320
  const result: Record<string, string> = {}
@@ -376,13 +339,6 @@ export class Router {
376
339
  return result
377
340
  }
378
341
 
379
- /**
380
- * Extract parameter names from a route pattern.
381
- */
382
- private extractParamNames(pattern: string): string[] {
383
- return extractParamNames(pattern)
384
- }
385
-
386
342
  /**
387
343
  * Get merged params from all current route matches.
388
344
  */
@@ -456,9 +412,7 @@ export class Router {
456
412
  private createMatchUrl(url: URL): URL {
457
413
  if (!this._basePath) return url
458
414
  const stripped = new URL(url)
459
- if (stripped.pathname.startsWith(this._basePath)) {
460
- stripped.pathname = stripped.pathname.slice(this._basePath.length) || '/'
461
- }
415
+ stripped.pathname = this.stripBasePath(stripped.pathname)
462
416
  return stripped
463
417
  }
464
418
 
@@ -1,4 +1,4 @@
1
- import type { StandardSchema } from './types'
1
+ import type { StandardSchema } from './types.js'
2
2
 
3
3
  /**
4
4
  * Built-in minimal Standard Schema builders for route params.
@@ -72,6 +72,17 @@ function object<T extends Record<string, StandardSchema>>(
72
72
 
73
73
  export const s = { object, string, optional }
74
74
 
75
+ /**
76
+ * Validate a value against a Standard Schema, throwing on failure.
77
+ */
78
+ export function validateSchema<T>(schema: StandardSchema<T>, value: unknown, errorPrefix: string): T {
79
+ const result = schema['~standard'].validate(value)
80
+ if ('issues' in result) {
81
+ throw new Error(`${errorPrefix}${result.issues.map(i => i.message).join(', ')}`)
82
+ }
83
+ return result.value
84
+ }
85
+
75
86
  // ─── Param literals ─────────────────────────────────────────────────────────
76
87
 
77
88
  export type ParamLiteral = 'string' | 'number' | 'uuid' | '?string' | '?number' | '?uuid' | 'string[]' | 'number[]' | '?string[]' | '?number[]'
@@ -100,7 +111,7 @@ export function resolveParamLiteral(literal: ParamLiteral): {
100
111
  const baseType = isArr ? rest.slice(0, -2) : rest
101
112
 
102
113
  const validateScalar = scalarValidators[baseType]
103
- if (!validateScalar) throw new Error(`Unknown param type: ${baseType}`)
114
+ if (!validateScalar) throw new Error(`[buzola] Unknown param type: ${baseType}`)
104
115
 
105
116
  let validate: StandardSchema['~standard']['validate']
106
117
 
@@ -50,20 +50,6 @@ export interface StandardSchema<T = unknown> {
50
50
  }
51
51
  }
52
52
 
53
- /** Result returned by a route guard to redirect navigation. */
54
- export interface GuardRedirect {
55
- redirect: string
56
- }
57
-
58
- /** Context passed to route guards. */
59
- export interface GuardContext {
60
- signal: AbortSignal
61
- }
62
-
63
- /** Guard function — return false to block, a GuardRedirect to redirect, or true/void to allow. */
64
- export type RouteGuardResult = boolean | void | GuardRedirect
65
- export type RouteGuard = (match: RouteMatch, context: GuardContext) => RouteGuardResult | Promise<RouteGuardResult>
66
-
67
53
  /** Route component — either a regular component or a lazy-loaded one. */
68
54
  export type RouteComponent = ComponentType<Record<string, never>> | LazyExoticComponent<ComponentType<Record<string, never>>>
69
55
 
@@ -71,12 +57,12 @@ export type RouteComponent = ComponentType<Record<string, never>> | LazyExoticCo
71
57
  export interface RouteConfig {
72
58
  /** The path segment for this route. */
73
59
  path: string
60
+ /** Override the URL path for matching (absolute, not joined with parent). */
61
+ matchPath?: string
74
62
  /** Component to render for this route. */
75
63
  component?: RouteComponent
76
64
  /** Schema for validating search params (Standard Schema compatible). */
77
65
  searchSchema?: StandardSchema
78
- /** Guard function called before entering this route. */
79
- beforeEnter?: RouteGuard
80
66
  /** Child routes. */
81
67
  children?: RouteConfig[]
82
68
  /** Whether this is a layout route (has outlet but no own path segment). */
@@ -117,8 +103,6 @@ export interface RouteNode {
117
103
  component?: RouteComponent
118
104
  /** Search params schema. */
119
105
  searchSchema?: StandardSchema
120
- /** Route guard. */
121
- beforeEnter?: RouteGuard
122
106
  /** Child nodes. */
123
107
  children: RouteNode[]
124
108
  /** Parent node reference. */
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Parse a dynamic route segment (e.g., ":userId" or ":slug+").
3
+ * Returns the parameter name and whether it's a catch-all.
4
+ */
5
+ export function parseParamSegment(segment: string): { name: string; isCatchAll: boolean } {
6
+ const isCatchAll = segment.endsWith('+')
7
+ const name = isCatchAll ? segment.slice(1, -1) : segment.slice(1)
8
+ return { name, isCatchAll }
9
+ }
10
+
1
11
  /**
2
12
  * Extract parameter names from a route pattern.
3
13
  * "/users/:userId/posts/:postId" → ["userId", "postId"]
@@ -7,9 +17,7 @@ export function extractParamNames(pattern: string): string[] {
7
17
  const names: string[] = []
8
18
  for (const segment of pattern.split('/')) {
9
19
  if (segment.startsWith(':')) {
10
- // Strip catch-all suffix (+)
11
- const name = segment.endsWith('+') ? segment.slice(1, -1) : segment.slice(1)
12
- names.push(name)
20
+ names.push(parseParamSegment(segment).name)
13
21
  }
14
22
  }
15
23
  return names
package/src/index.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  // ─── Engine ─────────────────────────────────────────────────────────────────
2
- export { NavigationAbortedError, Router } from './engine/router'
3
- export type { RouterOptions, RouterSubscriber } from './engine/router'
2
+ export { LoaderCache } from './engine/loader-cache.js'
4
3
 
5
- export { buildRouteTree, createRouteNode } from './engine/route-tree'
6
- export type { CreateRouteNodeOptions } from './engine/route-tree'
4
+ export { NavigationAbortedError, Router } from './engine/router.js'
5
+ export type { RouterOptions, RouterSubscriber } from './engine/router.js'
7
6
 
8
- export { matchRoutes } from './engine/matcher'
7
+ export { buildRouteTree, createRouteNode } from './engine/route-tree.js'
8
+ export type { CreateRouteNodeOptions } from './engine/route-tree.js'
9
9
 
10
- export { createBrowserNavigationAdapter, createMemoryNavigationAdapter } from './engine/navigation-adapter'
11
- export type { MemoryNavigationAdapterOptions } from './engine/navigation-adapter'
10
+ export { matchRoutes } from './engine/matcher.js'
11
+
12
+ export { createBrowserNavigationAdapter, createMemoryNavigationAdapter } from './engine/navigation-adapter.js'
13
+ export type { MemoryNavigationAdapterOptions } from './engine/navigation-adapter.js'
12
14
 
13
15
  // ─── Types ──────────────────────────────────────────────────────────────────
14
16
  export type {
@@ -17,28 +19,24 @@ export type {
17
19
  BuzolaPageMap,
18
20
  BuzolaPersistentParams,
19
21
  EffectivePageParams,
20
- GuardContext,
21
- GuardRedirect,
22
22
  NavigateOptions,
23
23
  NavigationAdapter,
24
24
  PageParams,
25
25
  RegisteredPage,
26
26
  RouteComponent,
27
27
  RouteConfig,
28
- RouteGuard,
29
- RouteGuardResult,
30
28
  RouteMatch,
31
29
  RouteNode,
32
30
  RouterState,
33
31
  StandardSchema,
34
- } from './engine/types'
32
+ } from './engine/types.js'
35
33
 
36
34
  // ─── Schema ──────────────────────────────────────────────────────────────────
37
- export { s } from './engine/schema'
35
+ export { s } from './engine/schema.js'
38
36
 
39
37
  // ─── Route definition ───────────────────────────────────────────────────────
40
- export { createPage } from './define/create-page'
41
- export type { CatchContext, PageDefinition, PageProps } from './define/create-page'
38
+ export { BuzolaRedirect, createPage, redirect } from './define/create-page.js'
39
+ export type { CatchContext, PageDefinition, PageProps, RedirectFn } from './define/create-page.js'
42
40
 
43
41
  // ─── React (re-export from subpath) ─────────────────────────────────────────
44
42
  export {
@@ -57,5 +55,5 @@ export {
57
55
  useRouter,
58
56
  useRouterState,
59
57
  useSearchParams,
60
- } from './react/index'
61
- export type { BlockerState, BuzolaProviderProps, LinkProps, OutletProps, RouteContextValue } from './react/index'
58
+ } from './react/index.js'
59
+ export type { BlockerState, BuzolaProviderProps, LinkProps, OutletProps, RouteContextValue } from './react/index.js'
@@ -1,6 +1,6 @@
1
1
  import { createContext } from 'react'
2
- import type { Router } from '../engine/router'
3
- import type { RouteMatch, RouterState } from '../engine/types'
2
+ import type { Router } from '../engine/router.js'
3
+ import type { RouteMatch, RouterState } from '../engine/types.js'
4
4
 
5
5
  /**
6
6
  * Context providing the Router instance.
@@ -1,7 +1,8 @@
1
1
  import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import type { Router } from '../engine/router'
3
- import type { EffectivePageParams, NavigateOptions, RegisteredPage, RouterState, StandardSchema } from '../engine/types'
4
- import { RouteContext, RouterContext } from './context'
2
+ import type { Router } from '../engine/router.js'
3
+ import { validateSchema } from '../engine/schema.js'
4
+ import type { EffectivePageParams, NavigateOptions, RegisteredPage, RouterState, StandardSchema } from '../engine/types.js'
5
+ import { RouteContext, RouterContext } from './context.js'
5
6
 
6
7
  /**
7
8
  * Get the Router instance.
@@ -73,24 +74,24 @@ export function useSearchParams<T = Record<string, string>>(
73
74
 
74
75
  return useMemo(() => {
75
76
  const searchParams = context.state.location.searchParams
76
- const raw: Record<string, string[]> = {}
77
+ const collected = new Map<string, string[]>()
77
78
  searchParams.forEach((value, key) => {
78
- const existing = raw[key]
79
+ const existing = collected.get(key)
79
80
  if (existing) {
80
81
  existing.push(value)
81
82
  } else {
82
- raw[key] = [value]
83
+ collected.set(key, [value])
83
84
  }
84
85
  })
85
86
 
87
+ // Single-value params are unwrapped to strings, multi-value remain as arrays
88
+ const raw: Record<string, string | string[]> = {}
89
+ for (const [key, values] of collected) {
90
+ raw[key] = values.length === 1 ? values[0] : values
91
+ }
92
+
86
93
  if (schema) {
87
- const result = schema['~standard'].validate(raw)
88
- if ('issues' in result) {
89
- throw new Error(
90
- `Search params validation failed: ${result.issues.map(i => i.message).join(', ')}`,
91
- )
92
- }
93
- return result.value
94
+ return validateSchema(schema, raw, '[buzola] Search params validation failed: ')
94
95
  }
95
96
 
96
97
  return raw as T
@@ -1,16 +1,26 @@
1
- export { BuzolaProvider } from './provider'
2
- export type { BuzolaProviderProps } from './provider'
1
+ export { BuzolaProvider } from './provider.js'
2
+ export type { BuzolaProviderProps, MiddlewareComponent, MiddlewareProps } from './provider.js'
3
3
 
4
- export { Outlet } from './outlet'
5
- export type { OutletProps } from './outlet'
4
+ export { Outlet } from './outlet.js'
5
+ export type { OutletProps } from './outlet.js'
6
6
 
7
- export { Link } from './link'
8
- export type { LinkProps } from './link'
7
+ export { Link } from './link.js'
8
+ export type { LinkProps } from './link.js'
9
9
 
10
- export { ErrorBoundary } from './error-boundary'
10
+ export { ErrorBoundary } from './error-boundary.js'
11
11
 
12
- export { useBlocker, useInvalidate, useNavigate, useNavigationState, useParams, useRoute, useRouter, useRouterState, useSearchParams } from './hooks'
13
- export type { BlockerState } from './hooks'
12
+ export {
13
+ useBlocker,
14
+ useInvalidate,
15
+ useNavigate,
16
+ useNavigationState,
17
+ useParams,
18
+ useRoute,
19
+ useRouter,
20
+ useRouterState,
21
+ useSearchParams,
22
+ } from './hooks.js'
23
+ export type { BlockerState } from './hooks.js'
14
24
 
15
- export { RouteContext, RouterContext } from './context'
16
- export type { RouteContextValue } from './context'
25
+ export { RouteContext, RouterContext } from './context.js'
26
+ export type { RouteContextValue } from './context.js'
@@ -22,6 +22,11 @@ type LinkPropsBase = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
22
22
  * When `false`, `data-active` is also set when the current path starts with the link target.
23
23
  */
24
24
  activeExact?: boolean
25
+ /**
26
+ * Render as child element instead of `<a>`.
27
+ * When `true`, clones the single child element and merges `href`, `onClick`, and `data-active` props into it.
28
+ */
29
+ asChild?: boolean
25
30
  }
26
31
 
27
32
  export type LinkProps<P extends RegisteredPage = RegisteredPage> = [keyof EffectivePageParams<P>] extends [never]
@@ -47,6 +52,7 @@ export function Link<P extends RegisteredPage>(props: LinkProps<P>): React.React
47
52
  viewTransition,
48
53
  resetScroll,
49
54
  activeExact = true,
55
+ asChild,
50
56
  onClick,
51
57
  children,
52
58
  ...rest
@@ -85,6 +91,16 @@ export function Link<P extends RegisteredPage>(props: LinkProps<P>): React.React
85
91
  [onClick, needsProgrammaticNav, router, to, params, replace, state, viewTransition, resetScroll],
86
92
  )
87
93
 
94
+ if (asChild) {
95
+ const child = React.Children.only(children) as React.ReactElement<any>
96
+ return React.cloneElement(child, {
97
+ href,
98
+ onClick: handleClick,
99
+ 'data-active': isActive ? '' : undefined,
100
+ ...rest,
101
+ })
102
+ }
103
+
88
104
  return (
89
105
  <a href={href} onClick={handleClick} data-active={isActive ? '' : undefined} {...rest}>
90
106
  {children}
@@ -2,18 +2,44 @@ import React, { Suspense, use, useMemo } from 'react'
2
2
  import { RouteContext, type RouteContextValue } from './context.js'
3
3
  import { ErrorBoundary } from './error-boundary.js'
4
4
 
5
+ const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
6
+
7
+ function DefaultNotFound(): React.ReactElement {
8
+ return React.createElement(
9
+ 'div',
10
+ {
11
+ style: { fontFamily: 'system-ui, sans-serif', padding: '2rem', maxWidth: '600px', margin: '0 auto' },
12
+ },
13
+ isDev
14
+ ? [
15
+ React.createElement('h1', { key: 'h' }, '404 — Page Not Found'),
16
+ React.createElement('p', { key: 'p1' }, `No route matches "${typeof window !== 'undefined' ? window.location.pathname : ''}".`),
17
+ React.createElement('p', {
18
+ key: 'p2',
19
+ style: { color: '#666', fontSize: '0.9rem' },
20
+ }, 'To handle this, add a _404.tsx catch-all route or pass a notFound prop to <Outlet />.'),
21
+ ]
22
+ : [
23
+ React.createElement('h1', { key: 'h' }, '404'),
24
+ React.createElement('p', { key: 'p' }, 'Page not found.'),
25
+ ],
26
+ )
27
+ }
28
+
5
29
  export interface OutletProps {
6
30
  /** Fallback to show while lazy components load. */
7
31
  fallback?: React.ReactNode
8
32
  /** Fallback to show when a route component throws an error. */
9
33
  errorFallback?: React.ReactNode | ((error: Error) => React.ReactNode)
34
+ /** Content to show when no route matches. Defaults to a built-in 404 page. Pass `null` to render nothing. */
35
+ notFound?: React.ReactNode
10
36
  }
11
37
 
12
38
  /**
13
39
  * Renders the component for the current depth in the route match chain.
14
40
  * Each Outlet increments the depth and renders the next matched route's component.
15
41
  */
16
- export function Outlet({ fallback, errorFallback }: OutletProps): React.ReactElement | null {
42
+ export function Outlet({ fallback, errorFallback, notFound }: OutletProps): React.ReactElement | null {
17
43
  const routeContext = use(RouteContext)
18
44
  if (!routeContext) {
19
45
  throw new Error('<Outlet /> must be used within a <BuzolaProvider>')
@@ -48,6 +74,11 @@ export function Outlet({ fallback, errorFallback }: OutletProps): React.ReactEle
48
74
  }, [state, depth, effectiveDepth, matches, match, routeContext.params])
49
75
 
50
76
  if (!match) {
77
+ // At root depth with no matches — show 404
78
+ if (depth === 0 && matches.length === 0) {
79
+ if (notFound === null) return null
80
+ return (notFound ?? React.createElement(DefaultNotFound, null)) as React.ReactElement
81
+ }
51
82
  return null
52
83
  }
53
84
 
@@ -1,13 +1,28 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
1
+ import React, { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'
2
2
  import { createBrowserNavigationAdapter } from '../engine/navigation-adapter.js'
3
3
  import { Router } from '../engine/router.js'
4
- import type { RouteNode } from '../engine/types.js'
4
+ import type { RouteMatch, RouteNode } from '../engine/types.js'
5
5
  import { RouteContext, type RouteContextValue, RouterContext } from './context.js'
6
6
  import { Outlet } from './outlet.js'
7
7
 
8
+ /** Props passed to the middleware component. */
9
+ export interface MiddlewareProps {
10
+ /** Merged params from all matched routes. */
11
+ params: Record<string, string>
12
+ /** All route matches from root to leaf. */
13
+ matches: RouteMatch[]
14
+ /** The rendered route tree to wrap. */
15
+ children: React.ReactNode
16
+ }
17
+
18
+ /** Middleware component that wraps the entire router output. */
19
+ export type MiddlewareComponent = React.ComponentType<MiddlewareProps>
20
+
8
21
  export type BuzolaProviderProps =
9
22
  & {
10
23
  children?: React.ReactNode
24
+ /** Global middleware that wraps the rendered route tree. Has access to all matched params. */
25
+ middleware?: MiddlewareComponent
11
26
  }
12
27
  & (
13
28
  | { router: Router; routes?: never; persistentParams?: never; pageRegistry?: never }
@@ -23,7 +38,7 @@ export type BuzolaProviderProps =
23
38
  * Root provider for Buzola router.
24
39
  * Subscribes to router state and provides context to the React tree.
25
40
  */
26
- export function BuzolaProvider({ routes, children, ...props }: BuzolaProviderProps): React.ReactElement {
41
+ export function BuzolaProvider({ routes, children, middleware: Middleware, ...props }: BuzolaProviderProps): React.ReactElement {
27
42
  const routerRef = useRef<Router | undefined>(props.router)
28
43
  if (!routerRef.current) {
29
44
  routerRef.current = new Router({
@@ -39,7 +54,7 @@ export function BuzolaProvider({ routes, children, ...props }: BuzolaProviderPro
39
54
  const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
40
55
 
41
56
  // Start navigation interception
42
- useEffect(() => {
57
+ useLayoutEffect(() => {
43
58
  return router.start()
44
59
  }, [router])
45
60
 
@@ -50,10 +65,23 @@ export function BuzolaProvider({ routes, children, ...props }: BuzolaProviderPro
50
65
  params: {},
51
66
  }), [state])
52
67
 
68
+ // Merge params from all matches for middleware
69
+ const allParams = useMemo(() => {
70
+ let merged: Record<string, string> = {}
71
+ for (const match of state.matches) {
72
+ merged = { ...merged, ...match.params }
73
+ }
74
+ return merged
75
+ }, [state.matches])
76
+
77
+ const content = children ?? <Outlet />
78
+
53
79
  return (
54
80
  <RouterContext value={router}>
55
81
  <RouteContext value={routeContextValue}>
56
- {children ?? <Outlet />}
82
+ {Middleware
83
+ ? <Middleware params={allParams} matches={state.matches}>{content}</Middleware>
84
+ : content}
57
85
  </RouteContext>
58
86
  </RouterContext>
59
87
  )
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=create-page.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"create-page.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/create-page.test.ts"],"names":[],"mappings":""}