@rangojs/router 0.0.0-experimental.10

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 (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,828 @@
1
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { join, dirname, resolve, relative, basename as pathBasename } from "node:path";
3
+ // @ts-ignore -- picomatch ships no .d.ts; types are trivial
4
+ import picomatch from "picomatch";
5
+
6
+ /**
7
+ * Extract route definitions from source code by statically parsing path() calls.
8
+ * No code execution needed -- works on raw source text.
9
+ *
10
+ * Handles multi-line handlers with JSX, nested braces, string literals,
11
+ * and comments. Skips unnamed paths (no { name: "..." }).
12
+ */
13
+ export function extractRoutesFromSource(
14
+ code: string
15
+ ): Array<{ name: string; pattern: string }> {
16
+ const routes: Array<{ name: string; pattern: string }> = [];
17
+ const regex = /\bpath(?:\.(?:json|text|html|xml|image|stream|any))?\s*\(/g;
18
+ let match;
19
+
20
+ while ((match = regex.exec(code)) !== null) {
21
+ const result = parsePathCall(code, match.index + match[0].length);
22
+ if (result) routes.push(result);
23
+ }
24
+
25
+ return routes;
26
+ }
27
+
28
+ /**
29
+ * Generate a per-module types file from extracted routes.
30
+ * Output has zero imports, preventing circular references.
31
+ */
32
+ export function generatePerModuleTypesSource(
33
+ routes: Array<{ name: string; pattern: string }>
34
+ ): string {
35
+ const valid = routes.filter(({ name }) => {
36
+ if (!name || /["'\\`\n\r]/.test(name)) {
37
+ console.warn(`[rsc-router] Skipping route with invalid name: ${JSON.stringify(name)}`);
38
+ return false;
39
+ }
40
+ return true;
41
+ });
42
+
43
+ // Deduplicate by name (last definition wins for same name)
44
+ const deduped = new Map<string, string>();
45
+ for (const { name, pattern } of valid) {
46
+ deduped.set(name, pattern);
47
+ }
48
+ const sorted = [...deduped.entries()]
49
+ .sort(([a], [b]) => a.localeCompare(b));
50
+ const body = sorted
51
+ .map(([name, pattern]) => {
52
+ // Quote names that aren't valid bare identifiers (dots, dashes, etc.)
53
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
54
+ return ` ${key}: "${pattern}",`;
55
+ })
56
+ .join("\n");
57
+ return `// Auto-generated by @rangojs/router - do not edit\nexport const routes = {\n${body}\n} as const;\nexport type routes = typeof routes;\n`;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Mini-parser internals
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function isWhitespace(ch: string): boolean {
65
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
66
+ }
67
+
68
+ /** Read a single- or double-quoted string literal starting at pos. */
69
+ function readString(
70
+ code: string,
71
+ pos: number
72
+ ): { value: string; end: number } | null {
73
+ const quote = code[pos];
74
+ if (quote !== '"' && quote !== "'") return null;
75
+
76
+ let value = "";
77
+ pos++;
78
+ while (pos < code.length) {
79
+ if (code[pos] === "\\") {
80
+ pos++;
81
+ if (pos < code.length) {
82
+ value += code[pos];
83
+ pos++;
84
+ }
85
+ continue;
86
+ }
87
+ if (code[pos] === quote) {
88
+ return { value, end: pos + 1 };
89
+ }
90
+ value += code[pos];
91
+ pos++;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** Skip past any string literal (single, double, or template). */
97
+ function skipStringLiteral(code: string, pos: number): number {
98
+ const quote = code[pos];
99
+
100
+ if (quote === "`") {
101
+ pos++;
102
+ while (pos < code.length) {
103
+ if (code[pos] === "\\") {
104
+ pos += 2;
105
+ continue;
106
+ }
107
+ if (code[pos] === "`") return pos + 1;
108
+ if (code[pos] === "$" && pos + 1 < code.length && code[pos + 1] === "{") {
109
+ pos += 2;
110
+ let braceDepth = 1;
111
+ while (pos < code.length && braceDepth > 0) {
112
+ if (code[pos] === "{") braceDepth++;
113
+ else if (code[pos] === "}") braceDepth--;
114
+ else if (code[pos] === "\\") pos++;
115
+ else if (
116
+ code[pos] === '"' ||
117
+ code[pos] === "'" ||
118
+ code[pos] === "`"
119
+ ) {
120
+ pos = skipStringLiteral(code, pos);
121
+ continue;
122
+ }
123
+ if (braceDepth > 0) pos++;
124
+ }
125
+ continue;
126
+ }
127
+ pos++;
128
+ }
129
+ return pos;
130
+ }
131
+
132
+ // Simple single/double quoted string
133
+ pos++;
134
+ while (pos < code.length) {
135
+ if (code[pos] === "\\") {
136
+ pos += 2;
137
+ continue;
138
+ }
139
+ if (code[pos] === quote) return pos + 1;
140
+ pos++;
141
+ }
142
+ return pos;
143
+ }
144
+
145
+ /**
146
+ * Check if code at pos starts with `name` as a standalone identifier
147
+ * followed by `:` (an object property).
148
+ */
149
+ function matchesNameColon(code: string, pos: number): boolean {
150
+ if (code.slice(pos, pos + 4) !== "name") return false;
151
+ if (pos > 0 && /\w/.test(code[pos - 1])) return false;
152
+ const afterName = pos + 4;
153
+ if (afterName < code.length && /\w/.test(code[afterName])) return false;
154
+ let checkPos = afterName;
155
+ while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
156
+ return code[checkPos] === ":";
157
+ }
158
+
159
+ /** Extract the string value after `name:` starting at the `n` of `name`. */
160
+ function extractNameValue(
161
+ code: string,
162
+ pos: number
163
+ ): { value: string; end: number } | null {
164
+ pos += 4; // skip 'name'
165
+ while (pos < code.length && isWhitespace(code[pos])) pos++;
166
+ pos++; // skip ':'
167
+ while (pos < code.length && isWhitespace(code[pos])) pos++;
168
+ return readString(code, pos);
169
+ }
170
+
171
+ /**
172
+ * Parse a single path() call starting right after the opening paren.
173
+ * Returns { name, pattern } or null if the call is unnamed.
174
+ */
175
+ function parsePathCall(
176
+ code: string,
177
+ pos: number
178
+ ): { name: string; pattern: string } | null {
179
+ // Skip whitespace to first argument
180
+ while (pos < code.length && isWhitespace(code[pos])) pos++;
181
+
182
+ // First argument must be a string literal (the pattern)
183
+ const patternStr = readString(code, pos);
184
+ if (!patternStr) return null;
185
+ const pattern = patternStr.value;
186
+ pos = patternStr.end;
187
+
188
+ // Scan the rest of the call tracking depth.
189
+ // depth=1: inside path(), depth=2: inside an object/paren at top level of call.
190
+ // We look for `name: "..."` at depth 2 (options object properties).
191
+ let depth = 1;
192
+ let name: string | null = null;
193
+
194
+ while (pos < code.length && depth > 0) {
195
+ const ch = code[pos];
196
+
197
+ if (isWhitespace(ch)) {
198
+ pos++;
199
+ continue;
200
+ }
201
+
202
+ // Line comment
203
+ if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
204
+ pos += 2;
205
+ while (pos < code.length && code[pos] !== "\n") pos++;
206
+ continue;
207
+ }
208
+
209
+ // Block comment
210
+ if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
211
+ pos += 2;
212
+ while (
213
+ pos < code.length - 1 &&
214
+ !(code[pos] === "*" && code[pos + 1] === "/")
215
+ )
216
+ pos++;
217
+ pos += 2;
218
+ continue;
219
+ }
220
+
221
+ // At depth 2 (inside an object at call top-level), look for name: "..."
222
+ if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
223
+ const nameResult = extractNameValue(code, pos);
224
+ if (nameResult) {
225
+ name = nameResult.value;
226
+ pos = nameResult.end;
227
+ continue;
228
+ }
229
+ }
230
+
231
+ // Skip string literals.
232
+ // Treat ' preceded by a word char as an apostrophe (e.g. "shouldn't"),
233
+ // not a string delimiter. In valid JS/TS, opening ' is never preceded
234
+ // by a word character.
235
+ if (ch === '"' || ch === "`" || (ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1])))) {
236
+ pos = skipStringLiteral(code, pos);
237
+ continue;
238
+ }
239
+
240
+ // Track depth
241
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
242
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
243
+
244
+ pos++;
245
+ }
246
+
247
+ if (name === null) return null;
248
+ return { name, pattern };
249
+ }
250
+
251
+ /**
252
+ * Generates a .ts file that augments RSCRouter.GeneratedRouteMap
253
+ * with route name -> pattern mappings. This enables Handler<"routeName">
254
+ * without circular references since the file has no imports from the app.
255
+ */
256
+ export function generateRouteTypesSource(
257
+ routeManifest: Record<string, string>
258
+ ): string {
259
+ const entries = Object.entries(routeManifest).sort(([a], [b]) =>
260
+ a.localeCompare(b)
261
+ );
262
+
263
+ const interfaceBody = entries
264
+ .map(([name, pattern]) => ` "${name}": "${pattern}";`)
265
+ .join("\n");
266
+
267
+ return `// Auto-generated by @rangojs/router - do not edit
268
+ export {};
269
+
270
+ declare global {
271
+ namespace RSCRouter {
272
+ interface GeneratedRouteMap {
273
+ ${interfaceBody}
274
+ }
275
+ }
276
+ }
277
+ `;
278
+ }
279
+
280
+ /** Default exclude patterns for route type scanning. */
281
+ export const DEFAULT_EXCLUDE_PATTERNS: string[] = [
282
+ "**/__tests__/**",
283
+ "**/__mocks__/**",
284
+ "**/dist/**",
285
+ "**/coverage/**",
286
+ "**/*.test.{ts,tsx}",
287
+ "**/*.spec.{ts,tsx}",
288
+ ];
289
+
290
+ export type ScanFilter = (absolutePath: string) => boolean;
291
+
292
+ /**
293
+ * Compile include/exclude glob patterns into a single predicate.
294
+ * Paths are made root-relative before matching.
295
+ * Returns undefined when no filtering is needed (no include, default exclude).
296
+ */
297
+ export function createScanFilter(
298
+ root: string,
299
+ opts: { include?: string[]; exclude?: string[] },
300
+ ): ScanFilter | undefined {
301
+ const { include, exclude } = opts;
302
+ const hasInclude = include && include.length > 0;
303
+ const hasCustomExclude = exclude !== undefined;
304
+
305
+ if (!hasInclude && !hasCustomExclude) return undefined;
306
+
307
+ const effectiveExclude = exclude ?? DEFAULT_EXCLUDE_PATTERNS;
308
+ const includeMatcher = hasInclude ? picomatch(include) : null;
309
+ const excludeMatcher = effectiveExclude.length > 0 ? picomatch(effectiveExclude) : null;
310
+
311
+ return (absolutePath: string) => {
312
+ const rel = relative(root, absolutePath);
313
+ if (excludeMatcher && excludeMatcher(rel)) return false;
314
+ if (includeMatcher) return includeMatcher(rel);
315
+ return true;
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Recursively find .ts/.tsx files under a directory, skipping node_modules
321
+ * and .gen. files.
322
+ */
323
+ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
324
+ const results: string[] = [];
325
+ let entries;
326
+ try {
327
+ entries = readdirSync(dir, { withFileTypes: true });
328
+ } catch (err) {
329
+ console.warn(`[rsc-router] Failed to scan directory ${dir}: ${(err as Error).message}`);
330
+ return results;
331
+ }
332
+ for (const entry of entries) {
333
+ const fullPath = join(dir, entry.name);
334
+ if (entry.isDirectory()) {
335
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
336
+ results.push(...findTsFiles(fullPath, filter));
337
+ } else if (
338
+ (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
339
+ !entry.name.includes(".gen.")
340
+ ) {
341
+ if (filter && !filter(fullPath)) continue;
342
+ results.push(fullPath);
343
+ }
344
+ }
345
+ return results;
346
+ }
347
+
348
+ /**
349
+ * Generate per-module route type files by statically parsing url module source.
350
+ * Scans for files containing `urls(` and writes a sibling `.gen.ts` with the
351
+ * extracted route name/pattern pairs. Only writes when content has changed.
352
+ */
353
+ export function writePerModuleRouteTypes(root: string, filter?: ScanFilter): void {
354
+ const files = findTsFiles(root, filter);
355
+ for (const filePath of files) {
356
+ writePerModuleRouteTypesForFile(filePath);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Generate per-module route types for a single url module file.
362
+ * No-ops if the file doesn't contain `urls(` or has no named routes.
363
+ */
364
+ export function writePerModuleRouteTypesForFile(filePath: string): void {
365
+ try {
366
+ const source = readFileSync(filePath, "utf-8");
367
+ if (!source.includes("urls(")) return;
368
+
369
+ const routes = extractRoutesFromSource(source);
370
+ if (routes.length === 0) return;
371
+
372
+ const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
373
+ const genSource = generatePerModuleTypesSource(routes);
374
+ const existing = existsSync(genPath) ? readFileSync(genPath, "utf-8") : null;
375
+ if (existing !== genSource) {
376
+ writeFileSync(genPath, genSource);
377
+ console.log(`[rsc-router] Generated route types -> ${genPath}`);
378
+ }
379
+ } catch (err) {
380
+ console.warn(`[rsc-router] Failed to generate route types for ${filePath}: ${(err as Error).message}`);
381
+ }
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Static include() parsing
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Extract include() calls from source code by statically parsing.
390
+ * Returns the path prefix, variable name, and optional name prefix for each.
391
+ */
392
+ export function extractIncludesFromSource(
393
+ code: string
394
+ ): Array<{ pathPrefix: string; variableName: string; namePrefix: string | null }> {
395
+ const results: Array<{
396
+ pathPrefix: string;
397
+ variableName: string;
398
+ namePrefix: string | null;
399
+ }> = [];
400
+ const regex = /\binclude\s*\(/g;
401
+ let match;
402
+
403
+ while ((match = regex.exec(code)) !== null) {
404
+ const result = parseIncludeCall(code, match.index + match[0].length);
405
+ if (result) results.push(result);
406
+ }
407
+
408
+ return results;
409
+ }
410
+
411
+ /**
412
+ * Parse a single include() call starting right after the opening paren.
413
+ * Expects: include("prefix", variableName, { name: "prefix" })
414
+ */
415
+ function parseIncludeCall(
416
+ code: string,
417
+ pos: number
418
+ ): {
419
+ pathPrefix: string;
420
+ variableName: string;
421
+ namePrefix: string | null;
422
+ } | null {
423
+ // Skip whitespace to first argument
424
+ while (pos < code.length && isWhitespace(code[pos])) pos++;
425
+
426
+ // First arg: string literal (pathPrefix)
427
+ const prefixStr = readString(code, pos);
428
+ if (!prefixStr) return null;
429
+ const pathPrefix = prefixStr.value;
430
+ pos = prefixStr.end;
431
+
432
+ // Comma
433
+ while (pos < code.length && isWhitespace(code[pos])) pos++;
434
+ if (pos >= code.length || code[pos] !== ",") return null;
435
+ pos++;
436
+ while (pos < code.length && isWhitespace(code[pos])) pos++;
437
+
438
+ // Second arg: identifier (variableName)
439
+ const varStart = pos;
440
+ while (pos < code.length && /[\w$]/.test(code[pos])) pos++;
441
+ if (pos === varStart) return null;
442
+ const variableName = code.slice(varStart, pos);
443
+
444
+ // Scan rest of call for optional { name: "..." }
445
+ let namePrefix: string | null = null;
446
+ let depth = 1; // inside include()
447
+
448
+ while (pos < code.length && depth > 0) {
449
+ const ch = code[pos];
450
+
451
+ if (isWhitespace(ch)) {
452
+ pos++;
453
+ continue;
454
+ }
455
+
456
+ // Line comment
457
+ if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
458
+ pos += 2;
459
+ while (pos < code.length && code[pos] !== "\n") pos++;
460
+ continue;
461
+ }
462
+
463
+ // Block comment
464
+ if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
465
+ pos += 2;
466
+ while (
467
+ pos < code.length - 1 &&
468
+ !(code[pos] === "*" && code[pos + 1] === "/")
469
+ )
470
+ pos++;
471
+ pos += 2;
472
+ continue;
473
+ }
474
+
475
+ // At depth 2 (inside options object), look for name: "..."
476
+ if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
477
+ const nameResult = extractNameValue(code, pos);
478
+ if (nameResult) {
479
+ namePrefix = nameResult.value;
480
+ pos = nameResult.end;
481
+ continue;
482
+ }
483
+ }
484
+
485
+ // Skip string literals
486
+ if (
487
+ ch === '"' ||
488
+ ch === "`" ||
489
+ (ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1])))
490
+ ) {
491
+ pos = skipStringLiteral(code, pos);
492
+ continue;
493
+ }
494
+
495
+ // Track depth
496
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
497
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
498
+
499
+ pos++;
500
+ }
501
+
502
+ return { pathPrefix, variableName, namePrefix };
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Import resolution
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Find the import statement for a local variable name.
511
+ * Returns the import specifier and the exported name from the source module.
512
+ */
513
+ function resolveImportedVariable(
514
+ code: string,
515
+ localName: string
516
+ ): { specifier: string; exportedName: string } | null {
517
+ const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
518
+ let match;
519
+
520
+ while ((match = importRegex.exec(code)) !== null) {
521
+ const imports = match[1];
522
+ const specifier = match[2];
523
+
524
+ const parts = imports
525
+ .split(",")
526
+ .map((s) => s.trim())
527
+ .filter(Boolean);
528
+ for (const part of parts) {
529
+ const asMatch = part.match(/^(\w+)\s+as\s+(\w+)$/);
530
+ if (asMatch && asMatch[2] === localName)
531
+ return { specifier, exportedName: asMatch[1] };
532
+ if (part === localName) return { specifier, exportedName: localName };
533
+ }
534
+ }
535
+
536
+ return null;
537
+ }
538
+
539
+ /**
540
+ * Resolve an import specifier relative to the importing file.
541
+ * Strips .js/.mjs extensions and tries .ts/.tsx candidates.
542
+ */
543
+ function resolveImportPath(
544
+ importSpec: string,
545
+ fromFile: string
546
+ ): string | null {
547
+ if (!importSpec.startsWith(".")) return null;
548
+
549
+ const dir = dirname(fromFile);
550
+ let base = importSpec;
551
+ if (base.endsWith(".js")) base = base.slice(0, -3);
552
+ else if (base.endsWith(".mjs")) base = base.slice(0, -4);
553
+
554
+ const candidates = [
555
+ resolve(dir, base + ".ts"),
556
+ resolve(dir, base + ".tsx"),
557
+ resolve(dir, base + "/index.ts"),
558
+ resolve(dir, base + "/index.tsx"),
559
+ ];
560
+
561
+ for (const candidate of candidates) {
562
+ if (existsSync(candidate)) return candidate;
563
+ }
564
+ return null;
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // urls() block extraction for same-file variables
569
+ // ---------------------------------------------------------------------------
570
+
571
+ function escapeRegExp(s: string): string {
572
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
573
+ }
574
+
575
+ /**
576
+ * Extract the source of a specific `const varName = urls(...)` block.
577
+ * Used for same-file variables where include() references a urls() defined
578
+ * in the same module rather than imported.
579
+ */
580
+ function extractUrlsBlockForVariable(
581
+ code: string,
582
+ varName: string
583
+ ): string | null {
584
+ const pattern = new RegExp(
585
+ `(?:export\\s+)?(?:const|let|var)\\s+${escapeRegExp(varName)}\\s*=\\s*urls\\s*\\(`
586
+ );
587
+ const match = pattern.exec(code);
588
+ if (!match) return null;
589
+
590
+ // Start from the opening paren of urls(
591
+ const openParen = match.index + match[0].length - 1;
592
+ let depth = 1;
593
+ let pos = openParen + 1;
594
+
595
+ while (pos < code.length && depth > 0) {
596
+ const ch = code[pos];
597
+
598
+ // Skip strings
599
+ if (
600
+ ch === '"' ||
601
+ ch === "`" ||
602
+ (ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1])))
603
+ ) {
604
+ pos = skipStringLiteral(code, pos);
605
+ continue;
606
+ }
607
+
608
+ // Line comment
609
+ if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
610
+ pos += 2;
611
+ while (pos < code.length && code[pos] !== "\n") pos++;
612
+ continue;
613
+ }
614
+
615
+ // Block comment
616
+ if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
617
+ pos += 2;
618
+ while (
619
+ pos < code.length - 1 &&
620
+ !(code[pos] === "*" && code[pos + 1] === "/")
621
+ )
622
+ pos++;
623
+ pos += 2;
624
+ continue;
625
+ }
626
+
627
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
628
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
629
+
630
+ pos++;
631
+ }
632
+
633
+ return code.slice(openParen, pos);
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // Combined route map building
638
+ // ---------------------------------------------------------------------------
639
+
640
+ /**
641
+ * Recursively build a route map from a urls module file.
642
+ * Extracts local path() routes and follows include() calls to sub-modules.
643
+ * Handles both imported and same-file variables.
644
+ */
645
+ export function buildCombinedRouteMap(
646
+ filePath: string,
647
+ variableName?: string,
648
+ visited?: Set<string>
649
+ ): Record<string, string> {
650
+ visited = visited ?? new Set();
651
+ const realPath = resolve(filePath);
652
+ const key = variableName ? `${realPath}:${variableName}` : realPath;
653
+ if (visited.has(key)) return {};
654
+ visited.add(key);
655
+
656
+ let source: string;
657
+ try {
658
+ source = readFileSync(realPath, "utf-8");
659
+ } catch {
660
+ return {};
661
+ }
662
+
663
+ // If a specific variable is requested, extract just its urls() block
664
+ let block: string;
665
+ if (variableName) {
666
+ const extracted = extractUrlsBlockForVariable(source, variableName);
667
+ if (!extracted) return {};
668
+ block = extracted;
669
+ } else {
670
+ block = source;
671
+ }
672
+
673
+ return buildRouteMapFromBlock(block, source, realPath, visited);
674
+ }
675
+
676
+ function buildRouteMapFromBlock(
677
+ block: string,
678
+ fullSource: string,
679
+ filePath: string,
680
+ visited: Set<string>
681
+ ): Record<string, string> {
682
+ const routeMap: Record<string, string> = {};
683
+
684
+ // Extract local path() routes
685
+ const localRoutes = extractRoutesFromSource(block);
686
+ for (const { name, pattern } of localRoutes) {
687
+ routeMap[name] = pattern;
688
+ }
689
+
690
+ // Extract include() calls
691
+ const includes = extractIncludesFromSource(block);
692
+ for (const { pathPrefix, variableName, namePrefix } of includes) {
693
+ let childRoutes: Record<string, string>;
694
+
695
+ // Try import resolution first
696
+ const imported = resolveImportedVariable(fullSource, variableName);
697
+ if (imported) {
698
+ const targetFile = resolveImportPath(imported.specifier, filePath);
699
+ if (!targetFile) continue;
700
+ childRoutes = buildCombinedRouteMap(
701
+ targetFile,
702
+ imported.exportedName,
703
+ visited
704
+ );
705
+ } else {
706
+ // Same-file variable
707
+ childRoutes = buildCombinedRouteMap(filePath, variableName, visited);
708
+ }
709
+
710
+ // Apply prefixes
711
+ for (const [name, pattern] of Object.entries(childRoutes)) {
712
+ const prefixedName = namePrefix ? `${namePrefix}.${name}` : name;
713
+ const prefixedPattern =
714
+ pattern === "/" ? pathPrefix || "/" : pathPrefix + pattern;
715
+ routeMap[prefixedName] = prefixedPattern;
716
+ }
717
+ }
718
+
719
+ return routeMap;
720
+ }
721
+
722
+ // ---------------------------------------------------------------------------
723
+ // Router file URL extraction
724
+ // ---------------------------------------------------------------------------
725
+
726
+ /**
727
+ * Extract the url patterns variable from a router file.
728
+ * Looks for patterns like:
729
+ * .routes(variableName)
730
+ * urls: variableName
731
+ * Returns the local variable name and optional import info.
732
+ */
733
+ function extractUrlsVariableFromRouter(
734
+ code: string
735
+ ): string | null {
736
+ // Pattern 1: .routes(variableName) where variableName is an identifier (not a string)
737
+ const routesCallMatch = code.match(/\.routes\s*\(\s*([a-zA-Z_$][\w$]*)\s*\)/);
738
+ if (routesCallMatch) return routesCallMatch[1];
739
+
740
+ // Pattern 2: urls: variableName in createRouter options
741
+ const urlsOptionMatch = code.match(/urls\s*:\s*([a-zA-Z_$][\w$]*)/);
742
+ if (urlsOptionMatch) return urlsOptionMatch[1];
743
+
744
+ return null;
745
+ }
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // Per-router named-routes.gen.ts writer
749
+ // ---------------------------------------------------------------------------
750
+
751
+ /**
752
+ * Scan for files containing createRouter() and return their paths.
753
+ * Call once at startup; the result can be reused on subsequent watcher triggers.
754
+ */
755
+ export function findRouterFiles(root: string, filter?: ScanFilter): string[] {
756
+ const files = findTsFiles(root, filter);
757
+ const result: string[] = [];
758
+ for (const filePath of files) {
759
+ if (filePath.includes(".gen.")) continue;
760
+ try {
761
+ const source = readFileSync(filePath, "utf-8");
762
+ if (/\bcreateRouter\s*[<(]/.test(source)) {
763
+ result.push(filePath);
764
+ }
765
+ } catch {
766
+ continue;
767
+ }
768
+ }
769
+ return result;
770
+ }
771
+
772
+ /**
773
+ * Generate per-router named-routes.gen.ts files from known router file paths.
774
+ * Re-reads each router file and resolves url patterns via static source parsing.
775
+ *
776
+ * Pass `knownRouterFiles` from a previous `findRouterFiles()` call to skip the
777
+ * full directory scan. If omitted, falls back to scanning (startup path).
778
+ */
779
+ export function writeCombinedRouteTypes(root: string, knownRouterFiles?: string[]): void {
780
+ // Delete old combined named-routes.gen.ts if it exists (stale from older versions)
781
+ try {
782
+ const oldCombinedPath = join(root, "src", "named-routes.gen.ts");
783
+ if (existsSync(oldCombinedPath)) {
784
+ unlinkSync(oldCombinedPath);
785
+ console.log(`[rsc-router] Removed stale combined route types: ${oldCombinedPath}`);
786
+ }
787
+ } catch {}
788
+
789
+ const routerFilePaths = knownRouterFiles ?? findRouterFiles(root);
790
+ if (routerFilePaths.length === 0) return;
791
+
792
+ for (const routerFilePath of routerFilePaths) {
793
+ let routerSource: string;
794
+ try {
795
+ routerSource = readFileSync(routerFilePath, "utf-8");
796
+ } catch {
797
+ continue;
798
+ }
799
+ // Extract the urls variable name from .routes(varName) or urls: varName
800
+ const urlsVarName = extractUrlsVariableFromRouter(routerSource);
801
+ if (!urlsVarName) continue;
802
+
803
+ // Resolve the variable to its source module
804
+ let routeMap: Record<string, string>;
805
+
806
+ const imported = resolveImportedVariable(routerSource, urlsVarName);
807
+ if (imported) {
808
+ // Variable is imported from another module
809
+ const targetFile = resolveImportPath(imported.specifier, routerFilePath);
810
+ if (!targetFile) continue;
811
+ routeMap = buildCombinedRouteMap(targetFile, imported.exportedName);
812
+ } else {
813
+ // Variable is defined in the same file
814
+ routeMap = buildCombinedRouteMap(routerFilePath, urlsVarName);
815
+ }
816
+
817
+ if (Object.keys(routeMap).length === 0) continue;
818
+
819
+ const routerBasename = pathBasename(routerFilePath).replace(/\.(tsx?|jsx?)$/, "");
820
+ const outPath = join(dirname(routerFilePath), `${routerBasename}.named-routes.gen.ts`);
821
+ const source = generateRouteTypesSource(routeMap);
822
+ const existing = existsSync(outPath) ? readFileSync(outPath, "utf-8") : null;
823
+ if (existing !== source) {
824
+ writeFileSync(outPath, source);
825
+ console.log(`[rsc-router] Generated route types (${Object.keys(routeMap).length} routes) -> ${outPath}`);
826
+ }
827
+ }
828
+ }