@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43

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 (174) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +126 -38
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1171 -461
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +19 -16
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +45 -4
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +28 -20
  12. package/skills/intercept/SKILL.md +20 -0
  13. package/skills/layout/SKILL.md +22 -0
  14. package/skills/links/SKILL.md +91 -17
  15. package/skills/loader/SKILL.md +88 -45
  16. package/skills/middleware/SKILL.md +34 -3
  17. package/skills/migrate-nextjs/SKILL.md +560 -0
  18. package/skills/migrate-react-router/SKILL.md +765 -0
  19. package/skills/parallel/SKILL.md +185 -0
  20. package/skills/prerender/SKILL.md +110 -68
  21. package/skills/rango/SKILL.md +24 -22
  22. package/skills/response-routes/SKILL.md +8 -0
  23. package/skills/route/SKILL.md +55 -0
  24. package/skills/router-setup/SKILL.md +87 -2
  25. package/skills/streams-and-websockets/SKILL.md +283 -0
  26. package/skills/typesafety/SKILL.md +13 -1
  27. package/src/__internal.ts +1 -1
  28. package/src/browser/app-shell.ts +52 -0
  29. package/src/browser/app-version.ts +14 -0
  30. package/src/browser/event-controller.ts +5 -0
  31. package/src/browser/navigation-bridge.ts +90 -16
  32. package/src/browser/navigation-client.ts +167 -59
  33. package/src/browser/navigation-store.ts +68 -9
  34. package/src/browser/navigation-transaction.ts +11 -9
  35. package/src/browser/partial-update.ts +113 -17
  36. package/src/browser/prefetch/cache.ts +184 -16
  37. package/src/browser/prefetch/fetch.ts +180 -33
  38. package/src/browser/prefetch/policy.ts +6 -0
  39. package/src/browser/prefetch/queue.ts +123 -20
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +81 -9
  43. package/src/browser/react/NavigationProvider.tsx +89 -14
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/use-handle.ts +9 -58
  46. package/src/browser/react/use-navigation.ts +22 -2
  47. package/src/browser/react/use-params.ts +11 -1
  48. package/src/browser/react/use-router.ts +29 -9
  49. package/src/browser/rsc-router.tsx +168 -65
  50. package/src/browser/scroll-restoration.ts +41 -42
  51. package/src/browser/segment-reconciler.ts +36 -9
  52. package/src/browser/server-action-bridge.ts +8 -6
  53. package/src/browser/types.ts +49 -5
  54. package/src/build/generate-manifest.ts +6 -6
  55. package/src/build/generate-route-types.ts +3 -0
  56. package/src/build/route-trie.ts +50 -24
  57. package/src/build/route-types/include-resolution.ts +8 -1
  58. package/src/build/route-types/router-processing.ts +223 -74
  59. package/src/build/route-types/scan-filter.ts +8 -1
  60. package/src/cache/cache-runtime.ts +15 -11
  61. package/src/cache/cache-scope.ts +48 -7
  62. package/src/cache/cf/cf-cache-store.ts +455 -15
  63. package/src/cache/cf/index.ts +5 -1
  64. package/src/cache/document-cache.ts +17 -7
  65. package/src/cache/index.ts +1 -0
  66. package/src/cache/taint.ts +55 -0
  67. package/src/client.tsx +84 -230
  68. package/src/context-var.ts +72 -2
  69. package/src/debug.ts +2 -2
  70. package/src/handle.ts +40 -0
  71. package/src/index.rsc.ts +6 -1
  72. package/src/index.ts +49 -6
  73. package/src/outlet-context.ts +1 -1
  74. package/src/prerender/store.ts +5 -4
  75. package/src/prerender.ts +138 -77
  76. package/src/response-utils.ts +28 -0
  77. package/src/reverse.ts +27 -2
  78. package/src/route-definition/dsl-helpers.ts +240 -40
  79. package/src/route-definition/helpers-types.ts +67 -19
  80. package/src/route-definition/index.ts +3 -0
  81. package/src/route-definition/redirect.ts +11 -3
  82. package/src/route-definition/resolve-handler-use.ts +155 -0
  83. package/src/route-map-builder.ts +7 -1
  84. package/src/route-types.ts +18 -0
  85. package/src/router/content-negotiation.ts +100 -1
  86. package/src/router/find-match.ts +4 -2
  87. package/src/router/handler-context.ts +101 -25
  88. package/src/router/intercept-resolution.ts +11 -4
  89. package/src/router/lazy-includes.ts +10 -7
  90. package/src/router/loader-resolution.ts +159 -21
  91. package/src/router/logging.ts +5 -2
  92. package/src/router/manifest.ts +31 -16
  93. package/src/router/match-api.ts +127 -192
  94. package/src/router/match-middleware/background-revalidation.ts +30 -2
  95. package/src/router/match-middleware/cache-lookup.ts +94 -17
  96. package/src/router/match-middleware/cache-store.ts +53 -10
  97. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  98. package/src/router/match-middleware/segment-resolution.ts +61 -5
  99. package/src/router/match-result.ts +104 -10
  100. package/src/router/metrics.ts +6 -1
  101. package/src/router/middleware-types.ts +8 -30
  102. package/src/router/middleware.ts +36 -10
  103. package/src/router/navigation-snapshot.ts +182 -0
  104. package/src/router/pattern-matching.ts +60 -9
  105. package/src/router/prerender-match.ts +110 -10
  106. package/src/router/preview-match.ts +30 -102
  107. package/src/router/request-classification.ts +310 -0
  108. package/src/router/route-snapshot.ts +245 -0
  109. package/src/router/router-context.ts +6 -1
  110. package/src/router/router-interfaces.ts +36 -4
  111. package/src/router/router-options.ts +37 -11
  112. package/src/router/segment-resolution/fresh.ts +198 -20
  113. package/src/router/segment-resolution/helpers.ts +29 -24
  114. package/src/router/segment-resolution/loader-cache.ts +1 -0
  115. package/src/router/segment-resolution/revalidation.ts +438 -300
  116. package/src/router/segment-wrappers.ts +2 -0
  117. package/src/router/trie-matching.ts +10 -4
  118. package/src/router/types.ts +1 -0
  119. package/src/router/url-params.ts +49 -0
  120. package/src/router.ts +60 -8
  121. package/src/rsc/handler.ts +478 -374
  122. package/src/rsc/helpers.ts +69 -41
  123. package/src/rsc/loader-fetch.ts +23 -3
  124. package/src/rsc/manifest-init.ts +5 -1
  125. package/src/rsc/progressive-enhancement.ts +16 -2
  126. package/src/rsc/response-route-handler.ts +14 -1
  127. package/src/rsc/rsc-rendering.ts +19 -1
  128. package/src/rsc/server-action.ts +10 -0
  129. package/src/rsc/ssr-setup.ts +2 -2
  130. package/src/rsc/types.ts +9 -1
  131. package/src/segment-content-promise.ts +67 -0
  132. package/src/segment-loader-promise.ts +122 -0
  133. package/src/segment-system.tsx +109 -23
  134. package/src/server/context.ts +166 -17
  135. package/src/server/handle-store.ts +19 -0
  136. package/src/server/loader-registry.ts +9 -8
  137. package/src/server/request-context.ts +194 -60
  138. package/src/ssr/index.tsx +4 -0
  139. package/src/static-handler.ts +18 -6
  140. package/src/types/cache-types.ts +4 -4
  141. package/src/types/handler-context.ts +137 -65
  142. package/src/types/loader-types.ts +41 -15
  143. package/src/types/request-scope.ts +126 -0
  144. package/src/types/route-entry.ts +19 -1
  145. package/src/types/segments.ts +2 -0
  146. package/src/urls/include-helper.ts +24 -14
  147. package/src/urls/path-helper-types.ts +39 -6
  148. package/src/urls/path-helper.ts +48 -13
  149. package/src/urls/pattern-types.ts +12 -0
  150. package/src/urls/response-types.ts +18 -16
  151. package/src/use-loader.tsx +77 -5
  152. package/src/vite/debug.ts +55 -0
  153. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  154. package/src/vite/discovery/discover-routers.ts +5 -1
  155. package/src/vite/discovery/prerender-collection.ts +128 -74
  156. package/src/vite/discovery/state.ts +13 -6
  157. package/src/vite/index.ts +4 -0
  158. package/src/vite/plugin-types.ts +51 -79
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  160. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  161. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  162. package/src/vite/plugins/expose-action-id.ts +1 -3
  163. package/src/vite/plugins/expose-id-utils.ts +12 -0
  164. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  165. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  166. package/src/vite/plugins/performance-tracks.ts +86 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/version-plugin.ts +13 -1
  169. package/src/vite/rango.ts +204 -217
  170. package/src/vite/router-discovery.ts +335 -64
  171. package/src/vite/utils/banner.ts +4 -4
  172. package/src/vite/utils/package-resolution.ts +41 -1
  173. package/src/vite/utils/prerender-utils.ts +37 -5
  174. package/src/vite/utils/shared-utils.ts +3 -2
@@ -29,6 +29,7 @@ import {
29
29
  } from "./response-adapter.js";
30
30
  import { mergeLocationState } from "./history-state.js";
31
31
  import { classifyActionOutcome } from "./action-coordinator.js";
32
+ import { getAppVersion } from "./app-version.js";
32
33
 
33
34
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
34
35
  if (typeof Symbol.dispose === "undefined") {
@@ -43,8 +44,6 @@ if (typeof Symbol.asyncDispose === "undefined") {
43
44
  */
44
45
  export interface ServerActionBridgeConfigWithController extends ServerActionBridgeConfig {
45
46
  eventController: EventController;
46
- /** RSC version from initial payload metadata */
47
- version?: string;
48
47
  /** Callback to trigger SPA navigation (for action redirects) */
49
48
  onNavigate?: (
50
49
  url: string,
@@ -75,7 +74,6 @@ export function createServerActionBridge(
75
74
  deps,
76
75
  onUpdate,
77
76
  renderSegments,
78
- version,
79
77
  onNavigate,
80
78
  } = config;
81
79
 
@@ -86,7 +84,7 @@ export function createServerActionBridge(
86
84
  client,
87
85
  onUpdate,
88
86
  renderSegments,
89
- version,
87
+ getVersion: getAppVersion,
90
88
  });
91
89
 
92
90
  /**
@@ -165,9 +163,15 @@ export function createServerActionBridge(
165
163
  segmentState.currentSegmentIds.join(","),
166
164
  );
167
165
  // Add version param for version mismatch detection
166
+ const version = getAppVersion();
168
167
  if (version) {
169
168
  url.searchParams.set("_rsc_v", version);
170
169
  }
170
+ // Add router ID for app switch detection
171
+ const rid = store.getRouterId?.();
172
+ if (rid) {
173
+ url.searchParams.set("_rsc_rid", rid);
174
+ }
171
175
 
172
176
  // Encode arguments
173
177
  const encodedBody = await deps.encodeReply(args, { temporaryReferences });
@@ -206,7 +210,6 @@ export function createServerActionBridge(
206
210
  "rsc-action": id,
207
211
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
208
212
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
209
- // Send intercept source URL so server can maintain intercept context
210
213
  ...(interceptSourceUrl && {
211
214
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
212
215
  }),
@@ -309,7 +312,6 @@ export function createServerActionBridge(
309
312
  matchedCount: payload.metadata?.matched?.length ?? 0,
310
313
  diffCount: payload.metadata?.diff?.length ?? 0,
311
314
  });
312
-
313
315
  // Guard: if the action was aborted while streaming (e.g., user navigated
314
316
  // away or abortAllActions fired), bail out before any reconcile/render/cache
315
317
  // writes to avoid overwriting the current UI with stale action results.
@@ -32,6 +32,9 @@ export type HandleData = Record<string, Record<string, unknown[]>>;
32
32
  export interface RscMetadata {
33
33
  pathname: string;
34
34
  segments: ResolvedSegment[];
35
+ /** Router instance ID. When this changes between navigations, the client
36
+ * forces a full tree replacement (app switch via host router). */
37
+ routerId?: string;
35
38
  isPartial?: boolean;
36
39
  isError?: boolean;
37
40
  matched?: string[];
@@ -70,6 +73,8 @@ export interface RscMetadata {
70
73
  * Included when theme is enabled in router config.
71
74
  */
72
75
  initialTheme?: Theme;
76
+ /** URL prefix for all routes (from createRouter({ basename })). */
77
+ basename?: string;
73
78
  /** Whether connection warmup is enabled */
74
79
  warmupEnabled?: boolean;
75
80
  /** Server-side redirect with optional state (for partial requests) */
@@ -215,6 +220,15 @@ export interface SegmentState {
215
220
  export interface NavigationUpdate {
216
221
  root: ReactNode | Promise<ReactNode>;
217
222
  metadata: RscMetadata;
223
+ /** Scroll behavior to apply after React commits this update */
224
+ scroll?: {
225
+ /** For back/forward: restore saved position */
226
+ restore?: boolean;
227
+ /** Set to false to disable scrolling entirely */
228
+ enabled?: boolean;
229
+ /** Function to check if streaming is in progress */
230
+ isStreaming?: () => boolean;
231
+ };
218
232
  }
219
233
 
220
234
  /**
@@ -332,7 +346,13 @@ export type ReadonlyURLSearchParams = Omit<
332
346
  export interface RscBrowserDependencies {
333
347
  createFromFetch: <T>(
334
348
  response: Promise<Response>,
335
- options?: { temporaryReferences?: any },
349
+ options?: {
350
+ temporaryReferences?: any;
351
+ findSourceMapURL?: (
352
+ filename: string,
353
+ environmentName: string,
354
+ ) => string | null;
355
+ },
336
356
  ) => Promise<T>;
337
357
  createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>;
338
358
  encodeReply: (
@@ -394,16 +414,25 @@ export interface NavigationStore {
394
414
  segments: ResolvedSegment[],
395
415
  handleData?: HandleData,
396
416
  ): void;
397
- getCachedSegments(
398
- historyKey: string,
399
- ):
400
- | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
417
+ getCachedSegments(historyKey: string):
418
+ | {
419
+ segments: ResolvedSegment[];
420
+ stale: boolean;
421
+ handleData?: HandleData;
422
+ routerId?: string;
423
+ }
401
424
  | undefined;
402
425
  hasHistoryCache(historyKey: string): boolean;
403
426
  updateCacheHandleData(historyKey: string, handleData: HandleData): void;
404
427
  markCacheAsStale(): void;
405
428
  markCacheAsStaleAndBroadcast(): void;
406
429
  clearHistoryCache(): void;
430
+ /**
431
+ * Clear this tab's nav + prefetch caches without broadcasting or rotating
432
+ * shared state. Intended for app-switch transitions that affect only this
433
+ * tab's session.
434
+ */
435
+ clearHistoryCacheLocal(): void;
407
436
  broadcastCacheInvalidation(): void;
408
437
 
409
438
  // Cross-tab refresh callback (set by navigation bridge)
@@ -413,6 +442,10 @@ export interface NavigationStore {
413
442
  getInterceptSourceUrl(): string | null;
414
443
  setInterceptSourceUrl(url: string | null): void;
415
444
 
445
+ // Router identity tracking (for cross-app navigation detection)
446
+ getRouterId?(): string | undefined;
447
+ setRouterId?(id: string): void;
448
+
416
449
  // UI update notifications
417
450
  onUpdate(callback: UpdateSubscriber): () => void;
418
451
  emitUpdate(update: NavigationUpdate): void;
@@ -443,6 +476,8 @@ export interface FetchPartialOptions {
443
476
  interceptSourceUrl?: string;
444
477
  /** RSC version for cache invalidation detection */
445
478
  version?: string;
479
+ /** Current router ID — server detects app switch and returns full response */
480
+ routerId?: string;
446
481
  /** If true, this is an HMR refetch - server should invalidate manifest cache */
447
482
  hmr?: boolean;
448
483
  }
@@ -511,6 +546,15 @@ export interface NavigationBridge {
511
546
  refresh(): Promise<void>;
512
547
  handlePopstate(): Promise<void>;
513
548
  registerLinkInterception(): () => void;
549
+ /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
550
+ updateVersion(newVersion: string): void;
551
+ /**
552
+ * Replace the active app-shell snapshot (rootLayout, basename, version)
553
+ * atomically. Used on cross-app navigations when the response's routerId
554
+ * indicates the user entered a different app. Theme, warmup, and prefetch
555
+ * TTL are document-lifetime and not part of the shell.
556
+ */
557
+ updateAppShell(next: import("./app-shell.js").AppShell): void;
514
558
  }
515
559
 
516
560
  /**
@@ -45,7 +45,7 @@ export interface GeneratedManifest {
45
45
  routeTrailingSlash?: Record<string, string>;
46
46
  /** Route names using Prerender (for dev-mode Node.js delegation) */
47
47
  prerenderRoutes?: string[];
48
- /** Route names with passthrough: true (handler kept in bundle for live fallback) */
48
+ /** Route names wrapped with Passthrough() (live handler for runtime fallback) */
49
49
  passthroughRoutes?: string[];
50
50
  /** Route name → response type for non-RSC routes */
51
51
  responseTypeRoutes?: Record<string, string>;
@@ -150,10 +150,7 @@ function buildPrefixTreeNode(
150
150
  if (prerenderDefs && entry.prerenderDef) {
151
151
  prerenderDefs[name] = entry.prerenderDef;
152
152
  }
153
- if (
154
- passthroughRoutes &&
155
- entry.prerenderDef?.options?.passthrough === true
156
- ) {
153
+ if (passthroughRoutes && entry.isPassthrough === true) {
157
154
  passthroughRoutes.push(name);
158
155
  }
159
156
  }
@@ -285,6 +282,7 @@ export function generateManifest<TEnv>(
285
282
  export function generateManifestFull<TEnv>(
286
283
  urlpatterns: UrlPatterns<TEnv, any>,
287
284
  mountIndex: number = 0,
285
+ options?: { urlPrefix?: string },
288
286
  ): FullManifest {
289
287
  const routeManifest: Record<string, string> = {};
290
288
  const routeAncestry: Record<string, string[]> = {};
@@ -310,6 +308,8 @@ export function generateManifestFull<TEnv>(
310
308
  counters: {},
311
309
  mountIndex,
312
310
  trackedIncludes, // Enable include tracking
311
+ // basename sets the initial URL prefix for all path() registrations
312
+ ...(options?.urlPrefix ? { urlPrefix: options.urlPrefix } : {}),
313
313
  },
314
314
  () => {
315
315
  const helpers = createRouteHelpers();
@@ -347,7 +347,7 @@ export function generateManifestFull<TEnv>(
347
347
  if (entry.prerenderDef) {
348
348
  prerenderDefs[name] = entry.prerenderDef;
349
349
  }
350
- if (entry.prerenderDef?.options?.passthrough === true) {
350
+ if (entry.isPassthrough === true) {
351
351
  passthroughRoutes.push(name);
352
352
  }
353
353
  }
@@ -25,6 +25,9 @@ export {
25
25
  } from "./route-types/include-resolution.js";
26
26
  export {
27
27
  extractUrlsVariableFromRouter,
28
+ extractUrlsFromRouter,
29
+ extractBasenameFromRouter,
30
+ type UrlsExtractionResult,
28
31
  buildCombinedRouteMapForRouterFile,
29
32
  detectUnresolvableIncludes,
30
33
  detectUnresolvableIncludesForUrlsFile,
@@ -98,8 +98,14 @@ export function buildRouteTrie(
98
98
  }
99
99
 
100
100
  /**
101
- * Insert a route into the trie, handling optional params by forking
102
- * the insertion path (one terminal without the param, one with).
101
+ * Insert a route into the trie. Optional params expand into two branches at
102
+ * registration time (skip-first, then present), so each terminal lives at the
103
+ * correct depth for its number of bound params and carries a branch-local
104
+ * `pa` listing only those names. The trie's single-slot `node.p` is reused
105
+ * across branches because matching ignores `node.p.n` — the leaf's `pa` is
106
+ * the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
107
+ * last-wins rule produce greedy-leftmost semantics for free at any shared
108
+ * terminal depth.
103
109
  */
104
110
  function insertRoute(
105
111
  node: TrieNode,
@@ -107,14 +113,13 @@ function insertRoute(
107
113
  index: number,
108
114
  leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
109
115
  ): void {
110
- // Collect param names, optional param names, and constraints across all segments
111
- const paramNames: string[] = [];
116
+ // op (full optional list) and cv (full constraint map) are route-level and
117
+ // identical on every terminal, so compute them once on the shared base.
112
118
  const optionalParams: string[] = [];
113
119
  const constraints: Record<string, string[]> = {};
114
120
 
115
121
  for (const seg of segments) {
116
122
  if (seg.type === "param") {
117
- paramNames.push(seg.value);
118
123
  if (seg.optional) {
119
124
  optionalParams.push(seg.value);
120
125
  }
@@ -124,21 +129,15 @@ function insertRoute(
124
129
  }
125
130
  }
126
131
 
127
- const fullLeaf: TrieLeaf = {
132
+ const leafBase: Omit<TrieLeaf, "pa"> = {
128
133
  ...leaf,
129
- ...(paramNames.length > 0 ? { pa: paramNames } : {}),
130
134
  ...(optionalParams.length > 0 ? { op: optionalParams } : {}),
131
135
  ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
132
136
  };
133
137
 
134
- insertSegments(node, segments, index, fullLeaf);
138
+ insertSegments(node, segments, index, leafBase, []);
135
139
  }
136
140
 
137
- /**
138
- * Recursively insert segments into the trie.
139
- * For optional params, we add a terminal at the current node (param absent)
140
- * AND continue inserting into the param child (param present).
141
- */
142
141
  /**
143
142
  * Extract ancestry map from a built trie by visiting all leaf nodes.
144
143
  * Returns { routeName: ancestryShortCodes[] } for every route in the trie.
@@ -218,15 +217,25 @@ function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
218
217
  node.r = mergeLeaves(node.r, leaf);
219
218
  }
220
219
 
220
+ function buildLeaf(
221
+ leafBase: Omit<TrieLeaf, "pa">,
222
+ paramNames: string[],
223
+ ): TrieLeaf {
224
+ return paramNames.length > 0
225
+ ? { ...leafBase, pa: [...paramNames] }
226
+ : { ...leafBase };
227
+ }
228
+
221
229
  function insertSegments(
222
230
  node: TrieNode,
223
231
  segments: ParsedSegment[],
224
232
  index: number,
225
- leaf: TrieLeaf,
233
+ leafBase: Omit<TrieLeaf, "pa">,
234
+ paramNames: string[],
226
235
  ): void {
227
- // Base case: all segments consumed, add terminal
236
+ // Base case: all segments consumed, add terminal with branch-local pa
228
237
  if (index >= segments.length) {
229
- mergeLeaf(node, leaf);
238
+ mergeLeaf(node, buildLeaf(leafBase, paramNames));
230
239
  return;
231
240
  }
232
241
 
@@ -235,12 +244,19 @@ function insertSegments(
235
244
  if (segment.type === "static") {
236
245
  if (!node.s) node.s = {};
237
246
  if (!node.s[segment.value]) node.s[segment.value] = {};
238
- insertSegments(node.s[segment.value], segments, index + 1, leaf);
247
+ insertSegments(
248
+ node.s[segment.value],
249
+ segments,
250
+ index + 1,
251
+ leafBase,
252
+ paramNames,
253
+ );
239
254
  } else if (segment.type === "param") {
240
255
  if (segment.optional) {
241
- // Optional param: add terminal at current node (param absent)
242
- mergeLeaf(node, leaf);
243
- // AND continue with param child (param present)
256
+ // SKIP first: continue at the same node without binding this name.
257
+ // Skip-first ordering means the present-branch's TAKE overwrites any
258
+ // shared terminal later, giving greedy-leftmost semantics.
259
+ insertSegments(node, segments, index + 1, leafBase, paramNames);
244
260
  }
245
261
  if (segment.suffix) {
246
262
  // Suffix param: keyed by suffix string (e.g., ".html")
@@ -248,16 +264,26 @@ function insertSegments(
248
264
  if (!node.xp[segment.suffix]) {
249
265
  node.xp[segment.suffix] = { n: segment.value, c: {} };
250
266
  }
251
- insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
267
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
268
+ ...paramNames,
269
+ segment.value,
270
+ ]);
252
271
  } else {
253
272
  if (!node.p) {
254
273
  node.p = { n: segment.value, c: {} };
255
274
  }
256
- insertSegments(node.p.c, segments, index + 1, leaf);
275
+ insertSegments(node.p.c, segments, index + 1, leafBase, [
276
+ ...paramNames,
277
+ segment.value,
278
+ ]);
257
279
  }
258
280
  } else if (segment.type === "wildcard") {
259
- // Wildcard consumes all remaining segments
260
- const wildLeaf = { ...leaf, pn: "*" };
281
+ // Wildcard consumes all remaining segments. Carry any params bound before
282
+ // the wildcard in pa so they zip correctly against paramValues at match.
283
+ const wildLeaf: TrieLeaf & { pn: string } = {
284
+ ...buildLeaf(leafBase, paramNames),
285
+ pn: "*",
286
+ };
261
287
  const existing = node.w ? ({ ...node.w } as TrieLeaf) : undefined;
262
288
  const merged = mergeLeaves(existing, wildLeaf);
263
289
  node.w = merged as TrieLeaf & { pn: string };
@@ -357,12 +357,17 @@ function buildRouteMapFromBlock(
357
357
  /**
358
358
  * Build route map and search schemas together.
359
359
  * Internal helper used by the include resolution path.
360
+ *
361
+ * @param inlineBlock - Optional pre-extracted code block (e.g. from an inline
362
+ * builder function). When provided, variableName is ignored and the block
363
+ * is parsed directly for path()/include() calls.
360
364
  */
361
365
  export function buildCombinedRouteMapWithSearch(
362
366
  filePath: string,
363
367
  variableName?: string,
364
368
  visited?: Set<string>,
365
369
  diagnosticsOut?: UnresolvableInclude[],
370
+ inlineBlock?: string,
366
371
  ): {
367
372
  routes: Record<string, string>;
368
373
  searchSchemas: Record<string, Record<string, string>>;
@@ -384,7 +389,9 @@ export function buildCombinedRouteMapWithSearch(
384
389
  }
385
390
 
386
391
  let block: string;
387
- if (variableName) {
392
+ if (inlineBlock) {
393
+ block = inlineBlock;
394
+ } else if (variableName) {
388
395
  const extracted = extractUrlsBlockForVariable(source, variableName);
389
396
  if (!extracted) return { routes: {}, searchSchemas: {} };
390
397
  block = extracted;