@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,203 @@
|
|
|
1
|
+
import type { ComponentType, LazyExoticComponent } from 'react'
|
|
2
|
+
|
|
3
|
+
// ─── Module augmentation interface ──────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Module augmentation point for type-safe page-centric routing.
|
|
7
|
+
* Vite plugin generates declarations that extend this interface.
|
|
8
|
+
* Maps page IDs to their params types.
|
|
9
|
+
*/
|
|
10
|
+
// biome-ignore lint/suspicious/noEmptyInterface: declaration merging
|
|
11
|
+
export interface BuzolaPageMap {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Module augmentation point for persistent parameters.
|
|
15
|
+
* Vite plugin generates declarations that extend this interface.
|
|
16
|
+
* Keys listed here are automatically carried over between navigations.
|
|
17
|
+
*/
|
|
18
|
+
// biome-ignore lint/suspicious/noEmptyInterface: declaration merging
|
|
19
|
+
export interface BuzolaPersistentParams {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Registered page IDs from BuzolaPageMap.
|
|
23
|
+
*/
|
|
24
|
+
export type RegisteredPage = keyof BuzolaPageMap & string
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the params type for a registered page.
|
|
28
|
+
*/
|
|
29
|
+
export type PageParams<P extends keyof BuzolaPageMap> = BuzolaPageMap[P]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Effective params for a page — persistent params become optional.
|
|
33
|
+
* When BuzolaPersistentParams is empty (default), collapses to BuzolaPageMap[P].
|
|
34
|
+
*/
|
|
35
|
+
export type EffectivePageParams<P extends keyof BuzolaPageMap> =
|
|
36
|
+
& Omit<BuzolaPageMap[P], keyof BuzolaPersistentParams & string>
|
|
37
|
+
& Partial<Pick<BuzolaPageMap[P], keyof BuzolaPersistentParams & string & keyof BuzolaPageMap[P]>>
|
|
38
|
+
|
|
39
|
+
// ─── Route configuration ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Standard Schema compatible interface (Zod 4, Valibot, ArkType).
|
|
43
|
+
* See: https://github.com/standard-schema/standard-schema
|
|
44
|
+
*/
|
|
45
|
+
export interface StandardSchema<T = unknown> {
|
|
46
|
+
'~standard': {
|
|
47
|
+
version: 1
|
|
48
|
+
vendor: string
|
|
49
|
+
validate: (value: unknown) => { value: T } | { issues: readonly { message: string }[] }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
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
|
+
/** Route component — either a regular component or a lazy-loaded one. */
|
|
68
|
+
export type RouteComponent = ComponentType<Record<string, never>> | LazyExoticComponent<ComponentType<Record<string, never>>>
|
|
69
|
+
|
|
70
|
+
/** Configuration for a single route. */
|
|
71
|
+
export interface RouteConfig {
|
|
72
|
+
/** The path segment for this route. */
|
|
73
|
+
path: string
|
|
74
|
+
/** Component to render for this route. */
|
|
75
|
+
component?: RouteComponent
|
|
76
|
+
/** Schema for validating search params (Standard Schema compatible). */
|
|
77
|
+
searchSchema?: StandardSchema
|
|
78
|
+
/** Guard function called before entering this route. */
|
|
79
|
+
beforeEnter?: RouteGuard
|
|
80
|
+
/** Child routes. */
|
|
81
|
+
children?: RouteConfig[]
|
|
82
|
+
/** Whether this is a layout route (has outlet but no own path segment). */
|
|
83
|
+
isLayout?: boolean
|
|
84
|
+
/** Whether this is an index route (renders at parent's exact path). */
|
|
85
|
+
isIndex?: boolean
|
|
86
|
+
/** Whether this is a pathless group (e.g., (auth) — no URL segment). */
|
|
87
|
+
isPathlessGroup?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Route matching ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** A matched route with extracted params. */
|
|
93
|
+
export interface RouteMatch {
|
|
94
|
+
/** The route node that matched. */
|
|
95
|
+
node: RouteNode
|
|
96
|
+
/** Extracted path parameters. */
|
|
97
|
+
params: Record<string, string>
|
|
98
|
+
/** The full URL pathname that was matched. */
|
|
99
|
+
pathname: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Route tree ─────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** A node in the route tree. */
|
|
105
|
+
export interface RouteNode {
|
|
106
|
+
/** Unique ID for this node. */
|
|
107
|
+
id: string
|
|
108
|
+
/** The path segment pattern (e.g., "/users/:userId"). */
|
|
109
|
+
path: string
|
|
110
|
+
/** Full path from root (e.g., "/users/:userId"). */
|
|
111
|
+
fullPath: string
|
|
112
|
+
/** Compiled URLPattern for matching (leaf/index routes). */
|
|
113
|
+
pattern?: URLPattern
|
|
114
|
+
/** Compiled prefix URLPattern for param extraction (layout routes). */
|
|
115
|
+
prefixPattern?: URLPattern
|
|
116
|
+
/** Component to render. */
|
|
117
|
+
component?: RouteComponent
|
|
118
|
+
/** Search params schema. */
|
|
119
|
+
searchSchema?: StandardSchema
|
|
120
|
+
/** Route guard. */
|
|
121
|
+
beforeEnter?: RouteGuard
|
|
122
|
+
/** Child nodes. */
|
|
123
|
+
children: RouteNode[]
|
|
124
|
+
/** Parent node reference. */
|
|
125
|
+
parent?: RouteNode
|
|
126
|
+
/** Whether this is a layout node (renders children via Outlet). */
|
|
127
|
+
isLayout: boolean
|
|
128
|
+
/** Whether this is an index route. */
|
|
129
|
+
isIndex: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Router state ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** The current state of the router. */
|
|
135
|
+
export interface RouterState {
|
|
136
|
+
/** Current URL location. */
|
|
137
|
+
location: URL
|
|
138
|
+
/** Array of matched routes from root to leaf. */
|
|
139
|
+
matches: RouteMatch[]
|
|
140
|
+
/** Whether a navigation is in progress. */
|
|
141
|
+
isPending: boolean
|
|
142
|
+
/** Navigation state from Navigation API. */
|
|
143
|
+
navigationState?: unknown
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Navigation adapter ─────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Options for programmatic navigation. */
|
|
149
|
+
export interface NavigateOptions {
|
|
150
|
+
/** Replace the current history entry instead of pushing. */
|
|
151
|
+
replace?: boolean
|
|
152
|
+
/** State to associate with the navigation entry. */
|
|
153
|
+
state?: unknown
|
|
154
|
+
/** Use View Transitions API for the navigation. */
|
|
155
|
+
viewTransition?: boolean
|
|
156
|
+
/**
|
|
157
|
+
* Whether to reset scroll position after navigation.
|
|
158
|
+
* Default is `true` (browser handles it: scroll to top on push, restore on traverse).
|
|
159
|
+
* Set to `false` to preserve current scroll position.
|
|
160
|
+
*/
|
|
161
|
+
resetScroll?: boolean
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Blocker function registered with the router.
|
|
166
|
+
* Called when a navigation is about to happen.
|
|
167
|
+
* Must return a Promise that resolves to `true` to allow navigation, `false` to block.
|
|
168
|
+
*/
|
|
169
|
+
export type BlockerFn = () => Promise<boolean>
|
|
170
|
+
|
|
171
|
+
/** Navigate event from the Navigation API (simplified). */
|
|
172
|
+
export interface BuzolaNavigateEvent {
|
|
173
|
+
/** The destination URL. */
|
|
174
|
+
destination: { url: string }
|
|
175
|
+
/** Whether the navigation can be intercepted. */
|
|
176
|
+
canIntercept: boolean
|
|
177
|
+
/** The type of navigation. */
|
|
178
|
+
navigationType: 'push' | 'replace' | 'reload' | 'traverse'
|
|
179
|
+
/** Whether the user initiated the navigation (e.g., clicking a link). */
|
|
180
|
+
userInitiated: boolean
|
|
181
|
+
/** Whether this navigation should use View Transitions API. */
|
|
182
|
+
viewTransition?: boolean
|
|
183
|
+
/** Intercept the navigation and handle it client-side. */
|
|
184
|
+
intercept(options: { handler: () => Promise<void>; scroll?: 'after-transition' | 'manual' }): void
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Abstraction over the Navigation API for testability. */
|
|
188
|
+
export interface NavigationAdapter {
|
|
189
|
+
/** Get the current URL. */
|
|
190
|
+
getCurrentURL(): URL
|
|
191
|
+
/** Navigate to a URL. */
|
|
192
|
+
navigate(url: string, options?: NavigateOptions): void
|
|
193
|
+
/** Go back. */
|
|
194
|
+
back(): void
|
|
195
|
+
/** Go forward. */
|
|
196
|
+
forward(): void
|
|
197
|
+
/** Add a navigate event listener. */
|
|
198
|
+
addEventListener(type: 'navigate', handler: (event: BuzolaNavigateEvent) => void): void
|
|
199
|
+
/** Remove a navigate event listener. */
|
|
200
|
+
removeEventListener(type: 'navigate', handler: (event: BuzolaNavigateEvent) => void): void
|
|
201
|
+
/** Get the current navigation state. */
|
|
202
|
+
getState(): unknown
|
|
203
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract parameter names from a route pattern.
|
|
3
|
+
* "/users/:userId/posts/:postId" → ["userId", "postId"]
|
|
4
|
+
* "/:lang/users/:userId+" → ["lang", "userId"]
|
|
5
|
+
*/
|
|
6
|
+
export function extractParamNames(pattern: string): string[] {
|
|
7
|
+
const names: string[] = []
|
|
8
|
+
for (const segment of pattern.split('/')) {
|
|
9
|
+
if (segment.startsWith(':')) {
|
|
10
|
+
// Strip catch-all suffix (+)
|
|
11
|
+
const name = segment.endsWith('+') ? segment.slice(1, -1) : segment.slice(1)
|
|
12
|
+
names.push(name)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return names
|
|
16
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Type declarations for Navigation API and URLPattern
|
|
2
|
+
// These are Baseline Newly Available (Jan 2026) but not yet in TypeScript's lib.
|
|
3
|
+
|
|
4
|
+
interface URLPatternInit {
|
|
5
|
+
protocol?: string
|
|
6
|
+
username?: string
|
|
7
|
+
password?: string
|
|
8
|
+
hostname?: string
|
|
9
|
+
port?: string
|
|
10
|
+
pathname?: string
|
|
11
|
+
search?: string
|
|
12
|
+
hash?: string
|
|
13
|
+
baseURL?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface URLPatternComponentResult {
|
|
17
|
+
input: string
|
|
18
|
+
groups: Record<string, string | undefined>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface URLPatternResult {
|
|
22
|
+
inputs: [URLPatternInit] | [URLPatternInit, string]
|
|
23
|
+
protocol: URLPatternComponentResult
|
|
24
|
+
username: URLPatternComponentResult
|
|
25
|
+
password: URLPatternComponentResult
|
|
26
|
+
hostname: URLPatternComponentResult
|
|
27
|
+
port: URLPatternComponentResult
|
|
28
|
+
pathname: URLPatternComponentResult
|
|
29
|
+
search: URLPatternComponentResult
|
|
30
|
+
hash: URLPatternComponentResult
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare class URLPattern {
|
|
34
|
+
constructor(init: URLPatternInit)
|
|
35
|
+
constructor(pattern: string, baseURL?: string)
|
|
36
|
+
test(input?: URLPatternInit | string): boolean
|
|
37
|
+
exec(input?: URLPatternInit | string): URLPatternResult | null
|
|
38
|
+
readonly protocol: string
|
|
39
|
+
readonly username: string
|
|
40
|
+
readonly password: string
|
|
41
|
+
readonly hostname: string
|
|
42
|
+
readonly port: string
|
|
43
|
+
readonly pathname: string
|
|
44
|
+
readonly search: string
|
|
45
|
+
readonly hash: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface NavigationDestination {
|
|
49
|
+
url: string
|
|
50
|
+
key: string
|
|
51
|
+
id: string
|
|
52
|
+
index: number
|
|
53
|
+
sameDocument: boolean
|
|
54
|
+
getState(): unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface NavigationHistoryEntry {
|
|
58
|
+
url: string | null
|
|
59
|
+
key: string
|
|
60
|
+
id: string
|
|
61
|
+
index: number
|
|
62
|
+
sameDocument: boolean
|
|
63
|
+
getState(): unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface NavigateEvent extends Event {
|
|
67
|
+
navigationType: 'push' | 'replace' | 'reload' | 'traverse'
|
|
68
|
+
destination: NavigationDestination
|
|
69
|
+
canIntercept: boolean
|
|
70
|
+
userInitiated: boolean
|
|
71
|
+
hashChange: boolean
|
|
72
|
+
signal: AbortSignal
|
|
73
|
+
formData: FormData | null
|
|
74
|
+
downloadRequest: string | null
|
|
75
|
+
info: unknown
|
|
76
|
+
intercept(options?: {
|
|
77
|
+
handler?: () => Promise<void>
|
|
78
|
+
focusReset?: 'after-transition' | 'manual'
|
|
79
|
+
scroll?: 'after-transition' | 'manual'
|
|
80
|
+
}): void
|
|
81
|
+
scroll(): void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface NavigationNavigateOptions {
|
|
85
|
+
state?: unknown
|
|
86
|
+
info?: unknown
|
|
87
|
+
history?: 'auto' | 'push' | 'replace'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface NavigationResult {
|
|
91
|
+
committed: Promise<NavigationHistoryEntry>
|
|
92
|
+
finished: Promise<NavigationHistoryEntry>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface Navigation extends EventTarget {
|
|
96
|
+
entries(): NavigationHistoryEntry[]
|
|
97
|
+
readonly currentEntry: NavigationHistoryEntry | null
|
|
98
|
+
readonly transition: NavigationTransition | null
|
|
99
|
+
readonly canGoBack: boolean
|
|
100
|
+
readonly canGoForward: boolean
|
|
101
|
+
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult
|
|
102
|
+
reload(options?: { state?: unknown; info?: unknown }): NavigationResult
|
|
103
|
+
traverseTo(key: string, options?: { info?: unknown }): NavigationResult
|
|
104
|
+
back(options?: { info?: unknown }): NavigationResult
|
|
105
|
+
forward(options?: { info?: unknown }): NavigationResult
|
|
106
|
+
addEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void
|
|
107
|
+
addEventListener(type: string, listener: EventListener): void
|
|
108
|
+
removeEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void
|
|
109
|
+
removeEventListener(type: string, listener: EventListener): void
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface NavigationTransition {
|
|
113
|
+
navigationType: 'push' | 'replace' | 'reload' | 'traverse'
|
|
114
|
+
from: NavigationHistoryEntry
|
|
115
|
+
finished: Promise<void>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface Window {
|
|
119
|
+
navigation: Navigation
|
|
120
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ─── Engine ─────────────────────────────────────────────────────────────────
|
|
2
|
+
export { NavigationAbortedError, Router } from './engine/router'
|
|
3
|
+
export type { RouterOptions, RouterSubscriber } from './engine/router'
|
|
4
|
+
|
|
5
|
+
export { buildRouteTree, createRouteNode } from './engine/route-tree'
|
|
6
|
+
export type { CreateRouteNodeOptions } from './engine/route-tree'
|
|
7
|
+
|
|
8
|
+
export { matchRoutes } from './engine/matcher'
|
|
9
|
+
|
|
10
|
+
export { createBrowserNavigationAdapter, createMemoryNavigationAdapter } from './engine/navigation-adapter'
|
|
11
|
+
export type { MemoryNavigationAdapterOptions } from './engine/navigation-adapter'
|
|
12
|
+
|
|
13
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
14
|
+
export type {
|
|
15
|
+
BlockerFn,
|
|
16
|
+
BuzolaNavigateEvent,
|
|
17
|
+
BuzolaPageMap,
|
|
18
|
+
BuzolaPersistentParams,
|
|
19
|
+
EffectivePageParams,
|
|
20
|
+
GuardContext,
|
|
21
|
+
GuardRedirect,
|
|
22
|
+
NavigateOptions,
|
|
23
|
+
NavigationAdapter,
|
|
24
|
+
PageParams,
|
|
25
|
+
RegisteredPage,
|
|
26
|
+
RouteComponent,
|
|
27
|
+
RouteConfig,
|
|
28
|
+
RouteGuard,
|
|
29
|
+
RouteGuardResult,
|
|
30
|
+
RouteMatch,
|
|
31
|
+
RouteNode,
|
|
32
|
+
RouterState,
|
|
33
|
+
StandardSchema,
|
|
34
|
+
} from './engine/types'
|
|
35
|
+
|
|
36
|
+
// ─── Schema ──────────────────────────────────────────────────────────────────
|
|
37
|
+
export { s } from './engine/schema'
|
|
38
|
+
|
|
39
|
+
// ─── Route definition ───────────────────────────────────────────────────────
|
|
40
|
+
export { createPage } from './define/create-page'
|
|
41
|
+
export type { CatchContext, PageDefinition, PageProps } from './define/create-page'
|
|
42
|
+
|
|
43
|
+
// ─── React (re-export from subpath) ─────────────────────────────────────────
|
|
44
|
+
export {
|
|
45
|
+
BuzolaProvider,
|
|
46
|
+
ErrorBoundary,
|
|
47
|
+
Link,
|
|
48
|
+
Outlet,
|
|
49
|
+
RouteContext,
|
|
50
|
+
RouterContext,
|
|
51
|
+
useBlocker,
|
|
52
|
+
useInvalidate,
|
|
53
|
+
useNavigate,
|
|
54
|
+
useNavigationState,
|
|
55
|
+
useParams,
|
|
56
|
+
useRoute,
|
|
57
|
+
useRouter,
|
|
58
|
+
useRouterState,
|
|
59
|
+
useSearchParams,
|
|
60
|
+
} from './react/index'
|
|
61
|
+
export type { BlockerState, BuzolaProviderProps, LinkProps, OutletProps, RouteContextValue } from './react/index'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
import type { Router } from '../engine/router'
|
|
3
|
+
import type { RouteMatch, RouterState } from '../engine/types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context providing the Router instance.
|
|
7
|
+
*/
|
|
8
|
+
export const RouterContext = createContext<Router | null>(null)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Context for the current route matching state.
|
|
12
|
+
*/
|
|
13
|
+
export interface RouteContextValue {
|
|
14
|
+
/** Current router state. */
|
|
15
|
+
state: RouterState
|
|
16
|
+
/** Current depth in the route match chain. */
|
|
17
|
+
depth: number
|
|
18
|
+
/** All matches from root to leaf. */
|
|
19
|
+
matches: RouteMatch[]
|
|
20
|
+
/** Merged params from root to current depth. */
|
|
21
|
+
params: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const RouteContext = createContext<RouteContextValue | null>(null)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React, { Component } from 'react'
|
|
2
|
+
|
|
3
|
+
interface ErrorBoundaryProps {
|
|
4
|
+
fallback?: React.ReactNode | ((error: Error) => React.ReactNode)
|
|
5
|
+
/** When this key changes, the error state is reset. */
|
|
6
|
+
resetKey?: string
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ErrorBoundaryState {
|
|
11
|
+
error: Error | null
|
|
12
|
+
prevResetKey: string | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Error boundary for catching rendering errors in route components.
|
|
17
|
+
* Resets automatically when `resetKey` changes (e.g., on navigation to a different route).
|
|
18
|
+
*/
|
|
19
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
20
|
+
state: ErrorBoundaryState = { error: null, prevResetKey: undefined }
|
|
21
|
+
|
|
22
|
+
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
|
23
|
+
return { error }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static getDerivedStateFromProps(
|
|
27
|
+
props: ErrorBoundaryProps,
|
|
28
|
+
state: ErrorBoundaryState,
|
|
29
|
+
): Partial<ErrorBoundaryState> | null {
|
|
30
|
+
// Reset error state when resetKey changes (i.e., route changed)
|
|
31
|
+
if (state.error && props.resetKey !== state.prevResetKey) {
|
|
32
|
+
return { error: null, prevResetKey: props.resetKey }
|
|
33
|
+
}
|
|
34
|
+
if (props.resetKey !== state.prevResetKey) {
|
|
35
|
+
return { prevResetKey: props.resetKey }
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
|
41
|
+
console.error('Buzola route error:', error, info)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
render(): React.ReactNode {
|
|
45
|
+
if (this.state.error) {
|
|
46
|
+
if (typeof this.props.fallback === 'function') {
|
|
47
|
+
return this.props.fallback(this.state.error)
|
|
48
|
+
}
|
|
49
|
+
if (this.props.fallback) {
|
|
50
|
+
return this.props.fallback
|
|
51
|
+
}
|
|
52
|
+
return React.createElement('div', { style: { color: 'red' } }, `Route error: ${this.state.error.message}`)
|
|
53
|
+
}
|
|
54
|
+
return this.props.children
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
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'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the Router instance.
|
|
8
|
+
*/
|
|
9
|
+
export function useRouter(): Router {
|
|
10
|
+
const router = use(RouterContext)
|
|
11
|
+
if (!router) {
|
|
12
|
+
throw new Error('useRouter must be used within a <BuzolaProvider>')
|
|
13
|
+
}
|
|
14
|
+
return router
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the current route context.
|
|
19
|
+
*/
|
|
20
|
+
function useRouteContext() {
|
|
21
|
+
const context = use(RouteContext)
|
|
22
|
+
if (!context) {
|
|
23
|
+
throw new Error('Route hooks must be used within a <BuzolaProvider>')
|
|
24
|
+
}
|
|
25
|
+
return context
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Type-safe navigation function.
|
|
30
|
+
* Uses page IDs instead of URL patterns.
|
|
31
|
+
* Persistent params are optional — resolved from current URL or fallback.
|
|
32
|
+
*/
|
|
33
|
+
type NavigateFn = <P extends RegisteredPage>(
|
|
34
|
+
to: P,
|
|
35
|
+
...args: [keyof EffectivePageParams<P>] extends [never] ? [options?: NavigateOptions]
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
37
|
+
: {} extends EffectivePageParams<P> ? [options?: NavigateOptions & { params?: EffectivePageParams<P> }]
|
|
38
|
+
: [options: NavigateOptions & { params: EffectivePageParams<P> }]
|
|
39
|
+
) => void
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hook to get a type-safe navigate function.
|
|
43
|
+
* Navigates using page IDs — the router looks up the route pattern from the page registry.
|
|
44
|
+
*/
|
|
45
|
+
export function useNavigate(): NavigateFn {
|
|
46
|
+
const router = useRouter()
|
|
47
|
+
|
|
48
|
+
return useCallback(
|
|
49
|
+
(to: string, options?: NavigateOptions & { params?: Record<string, string> }) => {
|
|
50
|
+
router.navigateToPage(to, options?.params, options)
|
|
51
|
+
},
|
|
52
|
+
[router],
|
|
53
|
+
) as NavigateFn
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Hook to get typed route params.
|
|
58
|
+
* Params are merged from all matched routes (root to current depth).
|
|
59
|
+
*/
|
|
60
|
+
export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
|
|
61
|
+
const context = useRouteContext()
|
|
62
|
+
return context.params as T
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Hook to get and validate search params.
|
|
67
|
+
* Optionally validates with a Standard Schema compatible schema.
|
|
68
|
+
*/
|
|
69
|
+
export function useSearchParams<T = Record<string, string>>(
|
|
70
|
+
schema?: StandardSchema<T>,
|
|
71
|
+
): T {
|
|
72
|
+
const context = useRouteContext()
|
|
73
|
+
|
|
74
|
+
return useMemo(() => {
|
|
75
|
+
const searchParams = context.state.location.searchParams
|
|
76
|
+
const raw: Record<string, string[]> = {}
|
|
77
|
+
searchParams.forEach((value, key) => {
|
|
78
|
+
const existing = raw[key]
|
|
79
|
+
if (existing) {
|
|
80
|
+
existing.push(value)
|
|
81
|
+
} else {
|
|
82
|
+
raw[key] = [value]
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
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
|
+
}
|
|
95
|
+
|
|
96
|
+
return raw as T
|
|
97
|
+
}, [context.state.location.searchParams, schema])
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook to get information about the current route.
|
|
102
|
+
*/
|
|
103
|
+
export function useRoute() {
|
|
104
|
+
const context = useRouteContext()
|
|
105
|
+
const router = useRouter()
|
|
106
|
+
const { state, matches, params, depth } = context
|
|
107
|
+
|
|
108
|
+
return useMemo(() => ({
|
|
109
|
+
/** Current route's params. */
|
|
110
|
+
params,
|
|
111
|
+
/** All route matches from root to leaf. */
|
|
112
|
+
matches,
|
|
113
|
+
/** Current match depth. */
|
|
114
|
+
depth,
|
|
115
|
+
/** Whether a navigation is pending. */
|
|
116
|
+
isPending: state.isPending,
|
|
117
|
+
/** Current pathname (with base path stripped). */
|
|
118
|
+
pathname: router.stripBasePath(state.location.pathname),
|
|
119
|
+
}), [params, matches, depth, state.isPending, state.location.pathname, router])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Hook to get the Navigation API state for the current entry.
|
|
124
|
+
*/
|
|
125
|
+
export function useNavigationState<T = unknown>(): T | undefined {
|
|
126
|
+
const context = useRouteContext()
|
|
127
|
+
return context.state.navigationState as T | undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hook to get the current RouterState.
|
|
132
|
+
*/
|
|
133
|
+
export function useRouterState(): RouterState {
|
|
134
|
+
const context = useRouteContext()
|
|
135
|
+
return context.state
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Hook to get a function that invalidates all page loaders.
|
|
140
|
+
* Loaders re-run in the background while stale data is still shown.
|
|
141
|
+
*/
|
|
142
|
+
export function useInvalidate(): () => void {
|
|
143
|
+
const router = useRouter()
|
|
144
|
+
return useCallback(() => router.invalidateAll(), [router])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Blocker ─────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export interface BlockerState {
|
|
150
|
+
/** Whether a navigation is currently blocked. */
|
|
151
|
+
state: 'idle' | 'blocked'
|
|
152
|
+
/** Allow the blocked navigation to proceed. */
|
|
153
|
+
proceed: () => void
|
|
154
|
+
/** Cancel the blocked navigation and stay on the current page. */
|
|
155
|
+
reset: () => void
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Hook to block navigations (e.g., to warn about unsaved changes).
|
|
160
|
+
*
|
|
161
|
+
* @param when - Whether blocking is active. Pass `true` to block, `false` to allow.
|
|
162
|
+
* @returns An object with the blocker state and proceed/reset functions.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```tsx
|
|
166
|
+
* const blocker = useBlocker(formIsDirty);
|
|
167
|
+
*
|
|
168
|
+
* if (blocker.state === 'blocked') {
|
|
169
|
+
* return <Dialog onConfirm={blocker.proceed} onCancel={blocker.reset} />;
|
|
170
|
+
* }
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export function useBlocker(when: boolean): BlockerState {
|
|
174
|
+
const router = useRouter()
|
|
175
|
+
const [state, setState] = useState<'idle' | 'blocked'>('idle')
|
|
176
|
+
const resolverRef = useRef<((proceed: boolean) => void) | null>(null)
|
|
177
|
+
|
|
178
|
+
const blockerFn = useCallback((): Promise<boolean> => {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
resolverRef.current = resolve
|
|
181
|
+
setState('blocked')
|
|
182
|
+
})
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!when) return
|
|
187
|
+
const cleanup = router.addBlocker(blockerFn)
|
|
188
|
+
return () => {
|
|
189
|
+
cleanup()
|
|
190
|
+
// If there's a pending blocker when unmounting, auto-proceed
|
|
191
|
+
if (resolverRef.current) {
|
|
192
|
+
resolverRef.current(true)
|
|
193
|
+
resolverRef.current = null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, [router, when, blockerFn])
|
|
197
|
+
|
|
198
|
+
const proceed = useCallback(() => {
|
|
199
|
+
resolverRef.current?.(true)
|
|
200
|
+
resolverRef.current = null
|
|
201
|
+
setState('idle')
|
|
202
|
+
}, [])
|
|
203
|
+
|
|
204
|
+
const reset = useCallback(() => {
|
|
205
|
+
resolverRef.current?.(false)
|
|
206
|
+
resolverRef.current = null
|
|
207
|
+
setState('idle')
|
|
208
|
+
}, [])
|
|
209
|
+
|
|
210
|
+
return { state, proceed, reset }
|
|
211
|
+
}
|