@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.
- package/dist/__tests__/create-page.test.d.ts +2 -0
- package/dist/__tests__/create-page.test.d.ts.map +1 -0
- package/dist/__tests__/create-page.test.js +457 -0
- package/dist/__tests__/create-page.test.js.map +1 -0
- package/dist/__tests__/define-routes.test.d.ts +2 -0
- package/dist/__tests__/define-routes.test.d.ts.map +1 -0
- package/dist/__tests__/define-routes.test.js +42 -0
- package/dist/__tests__/define-routes.test.js.map +1 -0
- package/dist/__tests__/matcher.test.d.ts +2 -0
- package/dist/__tests__/matcher.test.d.ts.map +1 -0
- package/dist/__tests__/matcher.test.js +108 -0
- package/dist/__tests__/matcher.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +327 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/type-safety.test.d.ts +15 -0
- package/dist/__tests__/type-safety.test.d.ts.map +1 -0
- package/dist/__tests__/type-safety.test.js +163 -0
- package/dist/__tests__/type-safety.test.js.map +1 -0
- package/dist/__tests__/types.test.d.ts +2 -0
- package/dist/__tests__/types.test.d.ts.map +1 -0
- package/dist/__tests__/types.test.js +22 -0
- package/dist/__tests__/types.test.js.map +1 -0
- package/dist/define/create-page.d.ts +87 -0
- package/dist/define/create-page.d.ts.map +1 -0
- package/dist/define/create-page.js +305 -0
- package/dist/define/create-page.js.map +1 -0
- package/dist/define/define-routes.d.ts +34 -0
- package/dist/define/define-routes.d.ts.map +1 -0
- package/dist/define/define-routes.js +53 -0
- package/dist/define/define-routes.js.map +1 -0
- package/dist/engine/matcher.d.ts +8 -0
- package/dist/engine/matcher.d.ts.map +1 -0
- package/dist/engine/matcher.js +79 -0
- package/dist/engine/matcher.js.map +1 -0
- package/dist/engine/navigation-adapter.d.ts +13 -0
- package/dist/engine/navigation-adapter.d.ts.map +1 -0
- package/dist/engine/navigation-adapter.js +156 -0
- package/dist/engine/navigation-adapter.js.map +1 -0
- package/dist/engine/route-tree.d.ts +21 -0
- package/dist/engine/route-tree.d.ts.map +1 -0
- package/dist/engine/route-tree.js +95 -0
- package/dist/engine/route-tree.js.map +1 -0
- package/dist/engine/router.d.ts +162 -0
- package/dist/engine/router.d.ts.map +1 -0
- package/dist/engine/router.js +400 -0
- package/dist/engine/router.js.map +1 -0
- package/dist/engine/schema.d.ts +46 -0
- package/dist/engine/schema.d.ts.map +1 -0
- package/dist/engine/schema.js +110 -0
- package/dist/engine/schema.js.map +1 -0
- package/dist/engine/types.d.ts +183 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +2 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/engine/utils.d.ts +7 -0
- package/dist/engine/utils.d.ts.map +1 -0
- package/dist/engine/utils.js +17 -0
- package/dist/engine/utils.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/react/context.d.ts +21 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +7 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/error-boundary.d.ts +24 -0
- package/dist/react/error-boundary.d.ts.map +1 -0
- package/dist/react/error-boundary.js +37 -0
- package/dist/react/error-boundary.js.map +1 -0
- package/dist/react/hooks.d.ts +85 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +161 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +7 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/link.d.ts +42 -0
- package/dist/react/link.d.ts.map +1 -0
- package/dist/react/link.js +38 -0
- package/dist/react/link.js.map +1 -0
- package/dist/react/outlet.d.ts +13 -0
- package/dist/react/outlet.d.ts.map +1 -0
- package/dist/react/outlet.js +46 -0
- package/dist/react/outlet.js.map +1 -0
- package/dist/react/provider.d.ts +22 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +37 -0
- package/dist/react/provider.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/create-page.test.ts +588 -0
- package/src/__tests__/matcher.test.ts +127 -0
- package/src/__tests__/router.test.ts +424 -0
- package/src/__tests__/type-safety.test.tsx +207 -0
- package/src/define/create-page.tsx +472 -0
- package/src/engine/matcher.ts +88 -0
- package/src/engine/navigation-adapter.ts +191 -0
- package/src/engine/route-tree.ts +129 -0
- package/src/engine/router.ts +471 -0
- package/src/engine/schema.ts +130 -0
- package/src/engine/types.ts +203 -0
- package/src/engine/utils.ts +16 -0
- package/src/env.d.ts +120 -0
- package/src/index.ts +61 -0
- package/src/react/context.ts +24 -0
- package/src/react/error-boundary.tsx +56 -0
- package/src/react/hooks.ts +211 -0
- package/src/react/index.ts +16 -0
- package/src/react/link.tsx +93 -0
- package/src/react/outlet.tsx +67 -0
- package/src/react/provider.tsx +60 -0
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when a route guard prevents navigation.
|
|
11
|
+
*/
|
|
12
|
+
export class NavigationAbortedError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super('Navigation aborted by route guard')
|
|
15
|
+
this.name = 'NavigationAbortedError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RouterSubscriber = (state: RouterState) => void
|
|
20
|
+
|
|
21
|
+
export interface RouterOptions {
|
|
22
|
+
/** Route tree nodes. */
|
|
23
|
+
routes: RouteNode[]
|
|
24
|
+
/** Navigation adapter (browser or memory). */
|
|
25
|
+
adapter: NavigationAdapter
|
|
26
|
+
/** Enable View Transitions API for navigations. */
|
|
27
|
+
viewTransitions?: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Base path prefix for all routes (e.g., "/app").
|
|
30
|
+
* Routes are defined without the base path; the router prepends it
|
|
31
|
+
* for navigation and strips it for matching.
|
|
32
|
+
*/
|
|
33
|
+
basePath?: string
|
|
34
|
+
/**
|
|
35
|
+
* Callback that returns fallback values for persistent parameters.
|
|
36
|
+
* Used when a persistent param is not explicitly provided and not present in the current URL.
|
|
37
|
+
*/
|
|
38
|
+
persistentParams?: () => Record<string, string>
|
|
39
|
+
/**
|
|
40
|
+
* Page registry mapping page IDs to route patterns.
|
|
41
|
+
* Generated by the Vite plugin.
|
|
42
|
+
*/
|
|
43
|
+
pageRegistry?: Record<string, string>
|
|
44
|
+
/**
|
|
45
|
+
* Number of previously visited URLs to keep cached per page component.
|
|
46
|
+
* When navigating back to a cached URL, stale data is shown immediately
|
|
47
|
+
* while a background reload runs (SWR).
|
|
48
|
+
* Set to 0 to always re-fetch on URL change (default).
|
|
49
|
+
*/
|
|
50
|
+
loaderCacheSize?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Central Router orchestrator.
|
|
55
|
+
* Intercepts all navigations via the Navigation API and manages route matching.
|
|
56
|
+
*/
|
|
57
|
+
export class Router {
|
|
58
|
+
private routes: RouteNode[]
|
|
59
|
+
private adapter: NavigationAdapter
|
|
60
|
+
private viewTransitions: boolean
|
|
61
|
+
private readonly _basePath: string
|
|
62
|
+
private readonly persistentParamsFn?: () => Record<string, string>
|
|
63
|
+
private readonly pageRegistry: Record<string, string>
|
|
64
|
+
private subscribers = new Set<RouterSubscriber>()
|
|
65
|
+
private invalidationListeners = new Set<() => void>()
|
|
66
|
+
private state: RouterState
|
|
67
|
+
private navigationId = 0
|
|
68
|
+
private currentAbortController?: AbortController
|
|
69
|
+
private stopFn: (() => void) | undefined
|
|
70
|
+
private blockers = new Set<BlockerFn>()
|
|
71
|
+
private pendingScrollBehavior: 'after-transition' | 'manual' = 'after-transition'
|
|
72
|
+
private readonly _loaderCacheSize: number
|
|
73
|
+
|
|
74
|
+
constructor(options: RouterOptions) {
|
|
75
|
+
this.routes = options.routes
|
|
76
|
+
this.adapter = options.adapter
|
|
77
|
+
this.viewTransitions = options.viewTransitions ?? false
|
|
78
|
+
this._basePath = options.basePath ? options.basePath.replace(/\/$/, '') : ''
|
|
79
|
+
this.persistentParamsFn = options.persistentParams
|
|
80
|
+
this.pageRegistry = options.pageRegistry ?? {}
|
|
81
|
+
this._loaderCacheSize = options.loaderCacheSize ?? 0
|
|
82
|
+
|
|
83
|
+
// Initialize state from current URL
|
|
84
|
+
const url = this.adapter.getCurrentURL()
|
|
85
|
+
const matches = matchRoutes(this.routes, this.createMatchUrl(url)) ?? []
|
|
86
|
+
|
|
87
|
+
this.state = {
|
|
88
|
+
location: url,
|
|
89
|
+
matches,
|
|
90
|
+
isPending: false,
|
|
91
|
+
navigationState: this.adapter.getState(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The configured base path (e.g., "/app"). Empty string if none.
|
|
97
|
+
*/
|
|
98
|
+
get basePath(): string {
|
|
99
|
+
return this._basePath
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Number of previously visited URLs to keep cached per page component.
|
|
104
|
+
*/
|
|
105
|
+
get loaderCacheSize(): number {
|
|
106
|
+
return this._loaderCacheSize
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get the current router state.
|
|
111
|
+
*/
|
|
112
|
+
getState(): RouterState {
|
|
113
|
+
return this.state
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Subscribe to state changes. Returns an unsubscribe function.
|
|
118
|
+
*/
|
|
119
|
+
subscribe(subscriber: RouterSubscriber): () => void {
|
|
120
|
+
this.subscribers.add(subscriber)
|
|
121
|
+
return () => {
|
|
122
|
+
this.subscribers.delete(subscriber)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Start intercepting navigations.
|
|
128
|
+
* Call this once when the router is initialized.
|
|
129
|
+
*/
|
|
130
|
+
start(): () => void {
|
|
131
|
+
const handler = (event: import('./types.js').BuzolaNavigateEvent) => {
|
|
132
|
+
if (!event.canIntercept) return
|
|
133
|
+
|
|
134
|
+
const url = new URL(event.destination.url)
|
|
135
|
+
const matchUrl = this.createMatchUrl(url)
|
|
136
|
+
const matches = matchRoutes(this.routes, matchUrl)
|
|
137
|
+
if (!matches) return
|
|
138
|
+
|
|
139
|
+
const currentNavId = ++this.navigationId
|
|
140
|
+
const useViewTransition = event.viewTransition || this.viewTransitions
|
|
141
|
+
const scrollBehavior = this.pendingScrollBehavior
|
|
142
|
+
this.pendingScrollBehavior = 'after-transition'
|
|
143
|
+
|
|
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
|
+
event.intercept({
|
|
152
|
+
scroll: scrollBehavior,
|
|
153
|
+
handler: async () => {
|
|
154
|
+
// Check blockers before setting pending state
|
|
155
|
+
for (const blocker of this.blockers) {
|
|
156
|
+
if (this.navigationId !== currentNavId) return
|
|
157
|
+
const shouldProceed = await blocker()
|
|
158
|
+
if (!shouldProceed) {
|
|
159
|
+
throw new NavigationAbortedError()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Set pending state
|
|
164
|
+
this.setState({
|
|
165
|
+
...this.state,
|
|
166
|
+
isPending: true,
|
|
167
|
+
})
|
|
168
|
+
|
|
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
|
+
// A newer navigation has started — abandon this one
|
|
203
|
+
if (this.navigationId !== currentNavId) return
|
|
204
|
+
|
|
205
|
+
// Perform the DOM update
|
|
206
|
+
const doUpdate = () => {
|
|
207
|
+
this.setState({
|
|
208
|
+
location: url,
|
|
209
|
+
matches,
|
|
210
|
+
isPending: false,
|
|
211
|
+
navigationState: this.adapter.getState(),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (useViewTransition && typeof document !== 'undefined' && 'startViewTransition' in document) {
|
|
216
|
+
;(document as unknown as { startViewTransition: (cb: () => void) => void }).startViewTransition(doUpdate)
|
|
217
|
+
} else {
|
|
218
|
+
doUpdate()
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.adapter.addEventListener('navigate', handler)
|
|
225
|
+
|
|
226
|
+
const stop = () => {
|
|
227
|
+
this.adapter.removeEventListener('navigate', handler)
|
|
228
|
+
}
|
|
229
|
+
this.stopFn = stop
|
|
230
|
+
|
|
231
|
+
return stop
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Navigate programmatically.
|
|
236
|
+
* The `to` path should not include the base path — it is prepended automatically.
|
|
237
|
+
*/
|
|
238
|
+
navigate(to: string, options?: NavigateOptions): void {
|
|
239
|
+
if (options?.resetScroll === false) {
|
|
240
|
+
this.pendingScrollBehavior = 'manual'
|
|
241
|
+
}
|
|
242
|
+
this.adapter.navigate(this._basePath + to, options)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Navigate to a page by its page ID.
|
|
247
|
+
* Looks up the route pattern from the page registry and builds the URL.
|
|
248
|
+
*/
|
|
249
|
+
navigateToPage(pageId: string, params?: Record<string, string>, options?: NavigateOptions): void {
|
|
250
|
+
const path = this.buildPagePath(pageId, params)
|
|
251
|
+
this.navigate(this.stripBasePath(path), options)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build a URL path for a page ID with the given params.
|
|
256
|
+
* Path params are substituted into the route pattern; extra params become query string.
|
|
257
|
+
*/
|
|
258
|
+
buildPagePath(pageId: string, params?: Record<string, string>): string {
|
|
259
|
+
const pattern = this.pageRegistry[pageId]
|
|
260
|
+
if (!pattern) {
|
|
261
|
+
throw new Error(`[buzola] Unknown page "${pageId}". Is it registered in the page registry?`)
|
|
262
|
+
}
|
|
263
|
+
const resolved = this.resolveParams(pattern, params)
|
|
264
|
+
return this.buildPath(pattern, resolved)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Go back in history.
|
|
269
|
+
*/
|
|
270
|
+
back(): void {
|
|
271
|
+
this.adapter.back()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Go forward in history.
|
|
276
|
+
*/
|
|
277
|
+
forward(): void {
|
|
278
|
+
this.adapter.forward()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build a URL from a path pattern and params.
|
|
283
|
+
* The returned path includes the base path prefix.
|
|
284
|
+
*/
|
|
285
|
+
buildPath(pattern: string, params?: Record<string, string>): string {
|
|
286
|
+
const segments = pattern.split('/')
|
|
287
|
+
const result: string[] = []
|
|
288
|
+
const missing: string[] = []
|
|
289
|
+
|
|
290
|
+
for (const segment of segments) {
|
|
291
|
+
if (segment.startsWith(':')) {
|
|
292
|
+
// Catch-all param (:slug+)
|
|
293
|
+
const isCatchAll = segment.endsWith('+')
|
|
294
|
+
const paramName = isCatchAll ? segment.slice(1, -1) : segment.slice(1)
|
|
295
|
+
const value = params?.[paramName]
|
|
296
|
+
if (value === undefined) {
|
|
297
|
+
missing.push(paramName)
|
|
298
|
+
} else {
|
|
299
|
+
if (isCatchAll) {
|
|
300
|
+
// Catch-all values may contain slashes — insert raw
|
|
301
|
+
result.push(value)
|
|
302
|
+
} else {
|
|
303
|
+
result.push(encodeURIComponent(value))
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
result.push(segment)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (missing.length > 0) {
|
|
312
|
+
if (!params) {
|
|
313
|
+
throw new Error(`Missing params for path "${pattern}": ${missing.join(', ')}`)
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`Missing param "${missing[0]}" for path "${pattern}"`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Extra params (not in path pattern) → query string
|
|
319
|
+
const pathParamNames = new Set(extractParamNames(pattern))
|
|
320
|
+
const search = new URLSearchParams()
|
|
321
|
+
if (params) {
|
|
322
|
+
for (const [key, value] of Object.entries(params)) {
|
|
323
|
+
if (!pathParamNames.has(key) && value !== undefined) {
|
|
324
|
+
search.set(key, value)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const path = this._basePath + result.join('/')
|
|
330
|
+
const qs = search.toString()
|
|
331
|
+
return qs ? `${path}?${qs}` : path
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Strip the base path prefix from a pathname.
|
|
336
|
+
* Returns the pathname relative to the base path.
|
|
337
|
+
*/
|
|
338
|
+
stripBasePath(pathname: string): string {
|
|
339
|
+
if (this._basePath && pathname.startsWith(this._basePath)) {
|
|
340
|
+
return pathname.slice(this._basePath.length) || '/'
|
|
341
|
+
}
|
|
342
|
+
return pathname
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolve params for a route pattern by merging:
|
|
347
|
+
* 1. Explicitly provided params (highest priority)
|
|
348
|
+
* 2. Current URL match params
|
|
349
|
+
* 3. persistentParams() callback (fallback)
|
|
350
|
+
*
|
|
351
|
+
* Only fills params that are present in the target pattern.
|
|
352
|
+
*/
|
|
353
|
+
resolveParams(pattern: string, explicitParams?: Record<string, string>): Record<string, string> {
|
|
354
|
+
const paramNames = this.extractParamNames(pattern)
|
|
355
|
+
const currentParams = this.getCurrentParams()
|
|
356
|
+
const fallbackParams = this.persistentParamsFn?.() ?? {}
|
|
357
|
+
const result: Record<string, string> = {}
|
|
358
|
+
|
|
359
|
+
for (const name of paramNames) {
|
|
360
|
+
const value = explicitParams?.[name] ?? currentParams[name] ?? fallbackParams[name]
|
|
361
|
+
if (value !== undefined) {
|
|
362
|
+
result[name] = value
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Pass through extra explicit params (not in pattern) for search params
|
|
367
|
+
if (explicitParams) {
|
|
368
|
+
const pathParamNames = new Set(paramNames)
|
|
369
|
+
for (const [key, value] of Object.entries(explicitParams)) {
|
|
370
|
+
if (!pathParamNames.has(key) && value !== undefined) {
|
|
371
|
+
result[key] = value
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return result
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Extract parameter names from a route pattern.
|
|
381
|
+
*/
|
|
382
|
+
private extractParamNames(pattern: string): string[] {
|
|
383
|
+
return extractParamNames(pattern)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get merged params from all current route matches.
|
|
388
|
+
*/
|
|
389
|
+
private getCurrentParams(): Record<string, string> {
|
|
390
|
+
const params: Record<string, string> = {}
|
|
391
|
+
for (const match of this.state.matches) {
|
|
392
|
+
Object.assign(params, match.params)
|
|
393
|
+
}
|
|
394
|
+
return params
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Trigger all registered invalidation listeners.
|
|
399
|
+
* Used to re-run page loaders in the background.
|
|
400
|
+
*/
|
|
401
|
+
invalidateAll(): void {
|
|
402
|
+
for (const listener of this.invalidationListeners) {
|
|
403
|
+
listener()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Register an invalidation listener. Returns an unsubscribe function.
|
|
409
|
+
*/
|
|
410
|
+
onInvalidate(listener: () => void): () => void {
|
|
411
|
+
this.invalidationListeners.add(listener)
|
|
412
|
+
return () => {
|
|
413
|
+
this.invalidationListeners.delete(listener)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Register a navigation blocker.
|
|
419
|
+
* The blocker function is called before each navigation.
|
|
420
|
+
* It must return a Promise that resolves to `true` to allow or `false` to block.
|
|
421
|
+
* Returns an unsubscribe function.
|
|
422
|
+
*/
|
|
423
|
+
addBlocker(fn: BlockerFn): () => void {
|
|
424
|
+
this.blockers.add(fn)
|
|
425
|
+
return () => {
|
|
426
|
+
this.blockers.delete(fn)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Dispose the router — clear all subscribers and reset state.
|
|
432
|
+
*/
|
|
433
|
+
dispose(): void {
|
|
434
|
+
this.stopFn?.()
|
|
435
|
+
this.stopFn = undefined
|
|
436
|
+
this.subscribers.clear()
|
|
437
|
+
this.invalidationListeners.clear()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Update the route tree (for HMR).
|
|
442
|
+
*/
|
|
443
|
+
updateRoutes(routes: RouteNode[]): void {
|
|
444
|
+
this.routes = routes
|
|
445
|
+
// Re-match current URL with new routes
|
|
446
|
+
const matches = matchRoutes(this.routes, this.createMatchUrl(this.state.location)) ?? []
|
|
447
|
+
this.setState({
|
|
448
|
+
...this.state,
|
|
449
|
+
matches,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Create a URL with basePath stripped for route matching.
|
|
455
|
+
*/
|
|
456
|
+
private createMatchUrl(url: URL): URL {
|
|
457
|
+
if (!this._basePath) return url
|
|
458
|
+
const stripped = new URL(url)
|
|
459
|
+
if (stripped.pathname.startsWith(this._basePath)) {
|
|
460
|
+
stripped.pathname = stripped.pathname.slice(this._basePath.length) || '/'
|
|
461
|
+
}
|
|
462
|
+
return stripped
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private setState(newState: RouterState): void {
|
|
466
|
+
this.state = newState
|
|
467
|
+
for (const subscriber of this.subscribers) {
|
|
468
|
+
subscriber(this.state)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { StandardSchema } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in minimal Standard Schema builders for route params.
|
|
5
|
+
* Use these when you don't need a full schema library like Zod or Valibot.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { s } from '@buzola/router'
|
|
10
|
+
*
|
|
11
|
+
* const page = createPage()
|
|
12
|
+
* .params({ id: s.string(), tab: s.optional(s.string()) })
|
|
13
|
+
* .route('/project/:id')
|
|
14
|
+
* .render(({ params }) => <div>{params.id}</div>)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Or use string literals for common types:
|
|
18
|
+
* ```ts
|
|
19
|
+
* createPage()
|
|
20
|
+
* .params({ id: 'uuid', order: 'number', text: '?string', tags: '?string[]' })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function string(): StandardSchema<string> {
|
|
25
|
+
return {
|
|
26
|
+
'~standard': {
|
|
27
|
+
version: 1,
|
|
28
|
+
vendor: 'buzola',
|
|
29
|
+
validate: (v) => typeof v === 'string' ? { value: v } : { issues: [{ message: 'expected string' }] },
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function optional(schema: StandardSchema<string>): StandardSchema<string | undefined> {
|
|
35
|
+
return {
|
|
36
|
+
'~standard': {
|
|
37
|
+
version: 1,
|
|
38
|
+
vendor: 'buzola',
|
|
39
|
+
validate: (v) => v === undefined || v === '' ? { value: undefined } : schema['~standard'].validate(v) as { value: string | undefined },
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isOptionalSchema(schema: StandardSchema): boolean {
|
|
45
|
+
const result = schema['~standard'].validate(undefined)
|
|
46
|
+
return !('issues' in result)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function object<T extends Record<string, StandardSchema>>(
|
|
50
|
+
shape: T,
|
|
51
|
+
): StandardSchema<{ [K in keyof T]: T[K] extends StandardSchema<infer V> ? V : never }> & {
|
|
52
|
+
__buzolaKeys: { name: string; optional: boolean; array: boolean }[]
|
|
53
|
+
} {
|
|
54
|
+
return {
|
|
55
|
+
__buzolaKeys: Object.entries(shape).map(([name, s]) => ({ name, optional: isOptionalSchema(s), array: false })),
|
|
56
|
+
'~standard': {
|
|
57
|
+
version: 1,
|
|
58
|
+
vendor: 'buzola',
|
|
59
|
+
validate: (v) => {
|
|
60
|
+
const input = (v ?? {}) as Record<string, unknown>
|
|
61
|
+
const result: Record<string, unknown> = {}
|
|
62
|
+
for (const [key, schema] of Object.entries(shape)) {
|
|
63
|
+
const r = schema['~standard'].validate(input[key])
|
|
64
|
+
if ('issues' in r) return r
|
|
65
|
+
result[key] = r.value
|
|
66
|
+
}
|
|
67
|
+
return { value: result } as { value: { [K in keyof T]: T[K] extends StandardSchema<infer V> ? V : never } }
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const s = { object, string, optional }
|
|
74
|
+
|
|
75
|
+
// ─── Param literals ─────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export type ParamLiteral = 'string' | 'number' | 'uuid' | '?string' | '?number' | '?uuid' | 'string[]' | 'number[]' | '?string[]' | '?number[]'
|
|
78
|
+
|
|
79
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
80
|
+
|
|
81
|
+
type ScalarValidator = (v: unknown) => { value: unknown } | { issues: readonly { message: string }[] }
|
|
82
|
+
|
|
83
|
+
const scalarValidators: Record<string, ScalarValidator> = {
|
|
84
|
+
string: (v) => typeof v === 'string' ? { value: v } : { issues: [{ message: 'expected string' }] },
|
|
85
|
+
number: (v) => {
|
|
86
|
+
const n = Number(v)
|
|
87
|
+
return Number.isNaN(n) ? { issues: [{ message: 'expected number' }] } : { value: n }
|
|
88
|
+
},
|
|
89
|
+
uuid: (v) => typeof v === 'string' && UUID_RE.test(v) ? { value: v } : { issues: [{ message: 'expected UUID' }] },
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveParamLiteral(literal: ParamLiteral): {
|
|
93
|
+
schema: StandardSchema
|
|
94
|
+
optional: boolean
|
|
95
|
+
array: boolean
|
|
96
|
+
} {
|
|
97
|
+
const isOpt = literal.startsWith('?')
|
|
98
|
+
const rest = isOpt ? literal.slice(1) : literal
|
|
99
|
+
const isArr = rest.endsWith('[]')
|
|
100
|
+
const baseType = isArr ? rest.slice(0, -2) : rest
|
|
101
|
+
|
|
102
|
+
const validateScalar = scalarValidators[baseType]
|
|
103
|
+
if (!validateScalar) throw new Error(`Unknown param type: ${baseType}`)
|
|
104
|
+
|
|
105
|
+
let validate: StandardSchema['~standard']['validate']
|
|
106
|
+
|
|
107
|
+
if (isArr) {
|
|
108
|
+
validate = (v) => {
|
|
109
|
+
if (isOpt && (v === undefined || (Array.isArray(v) && v.length === 0))) return { value: undefined }
|
|
110
|
+
if (!Array.isArray(v)) return { issues: [{ message: `expected ${baseType}[]` }] }
|
|
111
|
+
const result: unknown[] = []
|
|
112
|
+
for (const item of v) {
|
|
113
|
+
const r = validateScalar(item)
|
|
114
|
+
if ('issues' in r) return r
|
|
115
|
+
result.push(r.value)
|
|
116
|
+
}
|
|
117
|
+
return { value: result }
|
|
118
|
+
}
|
|
119
|
+
} else if (isOpt) {
|
|
120
|
+
validate = (v) => v === undefined || v === '' ? { value: undefined } : validateScalar(v)
|
|
121
|
+
} else {
|
|
122
|
+
validate = validateScalar
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
schema: { '~standard': { version: 1, vendor: 'buzola', validate } },
|
|
127
|
+
optional: isOpt,
|
|
128
|
+
array: isArr,
|
|
129
|
+
}
|
|
130
|
+
}
|