@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.
Files changed (114) hide show
  1. package/dist/__tests__/create-page.test.d.ts +2 -0
  2. package/dist/__tests__/create-page.test.d.ts.map +1 -0
  3. package/dist/__tests__/create-page.test.js +457 -0
  4. package/dist/__tests__/create-page.test.js.map +1 -0
  5. package/dist/__tests__/define-routes.test.d.ts +2 -0
  6. package/dist/__tests__/define-routes.test.d.ts.map +1 -0
  7. package/dist/__tests__/define-routes.test.js +42 -0
  8. package/dist/__tests__/define-routes.test.js.map +1 -0
  9. package/dist/__tests__/matcher.test.d.ts +2 -0
  10. package/dist/__tests__/matcher.test.d.ts.map +1 -0
  11. package/dist/__tests__/matcher.test.js +108 -0
  12. package/dist/__tests__/matcher.test.js.map +1 -0
  13. package/dist/__tests__/router.test.d.ts +2 -0
  14. package/dist/__tests__/router.test.d.ts.map +1 -0
  15. package/dist/__tests__/router.test.js +327 -0
  16. package/dist/__tests__/router.test.js.map +1 -0
  17. package/dist/__tests__/type-safety.test.d.ts +15 -0
  18. package/dist/__tests__/type-safety.test.d.ts.map +1 -0
  19. package/dist/__tests__/type-safety.test.js +163 -0
  20. package/dist/__tests__/type-safety.test.js.map +1 -0
  21. package/dist/__tests__/types.test.d.ts +2 -0
  22. package/dist/__tests__/types.test.d.ts.map +1 -0
  23. package/dist/__tests__/types.test.js +22 -0
  24. package/dist/__tests__/types.test.js.map +1 -0
  25. package/dist/define/create-page.d.ts +87 -0
  26. package/dist/define/create-page.d.ts.map +1 -0
  27. package/dist/define/create-page.js +305 -0
  28. package/dist/define/create-page.js.map +1 -0
  29. package/dist/define/define-routes.d.ts +34 -0
  30. package/dist/define/define-routes.d.ts.map +1 -0
  31. package/dist/define/define-routes.js +53 -0
  32. package/dist/define/define-routes.js.map +1 -0
  33. package/dist/engine/matcher.d.ts +8 -0
  34. package/dist/engine/matcher.d.ts.map +1 -0
  35. package/dist/engine/matcher.js +79 -0
  36. package/dist/engine/matcher.js.map +1 -0
  37. package/dist/engine/navigation-adapter.d.ts +13 -0
  38. package/dist/engine/navigation-adapter.d.ts.map +1 -0
  39. package/dist/engine/navigation-adapter.js +156 -0
  40. package/dist/engine/navigation-adapter.js.map +1 -0
  41. package/dist/engine/route-tree.d.ts +21 -0
  42. package/dist/engine/route-tree.d.ts.map +1 -0
  43. package/dist/engine/route-tree.js +95 -0
  44. package/dist/engine/route-tree.js.map +1 -0
  45. package/dist/engine/router.d.ts +162 -0
  46. package/dist/engine/router.d.ts.map +1 -0
  47. package/dist/engine/router.js +400 -0
  48. package/dist/engine/router.js.map +1 -0
  49. package/dist/engine/schema.d.ts +46 -0
  50. package/dist/engine/schema.d.ts.map +1 -0
  51. package/dist/engine/schema.js +110 -0
  52. package/dist/engine/schema.js.map +1 -0
  53. package/dist/engine/types.d.ts +183 -0
  54. package/dist/engine/types.d.ts.map +1 -0
  55. package/dist/engine/types.js +2 -0
  56. package/dist/engine/types.js.map +1 -0
  57. package/dist/engine/utils.d.ts +7 -0
  58. package/dist/engine/utils.d.ts.map +1 -0
  59. package/dist/engine/utils.js +17 -0
  60. package/dist/engine/utils.js.map +1 -0
  61. package/dist/index.d.ts +14 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +12 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/react/context.d.ts +21 -0
  66. package/dist/react/context.d.ts.map +1 -0
  67. package/dist/react/context.js +7 -0
  68. package/dist/react/context.js.map +1 -0
  69. package/dist/react/error-boundary.d.ts +24 -0
  70. package/dist/react/error-boundary.d.ts.map +1 -0
  71. package/dist/react/error-boundary.js +37 -0
  72. package/dist/react/error-boundary.js.map +1 -0
  73. package/dist/react/hooks.d.ts +85 -0
  74. package/dist/react/hooks.d.ts.map +1 -0
  75. package/dist/react/hooks.js +161 -0
  76. package/dist/react/hooks.js.map +1 -0
  77. package/dist/react/index.d.ts +12 -0
  78. package/dist/react/index.d.ts.map +1 -0
  79. package/dist/react/index.js +7 -0
  80. package/dist/react/index.js.map +1 -0
  81. package/dist/react/link.d.ts +42 -0
  82. package/dist/react/link.d.ts.map +1 -0
  83. package/dist/react/link.js +38 -0
  84. package/dist/react/link.js.map +1 -0
  85. package/dist/react/outlet.d.ts +13 -0
  86. package/dist/react/outlet.d.ts.map +1 -0
  87. package/dist/react/outlet.js +46 -0
  88. package/dist/react/outlet.js.map +1 -0
  89. package/dist/react/provider.d.ts +22 -0
  90. package/dist/react/provider.d.ts.map +1 -0
  91. package/dist/react/provider.js +37 -0
  92. package/dist/react/provider.js.map +1 -0
  93. package/package.json +29 -0
  94. package/src/__tests__/create-page.test.ts +588 -0
  95. package/src/__tests__/matcher.test.ts +127 -0
  96. package/src/__tests__/router.test.ts +424 -0
  97. package/src/__tests__/type-safety.test.tsx +207 -0
  98. package/src/define/create-page.tsx +472 -0
  99. package/src/engine/matcher.ts +88 -0
  100. package/src/engine/navigation-adapter.ts +191 -0
  101. package/src/engine/route-tree.ts +129 -0
  102. package/src/engine/router.ts +471 -0
  103. package/src/engine/schema.ts +130 -0
  104. package/src/engine/types.ts +203 -0
  105. package/src/engine/utils.ts +16 -0
  106. package/src/env.d.ts +120 -0
  107. package/src/index.ts +61 -0
  108. package/src/react/context.ts +24 -0
  109. package/src/react/error-boundary.tsx +56 -0
  110. package/src/react/hooks.ts +211 -0
  111. package/src/react/index.ts +16 -0
  112. package/src/react/link.tsx +93 -0
  113. package/src/react/outlet.tsx +67 -0
  114. 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
+ }