@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/dist/bin/rango.js CHANGED
@@ -1,22 +1,93 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin/rango.ts
4
- import { resolve as resolve2 } from "node:path";
4
+ import { resolve as resolve2, dirname as dirname2 } from "node:path";
5
+ import { readFileSync as readFileSync2, statSync } from "node:fs";
5
6
 
6
7
  // src/build/generate-route-types.ts
7
8
  import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from "node:fs";
8
9
  import { join, dirname, resolve, relative, basename as pathBasename } from "node:path";
9
10
  import picomatch from "picomatch";
11
+ import ts from "typescript";
12
+ function getStringValue(node) {
13
+ if (ts.isStringLiteral(node)) return node.text;
14
+ if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
15
+ return null;
16
+ }
17
+ function extractObjectStringProperties(node) {
18
+ const result = {};
19
+ for (const prop of node.properties) {
20
+ if (!ts.isPropertyAssignment(prop)) continue;
21
+ const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null;
22
+ if (!key) continue;
23
+ const val = getStringValue(prop.initializer);
24
+ if (val !== null) result[key] = val;
25
+ }
26
+ return result;
27
+ }
28
+ function extractParamsFromPattern(pattern) {
29
+ const params = {};
30
+ const regex = /:([a-zA-Z_$][\w$]*)(?:\([^)]+\))?(\?)?/g;
31
+ let match;
32
+ while ((match = regex.exec(pattern)) !== null) {
33
+ params[match[1]] = match[2] ? "string?" : "string";
34
+ }
35
+ return Object.keys(params).length > 0 ? params : void 0;
36
+ }
37
+ function formatRouteEntry(key, pattern, _params, search) {
38
+ const hasSearch = search && Object.keys(search).length > 0;
39
+ if (!hasSearch) {
40
+ return ` ${key}: "${pattern}",`;
41
+ }
42
+ const searchBody = Object.entries(search).map(([k, v]) => `${k}: "${v}"`).join(", ");
43
+ return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
44
+ }
10
45
  function extractRoutesFromSource(code) {
46
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
11
47
  const routes = [];
12
- const regex = /\bpath(?:\.[a-zA-Z_$][\w$]*)?\s*\(/g;
13
- let match;
14
- while ((match = regex.exec(code)) !== null) {
15
- const result = parsePathCall(code, match.index + match[0].length);
16
- if (result) routes.push(result);
48
+ function visit(node) {
49
+ if (ts.isCallExpression(node)) {
50
+ const callee = node.expression;
51
+ const isPath = ts.isIdentifier(callee) && callee.text === "path" || ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "path";
52
+ if (isPath && node.arguments.length >= 1) {
53
+ const route = extractRouteFromCallExpression(node);
54
+ if (route) routes.push(route);
55
+ }
56
+ }
57
+ ts.forEachChild(node, visit);
17
58
  }
59
+ visit(sourceFile);
18
60
  return routes;
19
61
  }
62
+ function extractRouteFromCallExpression(node) {
63
+ const patternNode = node.arguments[0];
64
+ const pattern = getStringValue(patternNode);
65
+ if (pattern === null) return null;
66
+ let name = null;
67
+ let search;
68
+ for (let i = 1; i < node.arguments.length; i++) {
69
+ const arg = node.arguments[i];
70
+ if (ts.isObjectLiteralExpression(arg)) {
71
+ for (const prop of arg.properties) {
72
+ if (!ts.isPropertyAssignment(prop)) continue;
73
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null;
74
+ if (propName === "name") {
75
+ name = getStringValue(prop.initializer);
76
+ } else if (propName === "search" && ts.isObjectLiteralExpression(prop.initializer)) {
77
+ search = extractObjectStringProperties(prop.initializer);
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if (!name) return null;
83
+ const params = extractParamsFromPattern(pattern);
84
+ return {
85
+ name,
86
+ pattern,
87
+ ...params ? { params } : {},
88
+ ...search && Object.keys(search).length > 0 ? { search } : {}
89
+ };
90
+ }
20
91
  function generatePerModuleTypesSource(routes) {
21
92
  const valid = routes.filter(({ name }) => {
22
93
  if (!name || /["'\\`\n\r]/.test(name)) {
@@ -26,17 +97,17 @@ function generatePerModuleTypesSource(routes) {
26
97
  return true;
27
98
  });
28
99
  const deduped = /* @__PURE__ */ new Map();
29
- for (const { name, pattern, search } of valid) {
30
- deduped.set(name, { pattern, search });
100
+ for (const { name, pattern, params, search } of valid) {
101
+ if (deduped.has(name)) {
102
+ console.warn(`[rsc-router] Duplicate route name "${name}" \u2014 keeping first definition`);
103
+ continue;
104
+ }
105
+ deduped.set(name, { pattern, params, search });
31
106
  }
32
107
  const sorted = [...deduped.entries()].sort(([a], [b]) => a.localeCompare(b));
33
- const body = sorted.map(([name, { pattern, search }]) => {
108
+ const body = sorted.map(([name, { pattern, params, search }]) => {
34
109
  const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
35
- if (search && Object.keys(search).length > 0) {
36
- const searchBody = Object.entries(search).map(([k, v]) => `${k}: "${v}"`).join(", ");
37
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
38
- }
39
- return ` ${key}: "${pattern}",`;
110
+ return formatRouteEntry(key, pattern, params, search);
40
111
  }).join("\n");
41
112
  return `// Auto-generated by @rangojs/router - do not edit
42
113
  export const routes = {
@@ -45,187 +116,27 @@ ${body}
45
116
  export type routes = typeof routes;
46
117
  `;
47
118
  }
48
- function isWhitespace(ch) {
49
- return ch === " " || ch === " " || ch === "\n" || ch === "\r";
50
- }
51
- function readString(code, pos) {
52
- const quote = code[pos];
53
- if (quote !== '"' && quote !== "'") return null;
54
- let value = "";
55
- pos++;
56
- while (pos < code.length) {
57
- if (code[pos] === "\\") {
58
- pos++;
59
- if (pos < code.length) {
60
- value += code[pos];
61
- pos++;
62
- }
63
- continue;
64
- }
65
- if (code[pos] === quote) {
66
- return { value, end: pos + 1 };
67
- }
68
- value += code[pos];
69
- pos++;
70
- }
71
- return null;
72
- }
73
- function skipStringLiteral(code, pos) {
74
- const quote = code[pos];
75
- if (quote === "`") {
76
- pos++;
77
- while (pos < code.length) {
78
- if (code[pos] === "\\") {
79
- pos += 2;
80
- continue;
81
- }
82
- if (code[pos] === "`") return pos + 1;
83
- if (code[pos] === "$" && pos + 1 < code.length && code[pos + 1] === "{") {
84
- pos += 2;
85
- let braceDepth = 1;
86
- while (pos < code.length && braceDepth > 0) {
87
- if (code[pos] === "{") braceDepth++;
88
- else if (code[pos] === "}") braceDepth--;
89
- else if (code[pos] === "\\") pos++;
90
- else if (code[pos] === '"' || code[pos] === "'" || code[pos] === "`") {
91
- pos = skipStringLiteral(code, pos);
92
- continue;
93
- }
94
- if (braceDepth > 0) pos++;
95
- }
96
- continue;
97
- }
98
- pos++;
99
- }
100
- return pos;
101
- }
102
- pos++;
103
- while (pos < code.length) {
104
- if (code[pos] === "\\") {
105
- pos += 2;
106
- continue;
107
- }
108
- if (code[pos] === quote) return pos + 1;
109
- pos++;
110
- }
111
- return pos;
112
- }
113
- function matchesNameColon(code, pos) {
114
- if (code.slice(pos, pos + 4) !== "name") return false;
115
- if (pos > 0 && /\w/.test(code[pos - 1])) return false;
116
- const afterName = pos + 4;
117
- if (afterName < code.length && /\w/.test(code[afterName])) return false;
118
- let checkPos = afterName;
119
- while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
120
- return code[checkPos] === ":";
121
- }
122
- function extractNameValue(code, pos) {
123
- pos += 4;
124
- while (pos < code.length && isWhitespace(code[pos])) pos++;
125
- pos++;
126
- while (pos < code.length && isWhitespace(code[pos])) pos++;
127
- return readString(code, pos);
128
- }
129
- function matchesSearchColon(code, pos) {
130
- if (code.slice(pos, pos + 6) !== "search") return false;
131
- if (pos > 0 && /\w/.test(code[pos - 1])) return false;
132
- const afterSearch = pos + 6;
133
- if (afterSearch < code.length && /\w/.test(code[afterSearch])) return false;
134
- let checkPos = afterSearch;
135
- while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
136
- return code[checkPos] === ":";
137
- }
138
- function extractSearchValue(code, pos) {
139
- pos += 6;
140
- while (pos < code.length && isWhitespace(code[pos])) pos++;
141
- pos++;
142
- while (pos < code.length && isWhitespace(code[pos])) pos++;
143
- if (code[pos] !== "{") return null;
144
- pos++;
145
- const schema = {};
146
- while (pos < code.length) {
147
- while (pos < code.length && isWhitespace(code[pos])) pos++;
148
- if (code[pos] === "}") return { value: schema, end: pos + 1 };
149
- if (code[pos] === ",") {
150
- pos++;
151
- continue;
152
- }
153
- let key;
154
- if (code[pos] === '"' || code[pos] === "'") {
155
- const keyStr = readString(code, pos);
156
- if (!keyStr) return null;
157
- key = keyStr.value;
158
- pos = keyStr.end;
159
- } else {
160
- const keyStart = pos;
161
- while (pos < code.length && /[\w$]/.test(code[pos])) pos++;
162
- if (pos === keyStart) return null;
163
- key = code.slice(keyStart, pos);
164
- }
165
- while (pos < code.length && isWhitespace(code[pos])) pos++;
166
- if (code[pos] !== ":") return null;
167
- pos++;
168
- while (pos < code.length && isWhitespace(code[pos])) pos++;
169
- const valStr = readString(code, pos);
170
- if (!valStr) return null;
171
- schema[key] = valStr.value;
172
- pos = valStr.end;
119
+ function generateRouteTypesSource(routeManifest, searchSchemas) {
120
+ const entries = Object.entries(routeManifest).sort(
121
+ ([a], [b]) => a.localeCompare(b)
122
+ );
123
+ const objectBody = entries.map(([name, pattern]) => {
124
+ const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
125
+ const params = extractParamsFromPattern(pattern);
126
+ const search = searchSchemas?.[name];
127
+ return formatRouteEntry(key, pattern, params, search);
128
+ }).join("\n");
129
+ return `// Auto-generated by @rangojs/router - do not edit
130
+ export const NamedRoutes = {
131
+ ${objectBody}
132
+ } as const;
133
+
134
+ declare global {
135
+ namespace RSCRouter {
136
+ interface GeneratedRouteMap extends Readonly<typeof NamedRoutes> {}
173
137
  }
174
- return null;
175
138
  }
176
- function parsePathCall(code, pos) {
177
- while (pos < code.length && isWhitespace(code[pos])) pos++;
178
- const patternStr = readString(code, pos);
179
- if (!patternStr) return null;
180
- const pattern = patternStr.value;
181
- pos = patternStr.end;
182
- let depth = 1;
183
- let name = null;
184
- let search;
185
- while (pos < code.length && depth > 0) {
186
- const ch = code[pos];
187
- if (isWhitespace(ch)) {
188
- pos++;
189
- continue;
190
- }
191
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
192
- pos += 2;
193
- while (pos < code.length && code[pos] !== "\n") pos++;
194
- continue;
195
- }
196
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
197
- pos += 2;
198
- while (pos < code.length - 1 && !(code[pos] === "*" && code[pos + 1] === "/"))
199
- pos++;
200
- pos += 2;
201
- continue;
202
- }
203
- if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
204
- const nameResult = extractNameValue(code, pos);
205
- if (nameResult) {
206
- name = nameResult.value;
207
- pos = nameResult.end;
208
- continue;
209
- }
210
- }
211
- if (depth === 2 && ch === "s" && matchesSearchColon(code, pos)) {
212
- const searchResult = extractSearchValue(code, pos);
213
- if (searchResult) {
214
- search = searchResult.value;
215
- pos = searchResult.end;
216
- continue;
217
- }
218
- }
219
- if (ch === '"' || ch === "`" || ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1]))) {
220
- pos = skipStringLiteral(code, pos);
221
- continue;
222
- }
223
- if (ch === "(" || ch === "{" || ch === "[") depth++;
224
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
225
- pos++;
226
- }
227
- if (name === null) return null;
228
- return { name, pattern, ...search ? { search } : {} };
139
+ `;
229
140
  }
230
141
  function findTsFiles(dir, filter) {
231
142
  const results = [];
@@ -248,11 +159,44 @@ function findTsFiles(dir, filter) {
248
159
  }
249
160
  return results;
250
161
  }
162
+ function findUrlsVariableNames(code) {
163
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
164
+ const names = [];
165
+ function visit(node) {
166
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
167
+ const callee = node.initializer.expression;
168
+ if (ts.isIdentifier(callee) && callee.text === "urls") {
169
+ names.push(node.name.text);
170
+ }
171
+ }
172
+ ts.forEachChild(node, visit);
173
+ }
174
+ visit(sourceFile);
175
+ return names;
176
+ }
251
177
  function writePerModuleRouteTypesForFile(filePath) {
252
178
  try {
253
179
  const source = readFileSync(filePath, "utf-8");
254
180
  if (!source.includes("urls(")) return;
255
- const routes = extractRoutesFromSource(source);
181
+ const varNames = findUrlsVariableNames(source);
182
+ let routes;
183
+ if (varNames.length > 0) {
184
+ routes = [];
185
+ for (const varName of varNames) {
186
+ const { routes: routeMap, searchSchemas } = buildCombinedRouteMapWithSearch(filePath, varName);
187
+ for (const [name, pattern] of Object.entries(routeMap)) {
188
+ const params = extractParamsFromPattern(pattern);
189
+ routes.push({
190
+ name,
191
+ pattern,
192
+ ...params ? { params } : {},
193
+ ...searchSchemas[name] ? { search: searchSchemas[name] } : {}
194
+ });
195
+ }
196
+ }
197
+ } else {
198
+ routes = extractRoutesFromSource(source);
199
+ }
256
200
  if (routes.length === 0) return;
257
201
  const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
258
202
  const genSource = generatePerModuleTypesSource(routes);
@@ -265,23 +209,300 @@ function writePerModuleRouteTypesForFile(filePath) {
265
209
  console.warn(`[rsc-router] Failed to generate route types for ${filePath}: ${err.message}`);
266
210
  }
267
211
  }
212
+ function extractIncludesFromSource(code) {
213
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
214
+ const results = [];
215
+ function visit(node) {
216
+ if (ts.isCallExpression(node)) {
217
+ const callee = node.expression;
218
+ if (ts.isIdentifier(callee) && callee.text === "include") {
219
+ const result = extractIncludeFromCallExpression(node);
220
+ if (result) results.push(result);
221
+ }
222
+ }
223
+ ts.forEachChild(node, visit);
224
+ }
225
+ visit(sourceFile);
226
+ return results;
227
+ }
228
+ function extractIncludeFromCallExpression(node) {
229
+ if (node.arguments.length < 2) return null;
230
+ const pathPrefix = getStringValue(node.arguments[0]);
231
+ if (pathPrefix === null) return null;
232
+ const secondArg = node.arguments[1];
233
+ if (!ts.isIdentifier(secondArg)) return null;
234
+ const variableName = secondArg.text;
235
+ let namePrefix = null;
236
+ if (node.arguments.length >= 3) {
237
+ const thirdArg = node.arguments[2];
238
+ if (ts.isObjectLiteralExpression(thirdArg)) {
239
+ for (const prop of thirdArg.properties) {
240
+ if (!ts.isPropertyAssignment(prop)) continue;
241
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null;
242
+ if (propName === "name") {
243
+ namePrefix = getStringValue(prop.initializer);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ return { pathPrefix, variableName, namePrefix };
249
+ }
250
+ function resolveImportedVariable(code, localName) {
251
+ const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
252
+ let match;
253
+ while ((match = importRegex.exec(code)) !== null) {
254
+ const imports = match[1];
255
+ const specifier = match[2];
256
+ const parts = imports.split(",").map((s) => s.trim()).filter(Boolean);
257
+ for (const part of parts) {
258
+ const asMatch = part.match(/^(\w+)\s+as\s+(\w+)$/);
259
+ if (asMatch && asMatch[2] === localName)
260
+ return { specifier, exportedName: asMatch[1] };
261
+ if (part === localName) return { specifier, exportedName: localName };
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+ function resolveImportPath(importSpec, fromFile) {
267
+ if (!importSpec.startsWith(".")) return null;
268
+ const dir = dirname(fromFile);
269
+ let base = importSpec;
270
+ if (base.endsWith(".js")) base = base.slice(0, -3);
271
+ else if (base.endsWith(".mjs")) base = base.slice(0, -4);
272
+ const candidates = [
273
+ resolve(dir, base + ".ts"),
274
+ resolve(dir, base + ".tsx"),
275
+ resolve(dir, base + "/index.ts"),
276
+ resolve(dir, base + "/index.tsx")
277
+ ];
278
+ for (const candidate of candidates) {
279
+ if (existsSync(candidate)) return candidate;
280
+ }
281
+ return null;
282
+ }
283
+ function extractUrlsBlockForVariable(code, varName) {
284
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
285
+ let result = null;
286
+ function visit(node) {
287
+ if (result) return;
288
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === varName && node.initializer && ts.isCallExpression(node.initializer)) {
289
+ const callee = node.initializer.expression;
290
+ if (ts.isIdentifier(callee) && callee.text === "urls") {
291
+ result = node.initializer.getText(sourceFile);
292
+ return;
293
+ }
294
+ }
295
+ ts.forEachChild(node, visit);
296
+ }
297
+ visit(sourceFile);
298
+ return result;
299
+ }
300
+ function buildRouteMapFromBlock(block, fullSource, filePath, visited, searchSchemasOut) {
301
+ const routeMap = {};
302
+ const localRoutes = extractRoutesFromSource(block);
303
+ for (const { name, pattern, search } of localRoutes) {
304
+ routeMap[name] = pattern;
305
+ if (search && searchSchemasOut) {
306
+ searchSchemasOut[name] = search;
307
+ }
308
+ }
309
+ const includes = extractIncludesFromSource(block);
310
+ for (const { pathPrefix, variableName, namePrefix } of includes) {
311
+ let childResult;
312
+ const imported = resolveImportedVariable(fullSource, variableName);
313
+ if (imported) {
314
+ const targetFile = resolveImportPath(imported.specifier, filePath);
315
+ if (!targetFile) continue;
316
+ childResult = buildCombinedRouteMapWithSearch(
317
+ targetFile,
318
+ imported.exportedName,
319
+ visited
320
+ );
321
+ } else {
322
+ childResult = buildCombinedRouteMapWithSearch(filePath, variableName, visited);
323
+ }
324
+ for (const [name, pattern] of Object.entries(childResult.routes)) {
325
+ const prefixedName = namePrefix ? `${namePrefix}.${name}` : name;
326
+ let prefixedPattern;
327
+ if (pattern === "/") {
328
+ prefixedPattern = pathPrefix || "/";
329
+ } else if (pathPrefix.endsWith("/") && pattern.startsWith("/")) {
330
+ prefixedPattern = pathPrefix + pattern.slice(1);
331
+ } else {
332
+ prefixedPattern = pathPrefix + pattern;
333
+ }
334
+ routeMap[prefixedName] = prefixedPattern;
335
+ if (childResult.searchSchemas[name] && searchSchemasOut) {
336
+ searchSchemasOut[prefixedName] = childResult.searchSchemas[name];
337
+ }
338
+ }
339
+ }
340
+ return routeMap;
341
+ }
342
+ function buildCombinedRouteMapWithSearch(filePath, variableName, visited) {
343
+ visited = visited ?? /* @__PURE__ */ new Set();
344
+ const realPath = resolve(filePath);
345
+ const key = variableName ? `${realPath}:${variableName}` : realPath;
346
+ if (visited.has(key)) {
347
+ console.warn(`[rsc-router] Circular include detected, skipping: ${key}`);
348
+ return { routes: {}, searchSchemas: {} };
349
+ }
350
+ visited.add(key);
351
+ let source;
352
+ try {
353
+ source = readFileSync(realPath, "utf-8");
354
+ } catch {
355
+ return { routes: {}, searchSchemas: {} };
356
+ }
357
+ let block;
358
+ if (variableName) {
359
+ const extracted = extractUrlsBlockForVariable(source, variableName);
360
+ if (!extracted) return { routes: {}, searchSchemas: {} };
361
+ block = extracted;
362
+ } else {
363
+ block = source;
364
+ }
365
+ const searchSchemas = {};
366
+ const routes = buildRouteMapFromBlock(block, source, realPath, visited, searchSchemas);
367
+ return { routes, searchSchemas };
368
+ }
369
+ function extractUrlsVariableFromRouter(code) {
370
+ const routesCallMatch = code.match(/\.routes\s*\(\s*([a-zA-Z_$][\w$]*)\s*\)/);
371
+ if (routesCallMatch) return routesCallMatch[1];
372
+ const urlsOptionMatch = code.match(/urls\s*:\s*([a-zA-Z_$][\w$]*)/);
373
+ if (urlsOptionMatch) return urlsOptionMatch[1];
374
+ return null;
375
+ }
376
+ function findRouterFiles(root, filter) {
377
+ const files = findTsFiles(root, filter);
378
+ const result = [];
379
+ for (const filePath of files) {
380
+ if (filePath.includes(".gen.")) continue;
381
+ try {
382
+ const source = readFileSync(filePath, "utf-8");
383
+ if (/\bcreateRouter\s*[<(]/.test(source)) {
384
+ result.push(filePath);
385
+ }
386
+ } catch {
387
+ continue;
388
+ }
389
+ }
390
+ return result;
391
+ }
392
+ function writeCombinedRouteTypes(root, knownRouterFiles, opts) {
393
+ try {
394
+ const oldCombinedPath = join(root, "src", "named-routes.gen.ts");
395
+ if (existsSync(oldCombinedPath)) {
396
+ unlinkSync(oldCombinedPath);
397
+ console.log(`[rsc-router] Removed stale combined route types: ${oldCombinedPath}`);
398
+ }
399
+ } catch {
400
+ }
401
+ const routerFilePaths = knownRouterFiles ?? findRouterFiles(root);
402
+ if (routerFilePaths.length === 0) return;
403
+ for (const routerFilePath of routerFilePaths) {
404
+ let routerSource;
405
+ try {
406
+ routerSource = readFileSync(routerFilePath, "utf-8");
407
+ } catch {
408
+ continue;
409
+ }
410
+ const urlsVarName = extractUrlsVariableFromRouter(routerSource);
411
+ if (!urlsVarName) continue;
412
+ let result;
413
+ const imported = resolveImportedVariable(routerSource, urlsVarName);
414
+ if (imported) {
415
+ const targetFile = resolveImportPath(imported.specifier, routerFilePath);
416
+ if (!targetFile) continue;
417
+ result = buildCombinedRouteMapWithSearch(targetFile, imported.exportedName);
418
+ } else {
419
+ result = buildCombinedRouteMapWithSearch(routerFilePath, urlsVarName);
420
+ }
421
+ const routerBasename = pathBasename(routerFilePath).replace(/\.(tsx?|jsx?)$/, "");
422
+ const outPath = join(dirname(routerFilePath), `${routerBasename}.named-routes.gen.ts`);
423
+ const existing = existsSync(outPath) ? readFileSync(outPath, "utf-8") : null;
424
+ if (Object.keys(result.routes).length === 0) {
425
+ if (!existing) {
426
+ const emptySource = generateRouteTypesSource({});
427
+ writeFileSync(outPath, emptySource);
428
+ }
429
+ continue;
430
+ }
431
+ const hasSearchSchemas = Object.keys(result.searchSchemas).length > 0;
432
+ const source = generateRouteTypesSource(
433
+ result.routes,
434
+ hasSearchSchemas ? result.searchSchemas : void 0
435
+ );
436
+ if (existing !== source) {
437
+ if (opts?.preserveIfLarger && existing) {
438
+ const existingCount = (existing.match(/^\s+["a-zA-Z_$][^:]*:\s*["{]/gm) || []).length;
439
+ const newCount = Object.keys(result.routes).length;
440
+ if (existingCount > newCount) {
441
+ continue;
442
+ }
443
+ }
444
+ writeFileSync(outPath, source);
445
+ console.log(`[rsc-router] Generated route types (${Object.keys(result.routes).length} routes) -> ${outPath}`);
446
+ }
447
+ }
448
+ }
268
449
 
269
450
  // src/bin/rango.ts
270
451
  var [command, ...args] = process.argv.slice(2);
271
- if (command === "extract-names") {
272
- const dir = args[0] ?? "./src";
273
- const resolvedDir = resolve2(dir);
274
- console.log(`[rango] Scanning ${resolvedDir} for url modules...`);
275
- const files = findTsFiles(resolvedDir);
452
+ if (command === "generate") {
453
+ if (args.length === 0) {
454
+ console.error("[rango] Usage: rango generate <file|dir> [file2 ...]");
455
+ process.exit(1);
456
+ }
457
+ const files = [];
458
+ for (const arg of args) {
459
+ const resolved = resolve2(arg);
460
+ try {
461
+ if (statSync(resolved).isDirectory()) {
462
+ files.push(...findTsFiles(resolved));
463
+ } else {
464
+ files.push(resolved);
465
+ }
466
+ } catch {
467
+ console.warn(`[rango] Skipping ${arg}: not found`);
468
+ }
469
+ }
470
+ if (files.length === 0) {
471
+ console.log("[rango] No files to process");
472
+ process.exit(0);
473
+ }
474
+ const routerFiles = [];
276
475
  for (const filePath of files) {
277
- writePerModuleRouteTypesForFile(filePath);
476
+ try {
477
+ const source = readFileSync2(filePath, "utf-8");
478
+ const isRouter = /\bcreateRouter\s*[<(]/.test(source);
479
+ const isUrls = source.includes("urls(");
480
+ if (isRouter) {
481
+ routerFiles.push(filePath);
482
+ }
483
+ if (isUrls) {
484
+ writePerModuleRouteTypesForFile(filePath);
485
+ }
486
+ } catch (err) {
487
+ console.warn(`[rango] Failed to process ${filePath}: ${err.message}`);
488
+ }
489
+ }
490
+ for (const routerFile of routerFiles) {
491
+ writeCombinedRouteTypes(dirname2(routerFile), [routerFile]);
278
492
  }
279
- console.log(`[rango] Scanned ${files.length} file(s)`);
493
+ console.log(`[rango] Processed ${files.length} file(s)${routerFiles.length ? ` (${routerFiles.length} router)` : ""}`);
280
494
  process.exit(0);
281
495
  } else {
282
- console.log(`Usage: rango <command>
496
+ console.log(`Usage: rango generate <file|dir> [file2 ...]
497
+
498
+ Auto-detects file type (createRouter, urls) and generates
499
+ the appropriate .gen.ts route type files.
500
+
501
+ Pass files, directories, or a mix of both.
283
502
 
284
- Commands:
285
- extract-names [dir] Extract route names from url modules (default: ./src)`);
503
+ Examples:
504
+ rango generate src/urls.tsx
505
+ rango generate src/router.tsx src/urls.tsx
506
+ rango generate src/`);
286
507
  process.exit(command ? 1 : 0);
287
508
  }