@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,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
+ }