@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,472 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
import { type ComponentType, use, useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
|
3
|
+
import { isOptionalSchema, type ParamLiteral, resolveParamLiteral } from '../engine/schema'
|
|
4
|
+
import type { RouteComponent, StandardSchema } from '../engine/types'
|
|
5
|
+
import { extractParamNames } from '../engine/utils'
|
|
6
|
+
import { RouteContext, RouterContext } from '../react/context'
|
|
7
|
+
import { ErrorBoundary } from '../react/error-boundary'
|
|
8
|
+
|
|
9
|
+
export interface PageProps<TParams> {
|
|
10
|
+
params: TParams
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CatchContext {
|
|
14
|
+
error: Error
|
|
15
|
+
retry: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type CatchHandler = React.ReactNode | ((ctx: CatchContext) => React.ReactNode)
|
|
19
|
+
|
|
20
|
+
export interface ParamMeta {
|
|
21
|
+
name: string
|
|
22
|
+
optional: boolean
|
|
23
|
+
array: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PageDefinition<TParams = Record<string, never>> {
|
|
27
|
+
__buzolaPage: true
|
|
28
|
+
component: RouteComponent
|
|
29
|
+
route?: string
|
|
30
|
+
paramsSchema?: StandardSchema
|
|
31
|
+
paramsMeta: ParamMeta[]
|
|
32
|
+
loader?: (ctx: { params: any }) => Promise<unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Route pattern type safety ──────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract dynamic parameter names from a route pattern at the type level.
|
|
39
|
+
* "/users/:userId/posts/:postId" → "userId" | "postId"
|
|
40
|
+
* "/:slug+" → "slug"
|
|
41
|
+
*/
|
|
42
|
+
type ExtractRouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
|
|
43
|
+
? (Param extends `${infer P}+` ? P : Param) | ExtractRouteParams<Rest>
|
|
44
|
+
: T extends `${string}:${infer Param}` ? (Param extends `${infer P}+` ? P : Param)
|
|
45
|
+
: never
|
|
46
|
+
|
|
47
|
+
// ─── Schema helpers ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
type ParamField = StandardSchema | ParamLiteral
|
|
50
|
+
type ParamsShape = Record<string, ParamField>
|
|
51
|
+
|
|
52
|
+
type ScalarType<T extends string> = T extends 'string' ? string
|
|
53
|
+
: T extends 'number' ? number
|
|
54
|
+
: T extends 'uuid' ? string
|
|
55
|
+
: never
|
|
56
|
+
|
|
57
|
+
type LiteralToType<L extends string> = L extends `?${infer Base}[]` ? ScalarType<Base>[] | undefined
|
|
58
|
+
: L extends `${infer Base}[]` ? ScalarType<Base>[]
|
|
59
|
+
: L extends `?${infer Base}` ? ScalarType<Base> | undefined
|
|
60
|
+
: ScalarType<L>
|
|
61
|
+
|
|
62
|
+
type InferShape<T extends ParamsShape> = {
|
|
63
|
+
[K in keyof T]: T[K] extends StandardSchema<infer V> ? V : T[K] extends string ? LiteralToType<T[K]> : never
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isStandardSchema(value: StandardSchema | ParamsShape): value is StandardSchema {
|
|
67
|
+
return '~standard' in value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveParamsInput<T>(input: StandardSchema<T> | ParamsShape): {
|
|
71
|
+
schema: StandardSchema<T>
|
|
72
|
+
paramsMeta: ParamMeta[]
|
|
73
|
+
} {
|
|
74
|
+
if (isStandardSchema(input)) {
|
|
75
|
+
return {
|
|
76
|
+
schema: input,
|
|
77
|
+
paramsMeta: (input as any).__buzolaKeys ?? [],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const shape = input
|
|
82
|
+
const paramsMeta: ParamMeta[] = []
|
|
83
|
+
const fieldSchemas: Record<string, StandardSchema> = {}
|
|
84
|
+
|
|
85
|
+
for (const [name, field] of Object.entries(shape)) {
|
|
86
|
+
if (typeof field === 'string') {
|
|
87
|
+
const resolved = resolveParamLiteral(field)
|
|
88
|
+
fieldSchemas[name] = resolved.schema
|
|
89
|
+
paramsMeta.push({ name, optional: resolved.optional, array: resolved.array })
|
|
90
|
+
} else {
|
|
91
|
+
fieldSchemas[name] = field
|
|
92
|
+
paramsMeta.push({ name, optional: isOptionalSchema(field), array: false })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const schema: StandardSchema<T> = {
|
|
97
|
+
'~standard': {
|
|
98
|
+
version: 1,
|
|
99
|
+
vendor: 'buzola',
|
|
100
|
+
validate: (v) => {
|
|
101
|
+
const record = (v ?? {}) as Record<string, unknown>
|
|
102
|
+
const result: Record<string, unknown> = {}
|
|
103
|
+
for (const [key, fs] of Object.entries(fieldSchemas)) {
|
|
104
|
+
const r = fs['~standard'].validate(record[key])
|
|
105
|
+
if ('issues' in r) return r
|
|
106
|
+
result[key] = r.value
|
|
107
|
+
}
|
|
108
|
+
return { value: result } as { value: T }
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { schema, paramsMeta }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Loader helpers ─────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
type LoaderFn = (ctx: { params: any }) => Promise<any>
|
|
119
|
+
|
|
120
|
+
function combineLoaders(loaders: LoaderFn[]): LoaderFn | undefined {
|
|
121
|
+
if (loaders.length === 0) return undefined
|
|
122
|
+
if (loaders.length === 1) return loaders[0]
|
|
123
|
+
return async (ctx) => Object.assign({}, ...await Promise.all(loaders.map(fn => fn(ctx))))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Component factory ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function createPageComponent<TParams>(
|
|
129
|
+
schema: StandardSchema<TParams> | undefined,
|
|
130
|
+
paramsMeta: ParamMeta[],
|
|
131
|
+
loaderFn: LoaderFn | undefined,
|
|
132
|
+
routePattern: string | undefined,
|
|
133
|
+
RenderComponent: ComponentType<any>,
|
|
134
|
+
catchHandler?: CatchHandler,
|
|
135
|
+
): PageDefinition<TParams> {
|
|
136
|
+
const arrayParams = new Set(paramsMeta.filter(m => m.array).map(m => m.name))
|
|
137
|
+
|
|
138
|
+
// Loader promise cache lives in the closure, outside React's render cycle.
|
|
139
|
+
// This is critical: when use() suspends during the initial render, React
|
|
140
|
+
// discards all uncommitted hook state (useRef, useState, useMemo).
|
|
141
|
+
// On retry, hooks reinitialize, and useMemo/useRef would create a new promise,
|
|
142
|
+
// causing an infinite suspend loop. The closure cache survives this.
|
|
143
|
+
|
|
144
|
+
// Active load state (current URL)
|
|
145
|
+
let activeUrlHref: string | null = null
|
|
146
|
+
let activeCacheKey: string | null = null
|
|
147
|
+
let activePromise: Promise<unknown> | null = null
|
|
148
|
+
let activeResolvedData: unknown = undefined
|
|
149
|
+
let hasResolved = false
|
|
150
|
+
let pendingBackgroundKey: string | null = null
|
|
151
|
+
|
|
152
|
+
// LRU cache for previously visited URLs (url → resolved data)
|
|
153
|
+
const staleDataCache = new Map<string, unknown>()
|
|
154
|
+
|
|
155
|
+
function evictStaleCache(maxSize: number): void {
|
|
156
|
+
while (staleDataCache.size > maxSize) {
|
|
157
|
+
const first = staleDataCache.keys().next().value!
|
|
158
|
+
staleDataCache.delete(first)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function BuzolaPage(): ReactElement | null {
|
|
163
|
+
const routeContext = use(RouteContext)
|
|
164
|
+
if (!routeContext) {
|
|
165
|
+
throw new Error('createPage component must be used within a <BuzolaProvider>')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const router = use(RouterContext)
|
|
169
|
+
|
|
170
|
+
const params = useMemo(() => {
|
|
171
|
+
const { state, matches, params: pathParams } = routeContext
|
|
172
|
+
|
|
173
|
+
const currentMatch = matches.find(m => m.node.component === BuzolaPage)
|
|
174
|
+
const pathParamNames = new Set(
|
|
175
|
+
currentMatch ? extractParamNames(currentMatch.node.fullPath) : [],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const merged: Record<string, string | string[]> = {}
|
|
179
|
+
|
|
180
|
+
if (arrayParams.size > 0) {
|
|
181
|
+
for (const name of arrayParams) {
|
|
182
|
+
if (pathParamNames.has(name)) continue
|
|
183
|
+
const values = [
|
|
184
|
+
...state.location.searchParams.getAll(name),
|
|
185
|
+
...state.location.searchParams.getAll(`${name}[]`),
|
|
186
|
+
]
|
|
187
|
+
if (values.length > 0) merged[name] = values
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
state.location.searchParams.forEach((value, key) => {
|
|
192
|
+
const cleanKey = key.endsWith('[]') ? key.slice(0, -2) : key
|
|
193
|
+
if (!pathParamNames.has(cleanKey) && !(cleanKey in merged)) {
|
|
194
|
+
merged[cleanKey] = value
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
for (const [key, value] of Object.entries(pathParams)) {
|
|
199
|
+
if (arrayParams.has(key)) {
|
|
200
|
+
merged[key] = value.split(',')
|
|
201
|
+
} else {
|
|
202
|
+
merged[key] = value
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (schema) {
|
|
207
|
+
const result = schema['~standard'].validate(merged)
|
|
208
|
+
if ('issues' in result) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Page params validation failed: ${result.issues.map(i => i.message).join(', ')}`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
return result.value
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return merged as TParams
|
|
217
|
+
}, [routeContext])
|
|
218
|
+
|
|
219
|
+
const [loaderKey, setLoaderKey] = useState(0)
|
|
220
|
+
const [, forceUpdate] = useReducer(x => x + 1, 0)
|
|
221
|
+
const invalidate = useCallback(() => setLoaderKey(k => k + 1), [])
|
|
222
|
+
|
|
223
|
+
// Subscribe to router-level invalidation
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!router) return
|
|
226
|
+
return router.onInvalidate(() => setLoaderKey(k => k + 1))
|
|
227
|
+
}, [router])
|
|
228
|
+
|
|
229
|
+
const cacheSize = router?.loaderCacheSize ?? 0
|
|
230
|
+
|
|
231
|
+
let data: unknown = undefined
|
|
232
|
+
let isLoading = false
|
|
233
|
+
|
|
234
|
+
if (loaderFn) {
|
|
235
|
+
const currentUrlHref = routeContext.state.location.href
|
|
236
|
+
const cacheKey = `${currentUrlHref}:${loaderKey}`
|
|
237
|
+
|
|
238
|
+
// URL changed — save old data to stale cache, restore from stale cache if available
|
|
239
|
+
if (activeUrlHref !== null && activeUrlHref !== currentUrlHref) {
|
|
240
|
+
if (hasResolved && activeResolvedData !== undefined) {
|
|
241
|
+
staleDataCache.set(activeUrlHref, activeResolvedData)
|
|
242
|
+
evictStaleCache(cacheSize)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (staleDataCache.has(currentUrlHref)) {
|
|
246
|
+
activeResolvedData = staleDataCache.get(currentUrlHref)
|
|
247
|
+
staleDataCache.delete(currentUrlHref)
|
|
248
|
+
hasResolved = true
|
|
249
|
+
} else {
|
|
250
|
+
hasResolved = false
|
|
251
|
+
activeResolvedData = undefined
|
|
252
|
+
}
|
|
253
|
+
pendingBackgroundKey = null
|
|
254
|
+
} else if (activeUrlHref === null && staleDataCache.has(currentUrlHref)) {
|
|
255
|
+
// Remount — restore from stale cache
|
|
256
|
+
activeResolvedData = staleDataCache.get(currentUrlHref)
|
|
257
|
+
staleDataCache.delete(currentUrlHref)
|
|
258
|
+
hasResolved = true
|
|
259
|
+
}
|
|
260
|
+
activeUrlHref = currentUrlHref
|
|
261
|
+
|
|
262
|
+
if (activeCacheKey === cacheKey) {
|
|
263
|
+
// Cache hit — check if background load is pending
|
|
264
|
+
if (pendingBackgroundKey === cacheKey) {
|
|
265
|
+
data = activeResolvedData
|
|
266
|
+
isLoading = true
|
|
267
|
+
} else {
|
|
268
|
+
// Either resolved or first-time suspend
|
|
269
|
+
if (hasResolved) {
|
|
270
|
+
data = activeResolvedData
|
|
271
|
+
} else {
|
|
272
|
+
data = use(activePromise!)
|
|
273
|
+
activeResolvedData = data
|
|
274
|
+
hasResolved = true
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else if (hasResolved) {
|
|
278
|
+
// Cache key changed + we have stale data → background reload (SWR)
|
|
279
|
+
const promise = loaderFn({ params })
|
|
280
|
+
activeCacheKey = cacheKey
|
|
281
|
+
activePromise = promise
|
|
282
|
+
pendingBackgroundKey = cacheKey
|
|
283
|
+
data = activeResolvedData
|
|
284
|
+
isLoading = true
|
|
285
|
+
|
|
286
|
+
promise.then(
|
|
287
|
+
(result) => {
|
|
288
|
+
// Only apply if this is still the active load
|
|
289
|
+
if (activeCacheKey === cacheKey) {
|
|
290
|
+
activeResolvedData = result
|
|
291
|
+
pendingBackgroundKey = null
|
|
292
|
+
forceUpdate()
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
(error) => {
|
|
296
|
+
// Store rejected promise so use() triggers ErrorBoundary
|
|
297
|
+
if (activeCacheKey === cacheKey) {
|
|
298
|
+
activePromise = promise
|
|
299
|
+
hasResolved = false
|
|
300
|
+
pendingBackgroundKey = null
|
|
301
|
+
forceUpdate()
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
} else {
|
|
306
|
+
// No stale data — first load, suspend via use()
|
|
307
|
+
const promise = loaderFn({ params })
|
|
308
|
+
activeCacheKey = cacheKey
|
|
309
|
+
activePromise = promise
|
|
310
|
+
data = use(promise)
|
|
311
|
+
activeResolvedData = data
|
|
312
|
+
hasResolved = true
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// On unmount, save active data to stale cache and reset active state
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
return () => {
|
|
319
|
+
if (hasResolved && activeResolvedData !== undefined && activeUrlHref) {
|
|
320
|
+
staleDataCache.set(activeUrlHref, activeResolvedData)
|
|
321
|
+
evictStaleCache(cacheSize)
|
|
322
|
+
}
|
|
323
|
+
hasResolved = false
|
|
324
|
+
activeResolvedData = undefined
|
|
325
|
+
activeCacheKey = null
|
|
326
|
+
activePromise = null
|
|
327
|
+
pendingBackgroundKey = null
|
|
328
|
+
activeUrlHref = null
|
|
329
|
+
}
|
|
330
|
+
}, [])
|
|
331
|
+
|
|
332
|
+
return <RenderComponent params={params} data={data} invalidate={invalidate} isLoading={isLoading} />
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function BuzolaPageWithCatch(): ReactElement | null {
|
|
336
|
+
const routeContext = use(RouteContext)
|
|
337
|
+
const [retryCount, setRetryCount] = useState(0)
|
|
338
|
+
const retry = useCallback(() => setRetryCount(c => c + 1), [])
|
|
339
|
+
const resetKey = `${routeContext?.state.location.href ?? ''}:${retryCount}`
|
|
340
|
+
const boundFallback = typeof catchHandler === 'function'
|
|
341
|
+
? (error: Error) => (catchHandler as (ctx: CatchContext) => React.ReactNode)({ error, retry })
|
|
342
|
+
: catchHandler
|
|
343
|
+
return (
|
|
344
|
+
<ErrorBoundary fallback={boundFallback} resetKey={resetKey}>
|
|
345
|
+
<BuzolaPage key={retryCount} />
|
|
346
|
+
</ErrorBoundary>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const component = catchHandler ? BuzolaPageWithCatch : BuzolaPage
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
__buzolaPage: true,
|
|
354
|
+
component: component as unknown as RouteComponent,
|
|
355
|
+
route: routePattern,
|
|
356
|
+
paramsSchema: schema as StandardSchema | undefined,
|
|
357
|
+
paramsMeta,
|
|
358
|
+
loader: loaderFn,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Builder ────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
interface WithCatch<TParams, TRenderProps> {
|
|
365
|
+
route<R extends string>(
|
|
366
|
+
pattern: [ExtractRouteParams<R>] extends [keyof TParams & string] ? R : never,
|
|
367
|
+
): { render(fn: ComponentType<TRenderProps>): PageDefinition<TParams> }
|
|
368
|
+
render(fn: ComponentType<TRenderProps>): PageDefinition<TParams>
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface WithRoute<TParams, TRenderProps> {
|
|
372
|
+
catch(handler: CatchHandler): WithCatch<TParams, TRenderProps>
|
|
373
|
+
route<R extends string>(
|
|
374
|
+
pattern: [ExtractRouteParams<R>] extends [keyof TParams & string] ? R : never,
|
|
375
|
+
): {
|
|
376
|
+
catch(handler: CatchHandler): { render(fn: ComponentType<TRenderProps>): PageDefinition<TParams> }
|
|
377
|
+
render(fn: ComponentType<TRenderProps>): PageDefinition<TParams>
|
|
378
|
+
}
|
|
379
|
+
render(fn: ComponentType<TRenderProps>): PageDefinition<TParams>
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface WithLoaderChain<TParams, TData>
|
|
383
|
+
extends WithRoute<TParams, PageProps<TParams> & { data: TData; invalidate: () => void; isLoading: boolean }>
|
|
384
|
+
{
|
|
385
|
+
loader<TNew extends Record<string, unknown>>(
|
|
386
|
+
fn: (ctx: { params: TParams }) => Promise<TNew>,
|
|
387
|
+
): WithLoaderChain<TParams, TData & TNew>
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface WithLoader<TParams> extends WithRoute<TParams, PageProps<TParams>> {
|
|
391
|
+
loader<TData extends Record<string, unknown>>(
|
|
392
|
+
fn: (ctx: { params: TParams }) => Promise<TData>,
|
|
393
|
+
): WithLoaderChain<TParams, TData>
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
interface PageBuilder {
|
|
397
|
+
params<T extends ParamsShape>(shape: T): WithLoader<InferShape<T>>
|
|
398
|
+
params<T extends Record<string, unknown>>(schema: StandardSchema<T>): WithLoader<T>
|
|
399
|
+
loader<TData extends Record<string, unknown>>(
|
|
400
|
+
fn: (ctx: { params: Record<string, never> }) => Promise<TData>,
|
|
401
|
+
): WithLoaderChain<Record<string, never>, TData>
|
|
402
|
+
catch(handler: CatchHandler): WithCatch<Record<string, never>, PageProps<Record<string, never>>>
|
|
403
|
+
route<R extends string>(
|
|
404
|
+
pattern: [ExtractRouteParams<R>] extends [never] ? R : never,
|
|
405
|
+
): {
|
|
406
|
+
catch(handler: CatchHandler): { render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition }
|
|
407
|
+
render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition
|
|
408
|
+
}
|
|
409
|
+
render(fn: ComponentType<PageProps<Record<string, never>>>): PageDefinition
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function withCatchAndRender(
|
|
413
|
+
schema: StandardSchema | undefined,
|
|
414
|
+
paramsMeta: ParamMeta[],
|
|
415
|
+
loaders: LoaderFn[],
|
|
416
|
+
catchHandler: CatchHandler,
|
|
417
|
+
) {
|
|
418
|
+
return {
|
|
419
|
+
route(pattern: string) {
|
|
420
|
+
return {
|
|
421
|
+
render(fn: ComponentType<any>) {
|
|
422
|
+
return createPageComponent(schema, paramsMeta, combineLoaders(loaders), pattern, fn, catchHandler)
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
render(fn: ComponentType<any>) {
|
|
427
|
+
return createPageComponent(schema, paramsMeta, combineLoaders(loaders), undefined, fn, catchHandler)
|
|
428
|
+
},
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function withRouteAndRender(
|
|
433
|
+
schema: StandardSchema | undefined,
|
|
434
|
+
paramsMeta: ParamMeta[],
|
|
435
|
+
loaders: LoaderFn[],
|
|
436
|
+
) {
|
|
437
|
+
return {
|
|
438
|
+
loader(loaderFn: LoaderFn) {
|
|
439
|
+
return withRouteAndRender(schema, paramsMeta, [...loaders, loaderFn])
|
|
440
|
+
},
|
|
441
|
+
catch(handler: CatchHandler) {
|
|
442
|
+
return withCatchAndRender(schema, paramsMeta, loaders, handler)
|
|
443
|
+
},
|
|
444
|
+
route(pattern: string) {
|
|
445
|
+
return {
|
|
446
|
+
catch(handler: CatchHandler) {
|
|
447
|
+
return {
|
|
448
|
+
render(fn: ComponentType<any>) {
|
|
449
|
+
return createPageComponent(schema, paramsMeta, combineLoaders(loaders), pattern, fn, handler)
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
render(fn: ComponentType<any>) {
|
|
454
|
+
return createPageComponent(schema, paramsMeta, combineLoaders(loaders), pattern, fn)
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
render(fn: ComponentType<any>) {
|
|
459
|
+
return createPageComponent(schema, paramsMeta, combineLoaders(loaders), undefined, fn)
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function createPage(): PageBuilder {
|
|
465
|
+
return {
|
|
466
|
+
params(input: StandardSchema | ParamsShape) {
|
|
467
|
+
const { schema, paramsMeta } = resolveParamsInput(input)
|
|
468
|
+
return withRouteAndRender(schema, paramsMeta, [])
|
|
469
|
+
},
|
|
470
|
+
...withRouteAndRender(undefined, [], []),
|
|
471
|
+
} as PageBuilder
|
|
472
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { RouteMatch, RouteNode } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Match a URL against the route tree.
|
|
5
|
+
* Returns an array of RouteMatch from root layout to leaf page, or null if no match.
|
|
6
|
+
* Performs depth-first matching.
|
|
7
|
+
*/
|
|
8
|
+
export function matchRoutes(nodes: RouteNode[], url: URL): RouteMatch[] | null {
|
|
9
|
+
const pathname = url.pathname
|
|
10
|
+
|
|
11
|
+
for (const node of nodes) {
|
|
12
|
+
const result = matchNode(node, pathname)
|
|
13
|
+
if (result) return result
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function matchNode(node: RouteNode, pathname: string): RouteMatch[] | null {
|
|
20
|
+
// Layout nodes: try to match children, prepend self if any child matches
|
|
21
|
+
if (node.isLayout && !node.isIndex) {
|
|
22
|
+
for (const child of node.children) {
|
|
23
|
+
const childMatch = matchNode(child, pathname)
|
|
24
|
+
if (childMatch) {
|
|
25
|
+
// Layout is part of the match chain
|
|
26
|
+
const layoutMatch: RouteMatch = {
|
|
27
|
+
node,
|
|
28
|
+
params: extractLayoutParams(node, pathname),
|
|
29
|
+
pathname,
|
|
30
|
+
}
|
|
31
|
+
return [layoutMatch, ...childMatch]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Leaf/index nodes: try to match the URLPattern
|
|
38
|
+
if (node.pattern) {
|
|
39
|
+
const result = node.pattern.exec({ pathname })
|
|
40
|
+
if (result) {
|
|
41
|
+
const params = extractParamsFromResult(result)
|
|
42
|
+
const match: RouteMatch = { node, params, pathname }
|
|
43
|
+
|
|
44
|
+
// If this node has children (e.g., a page with nested routes), try them too
|
|
45
|
+
if (node.children.length > 0) {
|
|
46
|
+
for (const child of node.children) {
|
|
47
|
+
const childMatch = matchNode(child, pathname)
|
|
48
|
+
if (childMatch) {
|
|
49
|
+
return [match, ...childMatch]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [match]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract params from a URLPattern exec result.
|
|
63
|
+
*/
|
|
64
|
+
function extractParamsFromResult(result: URLPatternResult): Record<string, string> {
|
|
65
|
+
const params: Record<string, string> = {}
|
|
66
|
+
const groups = result.pathname.groups
|
|
67
|
+
for (const [key, value] of Object.entries(groups)) {
|
|
68
|
+
// Skip unnamed groups (numeric keys from wildcards/optionals)
|
|
69
|
+
if (value !== undefined && !/^\d+$/.test(key)) {
|
|
70
|
+
params[key] = value
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return params
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract params from a layout node using its prefix URLPattern.
|
|
78
|
+
* Falls back to empty params if no prefix pattern is available.
|
|
79
|
+
*/
|
|
80
|
+
function extractLayoutParams(node: RouteNode, pathname: string): Record<string, string> {
|
|
81
|
+
if (node.prefixPattern) {
|
|
82
|
+
const result = node.prefixPattern.exec({ pathname })
|
|
83
|
+
if (result) {
|
|
84
|
+
return extractParamsFromResult(result)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {}
|
|
88
|
+
}
|