@rangojs/router 0.0.0-experimental.13 → 0.0.0-experimental.15

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.
@@ -2,336 +2,187 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from
2
2
  import { join, dirname, resolve, relative, basename as pathBasename } from "node:path";
3
3
  // @ts-ignore -- picomatch ships no .d.ts; types are trivial
4
4
  import picomatch from "picomatch";
5
+ import ts from "typescript";
5
6
 
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; search?: Record<string, string> }> {
16
- const routes: Array<{ name: string; pattern: string; search?: Record<string, string> }> = [];
17
- // Match `path(...)` and typed helpers like `path.json(...)`, `path.md(...)`.
18
- // Keep this generic so new helpers are picked up without parser updates.
19
- const regex = /\bpath(?:\.[a-zA-Z_$][\w$]*)?\s*\(/g;
20
- let match;
21
-
22
- while ((match = regex.exec(code)) !== null) {
23
- const result = parsePathCall(code, match.index + match[0].length);
24
- if (result) routes.push(result);
25
- }
7
+ // ---------------------------------------------------------------------------
8
+ // AST helpers
9
+ // ---------------------------------------------------------------------------
26
10
 
27
- return routes;
11
+ function getStringValue(node: ts.Node): string | null {
12
+ if (ts.isStringLiteral(node)) return node.text;
13
+ if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
14
+ return null;
28
15
  }
29
16
 
30
- /**
31
- * Generate a per-module types file from extracted routes.
32
- * Output has zero imports, preventing circular references.
33
- */
34
- export function generatePerModuleTypesSource(
35
- routes: Array<{ name: string; pattern: string; search?: Record<string, string> }>
36
- ): string {
37
- const valid = routes.filter(({ name }) => {
38
- if (!name || /["'\\`\n\r]/.test(name)) {
39
- console.warn(`[rsc-router] Skipping route with invalid name: ${JSON.stringify(name)}`);
40
- return false;
41
- }
42
- return true;
43
- });
44
-
45
- // Deduplicate by name (last definition wins for same name)
46
- const deduped = new Map<string, { pattern: string; search?: Record<string, string> }>();
47
- for (const { name, pattern, search } of valid) {
48
- deduped.set(name, { pattern, search });
17
+ function extractObjectStringProperties(node: ts.ObjectLiteralExpression): Record<string, string> {
18
+ const result: Record<string, string> = {};
19
+ for (const prop of node.properties) {
20
+ if (!ts.isPropertyAssignment(prop)) continue;
21
+ const key = ts.isIdentifier(prop.name) ? prop.name.text
22
+ : ts.isStringLiteral(prop.name) ? prop.name.text
23
+ : null;
24
+ if (!key) continue;
25
+ const val = getStringValue(prop.initializer);
26
+ if (val !== null) result[key] = val;
49
27
  }
50
- const sorted = [...deduped.entries()]
51
- .sort(([a], [b]) => a.localeCompare(b));
52
- const body = sorted
53
- .map(([name, { pattern, search }]) => {
54
- // Quote names that aren't valid bare identifiers (dots, dashes, etc.)
55
- const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
56
- if (search && Object.keys(search).length > 0) {
57
- const searchBody = Object.entries(search)
58
- .map(([k, v]) => `${k}: "${v}"`)
59
- .join(", ");
60
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
61
- }
62
- return ` ${key}: "${pattern}",`;
63
- })
64
- .join("\n");
65
- return `// Auto-generated by @rangojs/router - do not edit\nexport const routes = {\n${body}\n} as const;\nexport type routes = typeof routes;\n`;
28
+ return result;
66
29
  }
67
30
 
68
31
  // ---------------------------------------------------------------------------
69
- // Mini-parser internals
32
+ // Param extraction from route patterns
70
33
  // ---------------------------------------------------------------------------
71
34
 
72
- function isWhitespace(ch: string): boolean {
73
- return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
74
- }
75
-
76
- /** Read a single- or double-quoted string literal starting at pos. */
77
- function readString(
78
- code: string,
79
- pos: number
80
- ): { value: string; end: number } | null {
81
- const quote = code[pos];
82
- if (quote !== '"' && quote !== "'") return null;
83
-
84
- let value = "";
85
- pos++;
86
- while (pos < code.length) {
87
- if (code[pos] === "\\") {
88
- pos++;
89
- if (pos < code.length) {
90
- value += code[pos];
91
- pos++;
92
- }
93
- continue;
94
- }
95
- if (code[pos] === quote) {
96
- return { value, end: pos + 1 };
97
- }
98
- value += code[pos];
99
- pos++;
35
+ /**
36
+ * Extract typed params from a route pattern string.
37
+ * Matches `:paramName` and `:paramName?` (optional).
38
+ * Custom regex constraints like `:id(\d+)` are ignored for type purposes.
39
+ */
40
+ export function extractParamsFromPattern(pattern: string): Record<string, string> | undefined {
41
+ const params: Record<string, string> = {};
42
+ const regex = /:([a-zA-Z_$][\w$]*)(?:\([^)]+\))?(\?)?/g;
43
+ let match;
44
+ while ((match = regex.exec(pattern)) !== null) {
45
+ params[match[1]!] = match[2] ? "string?" : "string";
100
46
  }
101
- return null;
47
+ return Object.keys(params).length > 0 ? params : undefined;
102
48
  }
103
49
 
104
- /** Skip past any string literal (single, double, or template). */
105
- function skipStringLiteral(code: string, pos: number): number {
106
- const quote = code[pos];
107
-
108
- if (quote === "`") {
109
- pos++;
110
- while (pos < code.length) {
111
- if (code[pos] === "\\") {
112
- pos += 2;
113
- continue;
114
- }
115
- if (code[pos] === "`") return pos + 1;
116
- if (code[pos] === "$" && pos + 1 < code.length && code[pos + 1] === "{") {
117
- pos += 2;
118
- let braceDepth = 1;
119
- while (pos < code.length && braceDepth > 0) {
120
- if (code[pos] === "{") braceDepth++;
121
- else if (code[pos] === "}") braceDepth--;
122
- else if (code[pos] === "\\") pos++;
123
- else if (
124
- code[pos] === '"' ||
125
- code[pos] === "'" ||
126
- code[pos] === "`"
127
- ) {
128
- pos = skipStringLiteral(code, pos);
129
- continue;
130
- }
131
- if (braceDepth > 0) pos++;
132
- }
133
- continue;
134
- }
135
- pos++;
136
- }
137
- return pos;
138
- }
139
-
140
- // Simple single/double quoted string
141
- pos++;
142
- while (pos < code.length) {
143
- if (code[pos] === "\\") {
144
- pos += 2;
145
- continue;
146
- }
147
- if (code[pos] === quote) return pos + 1;
148
- pos++;
149
- }
150
- return pos;
151
- }
50
+ // ---------------------------------------------------------------------------
51
+ // Shared route entry formatter
52
+ // ---------------------------------------------------------------------------
152
53
 
153
54
  /**
154
- * Check if code at pos starts with `name` as a standalone identifier
155
- * followed by `:` (an object property).
55
+ * Format a single route entry for codegen output.
56
+ * Routes without search remain plain strings (params are extracted from
57
+ * the pattern at the type level by ExtractParams).
58
+ * Routes with search become objects with path and search fields.
156
59
  */
157
- function matchesNameColon(code: string, pos: number): boolean {
158
- if (code.slice(pos, pos + 4) !== "name") return false;
159
- if (pos > 0 && /\w/.test(code[pos - 1])) return false;
160
- const afterName = pos + 4;
161
- if (afterName < code.length && /\w/.test(code[afterName])) return false;
162
- let checkPos = afterName;
163
- while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
164
- return code[checkPos] === ":";
165
- }
60
+ export function formatRouteEntry(
61
+ key: string,
62
+ pattern: string,
63
+ _params?: Record<string, string>,
64
+ search?: Record<string, string>,
65
+ ): string {
66
+ const hasSearch = search && Object.keys(search).length > 0;
166
67
 
167
- /** Extract the string value after `name:` starting at the `n` of `name`. */
168
- function extractNameValue(
169
- code: string,
170
- pos: number
171
- ): { value: string; end: number } | null {
172
- pos += 4; // skip 'name'
173
- while (pos < code.length && isWhitespace(code[pos])) pos++;
174
- pos++; // skip ':'
175
- while (pos < code.length && isWhitespace(code[pos])) pos++;
176
- return readString(code, pos);
177
- }
68
+ if (!hasSearch) {
69
+ return ` ${key}: "${pattern}",`;
70
+ }
178
71
 
179
- /**
180
- * Parse a single path() call starting right after the opening paren.
181
- * Returns { name, pattern } or null if the call is unnamed.
182
- */
183
- /**
184
- * Check if code at pos starts with `search` as a standalone identifier
185
- * followed by `:` (an object property).
186
- */
187
- function matchesSearchColon(code: string, pos: number): boolean {
188
- if (code.slice(pos, pos + 6) !== "search") return false;
189
- if (pos > 0 && /\w/.test(code[pos - 1])) return false;
190
- const afterSearch = pos + 6;
191
- if (afterSearch < code.length && /\w/.test(code[afterSearch])) return false;
192
- let checkPos = afterSearch;
193
- while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
194
- return code[checkPos] === ":";
72
+ const searchBody = Object.entries(search!)
73
+ .map(([k, v]) => `${k}: "${v}"`)
74
+ .join(", ");
75
+ return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
195
76
  }
196
77
 
78
+ // ---------------------------------------------------------------------------
79
+ // AST-based route extraction
80
+ // ---------------------------------------------------------------------------
81
+
197
82
  /**
198
- * Extract a search schema object literal after `search:`.
199
- * Parses { key: "type", key2: "type2" } at the position after `search:`.
200
- * Returns the parsed schema and end position, or null if not an object literal.
83
+ * Extract route definitions from source code by walking the TypeScript AST.
84
+ * Finds path() and path.json(), path.md(), etc. call expressions and extracts
85
+ * the pattern, name, params, and optional search schema from each.
86
+ * Skips unnamed paths (no { name: "..." }).
201
87
  */
202
- function extractSearchValue(
203
- code: string,
204
- pos: number
205
- ): { value: Record<string, string>; end: number } | null {
206
- pos += 6; // skip 'search'
207
- while (pos < code.length && isWhitespace(code[pos])) pos++;
208
- pos++; // skip ':'
209
- while (pos < code.length && isWhitespace(code[pos])) pos++;
210
-
211
- if (code[pos] !== "{") return null;
212
- pos++; // skip '{'
213
-
214
- const schema: Record<string, string> = {};
215
-
216
- while (pos < code.length) {
217
- while (pos < code.length && isWhitespace(code[pos])) pos++;
218
- if (code[pos] === "}") return { value: schema, end: pos + 1 };
219
- if (code[pos] === ",") { pos++; continue; }
220
-
221
- // Parse key (identifier or string)
222
- let key: string;
223
- if (code[pos] === '"' || code[pos] === "'") {
224
- const keyStr = readString(code, pos);
225
- if (!keyStr) return null;
226
- key = keyStr.value;
227
- pos = keyStr.end;
228
- } else {
229
- const keyStart = pos;
230
- while (pos < code.length && /[\w$]/.test(code[pos])) pos++;
231
- if (pos === keyStart) return null;
232
- key = code.slice(keyStart, pos);
88
+ export function extractRoutesFromSource(
89
+ code: string
90
+ ): Array<{ name: string; pattern: string; params?: Record<string, string>; search?: Record<string, string> }> {
91
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
92
+ const routes: Array<{ name: string; pattern: string; params?: Record<string, string>; search?: Record<string, string> }> = [];
93
+
94
+ function visit(node: ts.Node) {
95
+ if (ts.isCallExpression(node)) {
96
+ const callee = node.expression;
97
+ const isPath =
98
+ (ts.isIdentifier(callee) && callee.text === "path") ||
99
+ (ts.isPropertyAccessExpression(callee) &&
100
+ ts.isIdentifier(callee.expression) && callee.expression.text === "path");
101
+
102
+ if (isPath && node.arguments.length >= 1) {
103
+ const route = extractRouteFromCallExpression(node);
104
+ if (route) routes.push(route);
105
+ }
233
106
  }
234
-
235
- // Skip colon
236
- while (pos < code.length && isWhitespace(code[pos])) pos++;
237
- if (code[pos] !== ":") return null;
238
- pos++;
239
- while (pos < code.length && isWhitespace(code[pos])) pos++;
240
-
241
- // Parse value (must be a string literal)
242
- const valStr = readString(code, pos);
243
- if (!valStr) return null;
244
- schema[key] = valStr.value;
245
- pos = valStr.end;
107
+ ts.forEachChild(node, visit);
246
108
  }
247
109
 
248
- return null;
110
+ visit(sourceFile);
111
+ return routes;
249
112
  }
250
113
 
251
- function parsePathCall(
252
- code: string,
253
- pos: number
254
- ): { name: string; pattern: string; search?: Record<string, string> } | null {
255
- // Skip whitespace to first argument
256
- while (pos < code.length && isWhitespace(code[pos])) pos++;
257
-
258
- // First argument must be a string literal (the pattern)
259
- const patternStr = readString(code, pos);
260
- if (!patternStr) return null;
261
- const pattern = patternStr.value;
262
- pos = patternStr.end;
263
-
264
- // Scan the rest of the call tracking depth.
265
- // depth=1: inside path(), depth=2: inside an object/paren at top level of call.
266
- // We look for `name: "..."` and `search: { ... }` at depth 2 (options object properties).
267
- let depth = 1;
114
+ function extractRouteFromCallExpression(
115
+ node: ts.CallExpression
116
+ ): { name: string; pattern: string; params?: Record<string, string>; search?: Record<string, string> } | null {
117
+ const patternNode = node.arguments[0];
118
+ const pattern = getStringValue(patternNode);
119
+ if (pattern === null) return null;
120
+
268
121
  let name: string | null = null;
269
122
  let search: Record<string, string> | undefined;
270
123
 
271
- while (pos < code.length && depth > 0) {
272
- const ch = code[pos];
273
-
274
- if (isWhitespace(ch)) {
275
- pos++;
276
- continue;
277
- }
278
-
279
- // Line comment
280
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
281
- pos += 2;
282
- while (pos < code.length && code[pos] !== "\n") pos++;
283
- continue;
124
+ for (let i = 1; i < node.arguments.length; i++) {
125
+ const arg = node.arguments[i];
126
+ if (ts.isObjectLiteralExpression(arg)) {
127
+ for (const prop of arg.properties) {
128
+ if (!ts.isPropertyAssignment(prop)) continue;
129
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null;
130
+ if (propName === "name") {
131
+ name = getStringValue(prop.initializer);
132
+ } else if (propName === "search" && ts.isObjectLiteralExpression(prop.initializer)) {
133
+ search = extractObjectStringProperties(prop.initializer);
134
+ }
135
+ }
284
136
  }
137
+ }
285
138
 
286
- // Block comment
287
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
288
- pos += 2;
289
- while (
290
- pos < code.length - 1 &&
291
- !(code[pos] === "*" && code[pos + 1] === "/")
292
- )
293
- pos++;
294
- pos += 2;
295
- continue;
296
- }
139
+ if (!name) return null;
140
+ const params = extractParamsFromPattern(pattern);
141
+ return {
142
+ name,
143
+ pattern,
144
+ ...(params ? { params } : {}),
145
+ ...(search && Object.keys(search).length > 0 ? { search } : {}),
146
+ };
147
+ }
297
148
 
298
- // At depth 2 (inside an object at call top-level), look for name: "..." and search: { ... }
299
- if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
300
- const nameResult = extractNameValue(code, pos);
301
- if (nameResult) {
302
- name = nameResult.value;
303
- pos = nameResult.end;
304
- continue;
305
- }
306
- }
149
+ // ---------------------------------------------------------------------------
150
+ // Code generation
151
+ // ---------------------------------------------------------------------------
307
152
 
308
- if (depth === 2 && ch === "s" && matchesSearchColon(code, pos)) {
309
- const searchResult = extractSearchValue(code, pos);
310
- if (searchResult) {
311
- search = searchResult.value;
312
- pos = searchResult.end;
313
- continue;
314
- }
153
+ /**
154
+ * Generate a per-module types file from extracted routes.
155
+ * Output has zero imports, preventing circular references.
156
+ */
157
+ export function generatePerModuleTypesSource(
158
+ routes: Array<{ name: string; pattern: string; params?: Record<string, string>; search?: Record<string, string> }>
159
+ ): string {
160
+ const valid = routes.filter(({ name }) => {
161
+ if (!name || /["'\\`\n\r]/.test(name)) {
162
+ console.warn(`[rsc-router] Skipping route with invalid name: ${JSON.stringify(name)}`);
163
+ return false;
315
164
  }
165
+ return true;
166
+ });
316
167
 
317
- // Skip string literals.
318
- // Treat ' preceded by a word char as an apostrophe (e.g. "shouldn't"),
319
- // not a string delimiter. In valid JS/TS, opening ' is never preceded
320
- // by a word character.
321
- if (ch === '"' || ch === "`" || (ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1])))) {
322
- pos = skipStringLiteral(code, pos);
168
+ // Deduplicate by name (first definition wins — primary route before variants)
169
+ const deduped = new Map<string, { pattern: string; params?: Record<string, string>; search?: Record<string, string> }>();
170
+ for (const { name, pattern, params, search } of valid) {
171
+ if (deduped.has(name)) {
172
+ console.warn(`[rsc-router] Duplicate route name "${name}" keeping first definition`);
323
173
  continue;
324
174
  }
325
-
326
- // Track depth
327
- if (ch === "(" || ch === "{" || ch === "[") depth++;
328
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
329
-
330
- pos++;
175
+ deduped.set(name, { pattern, params, search });
331
176
  }
332
-
333
- if (name === null) return null;
334
- return { name, pattern, ...(search ? { search } : {}) };
177
+ const sorted = [...deduped.entries()]
178
+ .sort(([a], [b]) => a.localeCompare(b));
179
+ const body = sorted
180
+ .map(([name, { pattern, params, search }]) => {
181
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
182
+ return formatRouteEntry(key, pattern, params, search);
183
+ })
184
+ .join("\n");
185
+ return `// Auto-generated by @rangojs/router - do not edit\nexport const routes = {\n${body}\n} as const;\nexport type routes = typeof routes;\n`;
335
186
  }
336
187
 
337
188
  /**
@@ -350,14 +201,9 @@ export function generateRouteTypesSource(
350
201
  const objectBody = entries
351
202
  .map(([name, pattern]) => {
352
203
  const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
204
+ const params = extractParamsFromPattern(pattern);
353
205
  const search = searchSchemas?.[name];
354
- if (search && Object.keys(search).length > 0) {
355
- const searchBody = Object.entries(search)
356
- .map(([k, v]) => `${k}: "${v}"`)
357
- .join(", ");
358
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
359
- }
360
- return ` ${key}: "${pattern}",`;
206
+ return formatRouteEntry(key, pattern, params, search);
361
207
  })
362
208
  .join("\n");
363
209
 
@@ -454,8 +300,36 @@ export function writePerModuleRouteTypes(root: string, filter?: ScanFilter): voi
454
300
  }
455
301
  }
456
302
 
303
+ /**
304
+ * Find all variable names assigned to urls() calls in source code.
305
+ * e.g. `export const patterns = urls(...)` → ["patterns"]
306
+ */
307
+ function findUrlsVariableNames(code: string): string[] {
308
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
309
+ const names: string[] = [];
310
+
311
+ function visit(node: ts.Node) {
312
+ if (
313
+ ts.isVariableDeclaration(node) &&
314
+ ts.isIdentifier(node.name) &&
315
+ node.initializer &&
316
+ ts.isCallExpression(node.initializer)
317
+ ) {
318
+ const callee = node.initializer.expression;
319
+ if (ts.isIdentifier(callee) && callee.text === "urls") {
320
+ names.push(node.name.text);
321
+ }
322
+ }
323
+ ts.forEachChild(node, visit);
324
+ }
325
+
326
+ visit(sourceFile);
327
+ return names;
328
+ }
329
+
457
330
  /**
458
331
  * Generate per-module route types for a single url module file.
332
+ * Follows include() calls recursively to produce the full route tree.
459
333
  * No-ops if the file doesn't contain `urls(` or has no named routes.
460
334
  */
461
335
  export function writePerModuleRouteTypesForFile(filePath: string): void {
@@ -463,7 +337,32 @@ export function writePerModuleRouteTypesForFile(filePath: string): void {
463
337
  const source = readFileSync(filePath, "utf-8");
464
338
  if (!source.includes("urls(")) return;
465
339
 
466
- const routes = extractRoutesFromSource(source);
340
+ const varNames = findUrlsVariableNames(source);
341
+
342
+ type Route = { name: string; pattern: string; params?: Record<string, string>; search?: Record<string, string> };
343
+ let routes: Route[];
344
+
345
+ if (varNames.length > 0) {
346
+ // Follow includes recursively via the combined route map builder.
347
+ // The visited set in buildCombinedRouteMapWithSearch prevents infinite loops.
348
+ routes = [];
349
+ for (const varName of varNames) {
350
+ const { routes: routeMap, searchSchemas } = buildCombinedRouteMapWithSearch(filePath, varName);
351
+ for (const [name, pattern] of Object.entries(routeMap)) {
352
+ const params = extractParamsFromPattern(pattern);
353
+ routes.push({
354
+ name,
355
+ pattern,
356
+ ...(params ? { params } : {}),
357
+ ...(searchSchemas[name] ? { search: searchSchemas[name] } : {}),
358
+ });
359
+ }
360
+ }
361
+ } else {
362
+ // Fallback: no urls() variable found, extract path() calls directly
363
+ routes = extractRoutesFromSource(source);
364
+ }
365
+
467
366
  if (routes.length === 0) return;
468
367
 
469
368
  const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
@@ -479,121 +378,58 @@ export function writePerModuleRouteTypesForFile(filePath: string): void {
479
378
  }
480
379
 
481
380
  // ---------------------------------------------------------------------------
482
- // Static include() parsing
381
+ // AST-based include() parsing
483
382
  // ---------------------------------------------------------------------------
484
383
 
485
384
  /**
486
- * Extract include() calls from source code by statically parsing.
385
+ * Extract include() calls from source code by walking the TypeScript AST.
487
386
  * Returns the path prefix, variable name, and optional name prefix for each.
488
387
  */
489
388
  export function extractIncludesFromSource(
490
389
  code: string
491
390
  ): Array<{ pathPrefix: string; variableName: string; namePrefix: string | null }> {
492
- const results: Array<{
493
- pathPrefix: string;
494
- variableName: string;
495
- namePrefix: string | null;
496
- }> = [];
497
- const regex = /\binclude\s*\(/g;
498
- let match;
499
-
500
- while ((match = regex.exec(code)) !== null) {
501
- const result = parseIncludeCall(code, match.index + match[0].length);
502
- if (result) results.push(result);
391
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
392
+ const results: Array<{ pathPrefix: string; variableName: string; namePrefix: string | null }> = [];
393
+
394
+ function visit(node: ts.Node) {
395
+ if (ts.isCallExpression(node)) {
396
+ const callee = node.expression;
397
+ if (ts.isIdentifier(callee) && callee.text === "include") {
398
+ const result = extractIncludeFromCallExpression(node);
399
+ if (result) results.push(result);
400
+ }
401
+ }
402
+ ts.forEachChild(node, visit);
503
403
  }
504
404
 
405
+ visit(sourceFile);
505
406
  return results;
506
407
  }
507
408
 
508
- /**
509
- * Parse a single include() call starting right after the opening paren.
510
- * Expects: include("prefix", variableName, { name: "prefix" })
511
- */
512
- function parseIncludeCall(
513
- code: string,
514
- pos: number
515
- ): {
516
- pathPrefix: string;
517
- variableName: string;
518
- namePrefix: string | null;
519
- } | null {
520
- // Skip whitespace to first argument
521
- while (pos < code.length && isWhitespace(code[pos])) pos++;
522
-
523
- // First arg: string literal (pathPrefix)
524
- const prefixStr = readString(code, pos);
525
- if (!prefixStr) return null;
526
- const pathPrefix = prefixStr.value;
527
- pos = prefixStr.end;
528
-
529
- // Comma
530
- while (pos < code.length && isWhitespace(code[pos])) pos++;
531
- if (pos >= code.length || code[pos] !== ",") return null;
532
- pos++;
533
- while (pos < code.length && isWhitespace(code[pos])) pos++;
534
-
535
- // Second arg: identifier (variableName)
536
- const varStart = pos;
537
- while (pos < code.length && /[\w$]/.test(code[pos])) pos++;
538
- if (pos === varStart) return null;
539
- const variableName = code.slice(varStart, pos);
540
-
541
- // Scan rest of call for optional { name: "..." }
542
- let namePrefix: string | null = null;
543
- let depth = 1; // inside include()
409
+ function extractIncludeFromCallExpression(
410
+ node: ts.CallExpression
411
+ ): { pathPrefix: string; variableName: string; namePrefix: string | null } | null {
412
+ if (node.arguments.length < 2) return null;
544
413
 
545
- while (pos < code.length && depth > 0) {
546
- const ch = code[pos];
414
+ const pathPrefix = getStringValue(node.arguments[0]);
415
+ if (pathPrefix === null) return null;
547
416
 
548
- if (isWhitespace(ch)) {
549
- pos++;
550
- continue;
551
- }
417
+ const secondArg = node.arguments[1];
418
+ if (!ts.isIdentifier(secondArg)) return null;
419
+ const variableName = secondArg.text;
552
420
 
553
- // Line comment
554
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
555
- pos += 2;
556
- while (pos < code.length && code[pos] !== "\n") pos++;
557
- continue;
558
- }
559
-
560
- // Block comment
561
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
562
- pos += 2;
563
- while (
564
- pos < code.length - 1 &&
565
- !(code[pos] === "*" && code[pos + 1] === "/")
566
- )
567
- pos++;
568
- pos += 2;
569
- continue;
570
- }
571
-
572
- // At depth 2 (inside options object), look for name: "..."
573
- if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
574
- const nameResult = extractNameValue(code, pos);
575
- if (nameResult) {
576
- namePrefix = nameResult.value;
577
- pos = nameResult.end;
578
- continue;
421
+ let namePrefix: string | null = null;
422
+ if (node.arguments.length >= 3) {
423
+ const thirdArg = node.arguments[2];
424
+ if (ts.isObjectLiteralExpression(thirdArg)) {
425
+ for (const prop of thirdArg.properties) {
426
+ if (!ts.isPropertyAssignment(prop)) continue;
427
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null;
428
+ if (propName === "name") {
429
+ namePrefix = getStringValue(prop.initializer);
430
+ }
579
431
  }
580
432
  }
581
-
582
- // Skip string literals
583
- if (
584
- ch === '"' ||
585
- ch === "`" ||
586
- (ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1])))
587
- ) {
588
- pos = skipStringLiteral(code, pos);
589
- continue;
590
- }
591
-
592
- // Track depth
593
- if (ch === "(" || ch === "{" || ch === "[") depth++;
594
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
595
-
596
- pos++;
597
433
  }
598
434
 
599
435
  return { pathPrefix, variableName, namePrefix };
@@ -665,69 +501,37 @@ function resolveImportPath(
665
501
  // urls() block extraction for same-file variables
666
502
  // ---------------------------------------------------------------------------
667
503
 
668
- function escapeRegExp(s: string): string {
669
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
670
- }
671
-
672
504
  /**
673
- * Extract the source of a specific `const varName = urls(...)` block.
674
- * Used for same-file variables where include() references a urls() defined
675
- * in the same module rather than imported.
505
+ * Extract the source of a specific `const varName = urls(...)` call using
506
+ * the TypeScript AST. Returns the full text of the urls() call expression.
676
507
  */
677
508
  function extractUrlsBlockForVariable(
678
509
  code: string,
679
510
  varName: string
680
511
  ): string | null {
681
- const pattern = new RegExp(
682
- `(?:export\\s+)?(?:const|let|var)\\s+${escapeRegExp(varName)}\\s*=\\s*urls\\s*\\(`
683
- );
684
- const match = pattern.exec(code);
685
- if (!match) return null;
686
-
687
- // Start from the opening paren of urls(
688
- const openParen = match.index + match[0].length - 1;
689
- let depth = 1;
690
- let pos = openParen + 1;
512
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
513
+ let result: string | null = null;
691
514
 
692
- while (pos < code.length && depth > 0) {
693
- const ch = code[pos];
694
-
695
- // Skip strings
515
+ function visit(node: ts.Node) {
516
+ if (result) return;
696
517
  if (
697
- ch === '"' ||
698
- ch === "`" ||
699
- (ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1])))
518
+ ts.isVariableDeclaration(node) &&
519
+ ts.isIdentifier(node.name) &&
520
+ node.name.text === varName &&
521
+ node.initializer &&
522
+ ts.isCallExpression(node.initializer)
700
523
  ) {
701
- pos = skipStringLiteral(code, pos);
702
- continue;
703
- }
704
-
705
- // Line comment
706
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
707
- pos += 2;
708
- while (pos < code.length && code[pos] !== "\n") pos++;
709
- continue;
710
- }
711
-
712
- // Block comment
713
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
714
- pos += 2;
715
- while (
716
- pos < code.length - 1 &&
717
- !(code[pos] === "*" && code[pos + 1] === "/")
718
- )
719
- pos++;
720
- pos += 2;
721
- continue;
524
+ const callee = node.initializer.expression;
525
+ if (ts.isIdentifier(callee) && callee.text === "urls") {
526
+ result = node.initializer.getText(sourceFile);
527
+ return;
528
+ }
722
529
  }
723
-
724
- if (ch === "(" || ch === "{" || ch === "[") depth++;
725
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
726
-
727
- pos++;
530
+ ts.forEachChild(node, visit);
728
531
  }
729
532
 
730
- return code.slice(openParen, pos);
533
+ visit(sourceFile);
534
+ return result;
731
535
  }
732
536
 
733
537
  // ---------------------------------------------------------------------------
@@ -747,7 +551,10 @@ export function buildCombinedRouteMap(
747
551
  visited = visited ?? new Set();
748
552
  const realPath = resolve(filePath);
749
553
  const key = variableName ? `${realPath}:${variableName}` : realPath;
750
- if (visited.has(key)) return {};
554
+ if (visited.has(key)) {
555
+ console.warn(`[rsc-router] Circular include detected, skipping: ${key}`);
556
+ return {};
557
+ }
751
558
  visited.add(key);
752
559
 
753
560
  let source: string;
@@ -842,7 +649,10 @@ function buildCombinedRouteMapWithSearch(
842
649
  visited = visited ?? new Set();
843
650
  const realPath = resolve(filePath);
844
651
  const key = variableName ? `${realPath}:${variableName}` : realPath;
845
- if (visited.has(key)) return { routes: {}, searchSchemas: {} };
652
+ if (visited.has(key)) {
653
+ console.warn(`[rsc-router] Circular include detected, skipping: ${key}`);
654
+ return { routes: {}, searchSchemas: {} };
655
+ }
846
656
  visited.add(key);
847
657
 
848
658
  let source: string;
@@ -1025,7 +835,7 @@ export function writeCombinedRouteTypes(root: string, knownRouterFiles?: string[
1025
835
  // or other dynamic code. During HMR (file watcher), always write so
1026
836
  // newly added routes appear immediately.
1027
837
  if (opts?.preserveIfLarger && existing) {
1028
- const existingCount = (existing.match(/^\s+["a-zA-Z_$][^:]*:\s*"/gm) || []).length;
838
+ const existingCount = (existing.match(/^\s+["a-zA-Z_$][^:]*:\s*["{]/gm) || []).length;
1029
839
  const newCount = Object.keys(result.routes).length;
1030
840
  if (existingCount > newCount) {
1031
841
  continue;