@pyreon/router 0.3.1 → 0.4.0

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.
@@ -5,12 +5,16 @@ import { Computed, Signal } from "@pyreon/reactivity";
5
5
  //#region src/types.d.ts
6
6
  /**
7
7
  * Extracts typed params from a path string at compile time.
8
+ * Supports optional params via `:param?` — their type is `string | undefined`.
8
9
  *
9
10
  * @example
10
11
  * ExtractParams<'/user/:id/posts/:postId'>
11
12
  * // → { id: string; postId: string }
13
+ *
14
+ * ExtractParams<'/user/:id?'>
15
+ * // → { id?: string | undefined }
12
16
  */
13
- type ExtractParams<T extends string> = T extends `${string}:${infer Param}*/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}*` ? { [K in Param]: string } : T extends `${string}:${infer Param}/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? { [K in Param]: string } : Record<never, never>;
17
+ type ExtractParams<T extends string> = T extends `${string}:${infer Param}*/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}*` ? { [K in Param]: string } : T extends `${string}:${infer Param}?/${infer Rest}` ? { [K in Param]?: string | undefined } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}?` ? { [K in Param]?: string | undefined } : T extends `${string}:${infer Param}/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? { [K in Param]: string } : Record<never, never>;
14
18
  /**
15
19
  * Route metadata interface. Extend it via module augmentation to add custom fields:
16
20
  *
@@ -33,7 +37,7 @@ interface RouteMeta {
33
37
  /** Scroll behavior for this route */
34
38
  scrollBehavior?: "top" | "restore" | "none";
35
39
  }
36
- interface ResolvedRoute<P extends Record<string, string> = Record<string, string>, Q extends Record<string, string> = Record<string, string>> {
40
+ interface ResolvedRoute<P extends Record<string, string | undefined> = Record<string, string>, Q extends Record<string, string> = Record<string, string>> {
37
41
  path: string;
38
42
  params: P;
39
43
  query: Q;
@@ -63,6 +67,15 @@ type RouteComponent = ComponentFn$1 | LazyComponent;
63
67
  type NavigationGuardResult = boolean | string | undefined;
64
68
  type NavigationGuard = (to: ResolvedRoute, from: ResolvedRoute) => NavigationGuardResult | Promise<NavigationGuardResult>;
65
69
  type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute) => void;
70
+ /**
71
+ * Called before each navigation. Return `true` to block, `false` to allow.
72
+ * Async blockers are supported (e.g. to show a confirmation dialog).
73
+ */
74
+ type BlockerFn = (to: ResolvedRoute, from: ResolvedRoute) => boolean | Promise<boolean>;
75
+ interface Blocker {
76
+ /** Unregister this blocker so future navigations proceed freely. */
77
+ remove(): void;
78
+ }
66
79
  interface LoaderContext {
67
80
  params: Record<string, string>;
68
81
  query: Record<string, string>;
@@ -88,6 +101,14 @@ interface RouteRecord<TPath extends string = string> {
88
101
  beforeEnter?: NavigationGuard | NavigationGuard[];
89
102
  /** Guard(s) run before leaving this route. Return false to cancel. */
90
103
  beforeLeave?: NavigationGuard | NavigationGuard[];
104
+ /**
105
+ * Alternative path(s) for this route. Alias paths render the same component
106
+ * and share guards, loaders, and metadata with the primary path.
107
+ *
108
+ * @example
109
+ * { path: "/user/:id", alias: ["/profile/:id"], component: UserPage }
110
+ */
111
+ alias?: string | string[];
91
112
  /** Child routes rendered inside this route's component via <RouterView /> */
92
113
  children?: RouteRecord[];
93
114
  /**
@@ -96,6 +117,12 @@ interface RouteRecord<TPath extends string = string> {
96
117
  * Receives an AbortSignal that fires if a newer navigation supersedes this one.
97
118
  */
98
119
  loader?: RouteLoaderFn;
120
+ /**
121
+ * When true, the router shows cached loader data immediately (stale) and
122
+ * revalidates in the background. The component re-renders once fresh data arrives.
123
+ * Only applies when navigating to a route that already has cached loader data.
124
+ */
125
+ staleWhileRevalidate?: boolean;
99
126
  /** Component rendered when this route's loader throws an error */
100
127
  errorComponent?: ComponentFn$1;
101
128
  }
@@ -104,6 +131,13 @@ interface RouterOptions {
104
131
  routes: RouteRecord[];
105
132
  /** "hash" (default) uses location.hash; "history" uses pushState */
106
133
  mode?: "hash" | "history";
134
+ /**
135
+ * Base path for the application. Used when deploying to a sub-path
136
+ * (e.g. `"/app"` for `https://example.com/app/`).
137
+ * Only applies in history mode. Must start with `/`.
138
+ * Default: `""` (no base path).
139
+ */
140
+ base?: string;
107
141
  /**
108
142
  * Global scroll behavior. Per-route meta.scrollBehavior takes precedence.
109
143
  * Default: "top"
@@ -130,6 +164,13 @@ interface RouterOptions {
130
164
  * Default: 100.
131
165
  */
132
166
  maxCacheSize?: number;
167
+ /**
168
+ * Trailing slash handling:
169
+ * - `"strip"` — removes trailing slashes before matching (default)
170
+ * - `"add"` — ensures paths always end with `/`
171
+ * - `"ignore"` — no normalization
172
+ */
173
+ trailingSlash?: "strip" | "add" | "ignore";
133
174
  }
134
175
  interface Router {
135
176
  /** Navigate to a path */
@@ -142,8 +183,18 @@ interface Router {
142
183
  }): Promise<void>;
143
184
  /** Replace current history entry */
144
185
  replace(path: string): Promise<void>;
145
- /** Go back */
186
+ /** Replace current history entry using a named route */
187
+ replace(location: {
188
+ name: string;
189
+ params?: Record<string, string>;
190
+ query?: Record<string, string>;
191
+ }): Promise<void>;
192
+ /** Go back one step in history */
146
193
  back(): void;
194
+ /** Go forward one step in history */
195
+ forward(): void;
196
+ /** Navigate forward or backward by `delta` steps in the history stack */
197
+ go(delta: number): void;
147
198
  /** Register a global before-navigation guard. Returns an unregister function. */
148
199
  beforeEach(guard: NavigationGuard): () => void;
149
200
  /** Register a global after-navigation hook. Returns an unregister function. */
@@ -152,12 +203,19 @@ interface Router {
152
203
  readonly currentRoute: () => ResolvedRoute;
153
204
  /** True while a navigation (guards + loaders) is in flight */
154
205
  readonly loading: () => boolean;
206
+ /**
207
+ * Promise that resolves once the initial navigation is complete.
208
+ * Useful for SSR and for delaying rendering until the first route is resolved.
209
+ */
210
+ isReady(): Promise<void>;
155
211
  /** Remove all event listeners, clear caches, and abort in-flight navigations. */
156
212
  destroy(): void;
157
213
  }
158
214
  interface RouterInstance extends Router {
159
215
  routes: RouteRecord[];
160
216
  mode: "hash" | "history";
217
+ /** Normalized base path (e.g. "/app"), empty string if none */
218
+ _base: string;
161
219
  _currentPath: Signal<string>;
162
220
  _currentRoute: Computed<ResolvedRoute>;
163
221
  _componentCache: Map<RouteRecord, ComponentFn$1>;
@@ -179,6 +237,12 @@ interface RouterInstance extends Router {
179
237
  _loaderData: Map<RouteRecord, unknown>;
180
238
  /** AbortController for the in-flight loader batch — aborted when a newer navigation starts */
181
239
  _abortController: AbortController | null;
240
+ /** Registered navigation blockers */
241
+ _blockers: Set<BlockerFn>;
242
+ /** Resolves the isReady() promise after initial navigation completes */
243
+ _readyResolve: (() => void) | null;
244
+ /** The isReady() promise instance */
245
+ _readyPromise: Promise<void>;
182
246
  }
183
247
  //#endregion
184
248
  //#region src/components.d.ts
@@ -315,8 +379,57 @@ declare function findRouteByName(name: string, routes: RouteRecord[]): RouteReco
315
379
  //#region src/router.d.ts
316
380
  declare const RouterContext: _pyreon_core0.Context<RouterInstance | null>;
317
381
  declare function useRouter(): Router;
318
- declare function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath>, Record<string, string>>;
382
+ declare function useRoute<TPath extends string = string>(): () => ResolvedRoute<ExtractParams<TPath> & Record<string, string>, Record<string, string>>;
383
+ /**
384
+ * In-component guard: called before the component's route is left.
385
+ * Return `false` to cancel, a string to redirect, or `undefined`/`true` to proceed.
386
+ * Automatically removed on component unmount.
387
+ *
388
+ * @example
389
+ * onBeforeRouteLeave((to, from) => {
390
+ * if (hasUnsavedChanges()) return false
391
+ * })
392
+ */
393
+ declare function onBeforeRouteLeave(guard: NavigationGuard): () => void;
394
+ /**
395
+ * In-component guard: called when the route changes but the component is reused
396
+ * (e.g. `/user/1` → `/user/2`). Useful for reacting to param changes.
397
+ * Automatically removed on component unmount.
398
+ *
399
+ * @example
400
+ * onBeforeRouteUpdate((to, from) => {
401
+ * if (!isValidId(to.params.id)) return false
402
+ * })
403
+ */
404
+ declare function onBeforeRouteUpdate(guard: NavigationGuard): () => void;
405
+ /**
406
+ * Register a navigation blocker. The `fn` callback is called before each
407
+ * navigation — return `true` (or resolve to `true`) to block it.
408
+ *
409
+ * Automatically removed on component unmount if called during component setup.
410
+ * Also installs a `beforeunload` handler so the browser shows a confirmation
411
+ * dialog when the user tries to close the tab while a blocker is active.
412
+ *
413
+ * @example
414
+ * const blocker = useBlocker((to, from) => {
415
+ * return hasUnsavedChanges() && !confirm("Discard changes?")
416
+ * })
417
+ * // later: blocker.remove()
418
+ */
419
+ declare function useBlocker(fn: BlockerFn): Blocker;
420
+ /**
421
+ * Reactive read/write access to the current route's query parameters.
422
+ *
423
+ * Returns `[get, set]` where `get` is a reactive signal producing the merged
424
+ * query object and `set` navigates to the current path with updated params.
425
+ *
426
+ * @example
427
+ * const [params, setParams] = useSearchParams({ page: "1", sort: "name" })
428
+ * params().page // "1" if not in URL
429
+ * setParams({ page: "2" }) // navigates to ?page=2&sort=name
430
+ */
431
+ declare function useSearchParams<T extends Record<string, string>>(defaults?: T): [get: () => T, set: (updates: Partial<T>) => Promise<void>];
319
432
  declare function createRouter(options: RouterOptions | RouteRecord[]): Router;
320
433
  //#endregion
321
- export { type AfterEachHook, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useLoaderData, useRoute, useRouter };
434
+ export { type AfterEachHook, type Blocker, type BlockerFn, type ExtractParams, type LazyComponent, type LoaderContext, type NavigationGuard, type NavigationGuardResult, type ResolvedRoute, type RouteComponent, type RouteLoaderFn, type RouteMeta, type RouteRecord, type Router, RouterContext, RouterLink, type RouterLinkProps, type RouterOptions, RouterProvider, type RouterProviderProps, RouterView, type RouterViewProps, type ScrollBehaviorFn, buildPath, createRouter, findRouteByName, hydrateLoaderData, lazy, onBeforeRouteLeave, onBeforeRouteUpdate, parseQuery, parseQueryMulti, prefetchLoaderData, resolveRoute, serializeLoaderData, stringifyQuery, useBlocker, useLoaderData, useRoute, useRouter, useSearchParams };
322
435
  //# sourceMappingURL=index2.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/types.ts","../../src/components.tsx","../../src/loader.ts","../../src/match.ts","../../src/router.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/types.ts","../../src/components.tsx","../../src/loader.ts","../../src/match.ts","../../src/router.ts"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/router",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Official router for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,9 +39,9 @@
39
39
  "prepublishOnly": "bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@pyreon/core": "^0.3.1",
43
- "@pyreon/reactivity": "^0.3.1",
44
- "@pyreon/runtime-dom": "^0.3.1"
42
+ "@pyreon/core": "^0.4.0",
43
+ "@pyreon/reactivity": "^0.4.0",
44
+ "@pyreon/runtime-dom": "^0.4.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@happy-dom/global-registrator": "^20.8.3",
@@ -146,7 +146,8 @@ export const RouterLink: ComponentFn<RouterLinkProps> = (props) => {
146
146
  prefetchRoute(router as RouterInstance, props.to)
147
147
  }
148
148
 
149
- const href = router?.mode === "history" ? props.to : `#${props.to}`
149
+ const inst = router as RouterInstance | null
150
+ const href = inst?.mode === "history" ? `${inst._base}${props.to}` : `#${props.to}`
150
151
 
151
152
  const activeClass = (): string => {
152
153
  if (!router) return ""
package/src/index.ts CHANGED
@@ -55,11 +55,22 @@ export {
55
55
  stringifyQuery,
56
56
  } from "./match"
57
57
  // Router factory + hooks
58
- export { createRouter, RouterContext, useRoute, useRouter } from "./router"
58
+ export {
59
+ createRouter,
60
+ onBeforeRouteLeave,
61
+ onBeforeRouteUpdate,
62
+ RouterContext,
63
+ useBlocker,
64
+ useRoute,
65
+ useRouter,
66
+ useSearchParams,
67
+ } from "./router"
59
68
  // Types
60
69
  // Data loaders
61
70
  export type {
62
71
  AfterEachHook,
72
+ Blocker,
73
+ BlockerFn,
63
74
  ExtractParams,
64
75
  LazyComponent,
65
76
  LoaderContext,
package/src/match.ts CHANGED
@@ -78,7 +78,9 @@ interface CompiledSegment {
78
78
  isParam: boolean
79
79
  /** true if this segment is a `:param*` splat */
80
80
  isSplat: boolean
81
- /** Param name (without leading `:` and trailing `*`) — empty for static segments */
81
+ /** true if this segment is a `:param?` optional */
82
+ isOptional: boolean
83
+ /** Param name (without leading `:` and trailing `*`/`?`) — empty for static segments */
82
84
  paramName: string
83
85
  }
84
86
 
@@ -122,6 +124,10 @@ interface FlattenedRoute {
122
124
  hasSplat: boolean
123
125
  /** true if this is a wildcard catch-all route (`*` or `(.*)`) */
124
126
  isWildcard: boolean
127
+ /** true if any segment is optional (`:param?`) */
128
+ hasOptional: boolean
129
+ /** Minimum number of segments that must be present (excluding trailing optionals) */
130
+ minSegments: number
125
131
  }
126
132
 
127
133
  /** WeakMap cache: compile each RouteRecord[] once */
@@ -129,12 +135,15 @@ const _compiledCache = new WeakMap<RouteRecord[], CompiledRoute[]>()
129
135
 
130
136
  function compileSegment(raw: string): CompiledSegment {
131
137
  if (raw.endsWith("*") && raw.startsWith(":")) {
132
- return { raw, isParam: true, isSplat: true, paramName: raw.slice(1, -1) }
138
+ return { raw, isParam: true, isSplat: true, isOptional: false, paramName: raw.slice(1, -1) }
139
+ }
140
+ if (raw.endsWith("?") && raw.startsWith(":")) {
141
+ return { raw, isParam: true, isSplat: false, isOptional: true, paramName: raw.slice(1, -1) }
133
142
  }
134
143
  if (raw.startsWith(":")) {
135
- return { raw, isParam: true, isSplat: false, paramName: raw.slice(1) }
144
+ return { raw, isParam: true, isSplat: false, isOptional: false, paramName: raw.slice(1) }
136
145
  }
137
- return { raw, isParam: false, isSplat: false, paramName: "" }
146
+ return { raw, isParam: false, isSplat: false, isOptional: false, paramName: "" }
138
147
  }
139
148
 
140
149
  function compileRoute(route: RouteRecord): CompiledRoute {
@@ -172,17 +181,32 @@ function compileRoute(route: RouteRecord): CompiledRoute {
172
181
  }
173
182
  }
174
183
 
184
+ /** Expand alias paths into additional compiled entries sharing the original RouteRecord */
185
+ function expandAliases(r: RouteRecord, c: CompiledRoute): CompiledRoute[] {
186
+ if (!r.alias) return []
187
+ const aliases = Array.isArray(r.alias) ? r.alias : [r.alias]
188
+ return aliases.map((aliasPath) => {
189
+ const { alias: _, ...withoutAlias } = r
190
+ const ac = compileRoute({ ...withoutAlias, path: aliasPath })
191
+ ac.children = c.children
192
+ ac.route = r
193
+ return ac
194
+ })
195
+ }
196
+
175
197
  function compileRoutes(routes: RouteRecord[]): CompiledRoute[] {
176
198
  const cached = _compiledCache.get(routes)
177
199
  if (cached) return cached
178
200
 
179
- const compiled = routes.map((r) => {
201
+ const compiled: CompiledRoute[] = []
202
+ for (const r of routes) {
180
203
  const c = compileRoute(r)
181
204
  if (r.children && r.children.length > 0) {
182
205
  c.children = compileRoutes(r.children)
183
206
  }
184
- return c
185
- })
207
+ compiled.push(c)
208
+ compiled.push(...expandAliases(r, c))
209
+ }
186
210
  _compiledCache.set(routes, compiled)
187
211
  return compiled
188
212
  }
@@ -204,6 +228,12 @@ function makeFlatEntry(
204
228
  isWildcard: boolean,
205
229
  ): FlattenedRoute {
206
230
  const isStatic = !isWildcard && segments.every((s) => !s.isParam)
231
+ const hasOptional = segments.some((s) => s.isOptional)
232
+ // minSegments: count of segments up to and not including trailing optionals
233
+ let minSegs = segments.length
234
+ if (hasOptional) {
235
+ while (minSegs > 0 && segments[minSegs - 1]?.isOptional) minSegs--
236
+ }
207
237
  return {
208
238
  segments,
209
239
  segmentCount: segments.length,
@@ -214,6 +244,8 @@ function makeFlatEntry(
214
244
  firstSegment: getFirstSegment(segments),
215
245
  hasSplat: segments.some((s) => s.isSplat),
216
246
  isWildcard,
247
+ hasOptional,
248
+ minSegments: minSegs,
217
249
  }
218
250
  }
219
251
 
@@ -372,8 +404,31 @@ function decodeSafe(s: string): string {
372
404
  * - Param segments: "/user/:id"
373
405
  * - Wildcard: "(.*)" matches everything
374
406
  */
407
+ /** Match a single pattern segment against a path segment, extracting params. Returns false on mismatch. */
408
+ function matchPatternSegment(
409
+ pp: string,
410
+ pt: string | undefined,
411
+ params: Record<string, string>,
412
+ pathParts: string[],
413
+ i: number,
414
+ ): "splat" | "continue" | "fail" {
415
+ if (pp.endsWith("*") && pp.startsWith(":")) {
416
+ params[pp.slice(1, -1)] = pathParts.slice(i).map(decodeURIComponent).join("/")
417
+ return "splat"
418
+ }
419
+ if (pp.endsWith("?") && pp.startsWith(":")) {
420
+ if (pt !== undefined) params[pp.slice(1, -1)] = decodeURIComponent(pt)
421
+ return "continue"
422
+ }
423
+ if (pt === undefined) return "fail"
424
+ if (pp.startsWith(":")) {
425
+ params[pp.slice(1)] = decodeURIComponent(pt)
426
+ return "continue"
427
+ }
428
+ return pp === pt ? "continue" : "fail"
429
+ }
430
+
375
431
  export function matchPath(pattern: string, path: string): Record<string, string> | null {
376
- // Wildcard pattern
377
432
  if (pattern === "(.*)" || pattern === "*") return {}
378
433
 
379
434
  const patternParts = pattern.split("/").filter(Boolean)
@@ -381,22 +436,18 @@ export function matchPath(pattern: string, path: string): Record<string, string>
381
436
 
382
437
  const params: Record<string, string> = {}
383
438
  for (let i = 0; i < patternParts.length; i++) {
384
- const pp = patternParts[i] as string
385
- const pt = pathParts[i] as string
386
- // Splat param — captures the rest of the path (e.g. ":path*")
387
- if (pp.endsWith("*") && pp.startsWith(":")) {
388
- const paramName = pp.slice(1, -1)
389
- params[paramName] = pathParts.slice(i).map(decodeURIComponent).join("/")
390
- return params
391
- }
392
- if (pp.startsWith(":")) {
393
- params[pp.slice(1)] = decodeURIComponent(pt)
394
- } else if (pp !== pt) {
395
- return null
396
- }
439
+ const result = matchPatternSegment(
440
+ patternParts[i] as string,
441
+ pathParts[i],
442
+ params,
443
+ pathParts,
444
+ i,
445
+ )
446
+ if (result === "splat") return params
447
+ if (result === "fail") return null
397
448
  }
398
449
 
399
- if (patternParts.length !== pathParts.length) return null
450
+ if (pathParts.length > patternParts.length) return null
400
451
  return params
401
452
  }
402
453
 
@@ -414,16 +465,21 @@ function captureSplat(pathParts: string[], from: number, pathLen: number): strin
414
465
 
415
466
  // ─── Flattened route matching ─────────────────────────────────────────────────
416
467
 
468
+ /** Check whether a flattened route's segment count is compatible with the path length */
469
+ function isSegmentCountCompatible(f: FlattenedRoute, pathLen: number): boolean {
470
+ if (f.segmentCount === pathLen) return true
471
+ if (f.hasSplat && pathLen >= f.segmentCount) return true
472
+ if (f.hasOptional && pathLen >= f.minSegments && pathLen <= f.segmentCount) return true
473
+ return false
474
+ }
475
+
417
476
  /** Try to match a flattened route against path parts */
418
477
  function matchFlattened(
419
478
  f: FlattenedRoute,
420
479
  pathParts: string[],
421
480
  pathLen: number,
422
481
  ): Record<string, string> | null {
423
- if (f.segmentCount !== pathLen) {
424
- // Could still match if route has a splat
425
- if (!f.hasSplat || pathLen < f.segmentCount) return null
426
- }
482
+ if (!isSegmentCountCompatible(f, pathLen)) return null
427
483
 
428
484
  const params: Record<string, string> = {}
429
485
  const segments = f.segments
@@ -431,11 +487,15 @@ function matchFlattened(
431
487
  for (let i = 0; i < count; i++) {
432
488
  const seg = segments[i]
433
489
  const pt = pathParts[i]
434
- if (!seg || pt === undefined) return null
490
+ if (!seg) return null
435
491
  if (seg.isSplat) {
436
492
  params[seg.paramName] = captureSplat(pathParts, i, pathLen)
437
493
  return params
438
494
  }
495
+ if (pt === undefined) {
496
+ if (!seg.isOptional) return null
497
+ continue
498
+ }
439
499
  if (seg.isParam) {
440
500
  params[seg.paramName] = decodeSafe(pt)
441
501
  } else if (seg.raw !== pt) {
@@ -564,7 +624,13 @@ function mergeMeta(matched: RouteRecord[]): RouteMeta {
564
624
 
565
625
  /** Build a path string from a named route's pattern and params */
566
626
  export function buildPath(pattern: string, params: Record<string, string>): string {
567
- return pattern.replace(/:([^/]+)\*?/g, (match, key) => {
627
+ const built = pattern.replace(/\/:([^/]+)\?/g, (_match, key) => {
628
+ const val = params[key]
629
+ // Optional param — omit the entire segment if no value provided
630
+ if (!val) return ""
631
+ return `/${encodeURIComponent(val)}`
632
+ })
633
+ return built.replace(/:([^/]+)\*?/g, (match, key) => {
568
634
  const val = params[key] ?? ""
569
635
  // Splat params contain slashes — don't encode them
570
636
  if (match.endsWith("*")) return val.split("/").map(encodeURIComponent).join("/")