@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.
- package/README.md +589 -4
- package/dist/bin/rango.js +425 -204
- package/dist/vite/index.js +116 -326
- package/package.json +1 -1
- package/skills/rango/SKILL.md +63 -0
- package/skills/testing/SKILL.md +226 -0
- package/skills/typesafety/SKILL.md +49 -31
- package/src/bin/rango.ts +63 -12
- package/src/build/generate-route-types.ts +260 -450
- package/src/index.rsc.ts +13 -1
- package/src/index.ts +7 -0
- package/src/route-definition.ts +31 -0
- package/src/router/match-middleware/cache-store.ts +1 -1
- package/src/router.ts +13 -6
- package/src/server.ts +13 -158
- package/src/urls.ts +29 -0
- package/src/vite/index.ts +9 -17
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
+
// Param extraction from route patterns
|
|
70
33
|
// ---------------------------------------------------------------------------
|
|
71
34
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
47
|
+
return Object.keys(params).length > 0 ? params : undefined;
|
|
102
48
|
}
|
|
103
49
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
155
|
-
*
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
|
203
|
-
code: string
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
110
|
+
visit(sourceFile);
|
|
111
|
+
return routes;
|
|
249
112
|
}
|
|
250
113
|
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (nameResult) {
|
|
302
|
-
name = nameResult.value;
|
|
303
|
-
pos = nameResult.end;
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Code generation
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
307
152
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
381
|
+
// AST-based include() parsing
|
|
483
382
|
// ---------------------------------------------------------------------------
|
|
484
383
|
|
|
485
384
|
/**
|
|
486
|
-
* Extract include() calls from source code by
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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
|
-
|
|
546
|
-
|
|
414
|
+
const pathPrefix = getStringValue(node.arguments[0]);
|
|
415
|
+
if (pathPrefix === null) return null;
|
|
547
416
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
417
|
+
const secondArg = node.arguments[1];
|
|
418
|
+
if (!ts.isIdentifier(secondArg)) return null;
|
|
419
|
+
const variableName = secondArg.text;
|
|
552
420
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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(...)`
|
|
674
|
-
*
|
|
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
|
|
682
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
// Skip strings
|
|
515
|
+
function visit(node: ts.Node) {
|
|
516
|
+
if (result) return;
|
|
696
517
|
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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))
|
|
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))
|
|
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;
|