@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.
- package/dist/define/create-page.d.ts +17 -5
- package/dist/define/create-page.d.ts.map +1 -1
- package/dist/define/create-page.js +78 -38
- package/dist/define/create-page.js.map +1 -1
- package/dist/engine/loader-cache.d.ts +18 -0
- package/dist/engine/loader-cache.d.ts.map +1 -0
- package/dist/engine/loader-cache.js +48 -0
- package/dist/engine/loader-cache.js.map +1 -0
- package/dist/engine/matcher.d.ts +1 -1
- package/dist/engine/matcher.d.ts.map +1 -1
- package/dist/engine/navigation-adapter.d.ts +1 -1
- package/dist/engine/navigation-adapter.d.ts.map +1 -1
- package/dist/engine/route-tree.d.ts +1 -2
- package/dist/engine/route-tree.d.ts.map +1 -1
- package/dist/engine/route-tree.js +5 -4
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +7 -10
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +23 -67
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/schema.d.ts +5 -1
- package/dist/engine/schema.d.ts.map +1 -1
- package/dist/engine/schema.js +11 -1
- package/dist/engine/schema.js.map +1 -1
- package/dist/engine/types.d.ts +2 -15
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/utils.d.ts +8 -0
- package/dist/engine/utils.d.ts.map +1 -1
- package/dist/engine/utils.js +10 -3
- package/dist/engine/utils.js.map +1 -1
- package/dist/index.d.ts +14 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/react/context.d.ts +2 -2
- package/dist/react/context.d.ts.map +1 -1
- package/dist/react/hooks.d.ts +3 -3
- package/dist/react/hooks.d.ts.map +1 -1
- package/dist/react/hooks.js +11 -9
- package/dist/react/hooks.js.map +1 -1
- package/dist/react/index.d.ts +11 -11
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -6
- package/dist/react/index.js.map +1 -1
- package/dist/react/link.d.ts +5 -0
- package/dist/react/link.d.ts.map +1 -1
- package/dist/react/link.js +10 -1
- package/dist/react/link.js.map +1 -1
- package/dist/react/outlet.d.ts +3 -1
- package/dist/react/outlet.d.ts.map +1 -1
- package/dist/react/outlet.js +25 -1
- package/dist/react/outlet.js.map +1 -1
- package/dist/react/provider.d.ts +15 -2
- package/dist/react/provider.d.ts.map +1 -1
- package/dist/react/provider.js +15 -4
- package/dist/react/provider.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/create-page.test.ts +42 -6
- package/src/__tests__/loader-cache.test.ts +115 -0
- package/src/__tests__/matcher.test.ts +3 -3
- package/src/__tests__/react.test.tsx +1020 -0
- package/src/__tests__/router.test.ts +4 -214
- package/src/__tests__/setup-dom.ts +21 -0
- package/src/define/create-page.tsx +94 -46
- package/src/engine/loader-cache.ts +55 -0
- package/src/engine/matcher.ts +1 -1
- package/src/engine/navigation-adapter.ts +1 -1
- package/src/engine/route-tree.ts +4 -6
- package/src/engine/router.ts +27 -73
- package/src/engine/schema.ts +13 -2
- package/src/engine/types.ts +2 -18
- package/src/engine/utils.ts +11 -3
- package/src/index.ts +15 -17
- package/src/react/context.ts +2 -2
- package/src/react/hooks.ts +14 -13
- package/src/react/index.ts +21 -11
- package/src/react/link.tsx +16 -0
- package/src/react/outlet.tsx +32 -1
- package/src/react/provider.tsx +33 -5
- package/dist/__tests__/create-page.test.d.ts +0 -2
- package/dist/__tests__/create-page.test.d.ts.map +0 -1
- package/dist/__tests__/create-page.test.js +0 -457
- package/dist/__tests__/create-page.test.js.map +0 -1
- package/dist/__tests__/matcher.test.d.ts +0 -2
- package/dist/__tests__/matcher.test.d.ts.map +0 -1
- package/dist/__tests__/matcher.test.js +0 -108
- package/dist/__tests__/matcher.test.js.map +0 -1
- package/dist/__tests__/router.test.d.ts +0 -2
- package/dist/__tests__/router.test.d.ts.map +0 -1
- package/dist/__tests__/router.test.js +0 -327
- package/dist/__tests__/router.test.js.map +0 -1
- package/dist/__tests__/type-safety.test.d.ts +0 -15
- package/dist/__tests__/type-safety.test.d.ts.map +0 -1
- package/dist/__tests__/type-safety.test.js +0 -163
- package/dist/__tests__/type-safety.test.js.map +0 -1
package/src/engine/router.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
|
106
|
-
return this.
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
package/src/engine/schema.ts
CHANGED
|
@@ -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
|
|
package/src/engine/types.ts
CHANGED
|
@@ -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. */
|
package/src/engine/utils.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
3
|
-
export type { RouterOptions, RouterSubscriber } from './engine/router'
|
|
2
|
+
export { LoaderCache } from './engine/loader-cache.js'
|
|
4
3
|
|
|
5
|
-
export {
|
|
6
|
-
export type {
|
|
4
|
+
export { NavigationAbortedError, Router } from './engine/router.js'
|
|
5
|
+
export type { RouterOptions, RouterSubscriber } from './engine/router.js'
|
|
7
6
|
|
|
8
|
-
export {
|
|
7
|
+
export { buildRouteTree, createRouteNode } from './engine/route-tree.js'
|
|
8
|
+
export type { CreateRouteNodeOptions } from './engine/route-tree.js'
|
|
9
9
|
|
|
10
|
-
export {
|
|
11
|
-
|
|
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'
|
package/src/react/context.ts
CHANGED
|
@@ -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.
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import type { Router } from '../engine/router'
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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
|
|
77
|
+
const collected = new Map<string, string[]>()
|
|
77
78
|
searchParams.forEach((value, key) => {
|
|
78
|
-
const existing =
|
|
79
|
+
const existing = collected.get(key)
|
|
79
80
|
if (existing) {
|
|
80
81
|
existing.push(value)
|
|
81
82
|
} else {
|
|
82
|
-
|
|
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
|
-
|
|
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
|
package/src/react/index.ts
CHANGED
|
@@ -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 {
|
|
13
|
-
|
|
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'
|
package/src/react/link.tsx
CHANGED
|
@@ -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}
|
package/src/react/outlet.tsx
CHANGED
|
@@ -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
|
|
package/src/react/provider.tsx
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
import React, { useCallback,
|
|
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
|
-
|
|
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
|
-
{
|
|
82
|
+
{Middleware
|
|
83
|
+
? <Middleware params={allParams} matches={state.matches}>{content}</Middleware>
|
|
84
|
+
: content}
|
|
57
85
|
</RouteContext>
|
|
58
86
|
</RouterContext>
|
|
59
87
|
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"create-page.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/create-page.test.ts"],"names":[],"mappings":""}
|