@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.
@@ -10,225 +10,85 @@ import { mkdirSync, writeFileSync as writeFileSync2, readFileSync as readFileSyn
10
10
  import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from "node:fs";
11
11
  import { join, dirname, resolve, relative, basename as pathBasename } from "node:path";
12
12
  import picomatch from "picomatch";
13
- function extractRoutesFromSource(code) {
14
- const routes = [];
15
- const regex = /\bpath(?:\.[a-zA-Z_$][\w$]*)?\s*\(/g;
16
- let match;
17
- while ((match = regex.exec(code)) !== null) {
18
- const result = parsePathCall(code, match.index + match[0].length);
19
- if (result) routes.push(result);
20
- }
21
- return routes;
13
+ import ts from "typescript";
14
+ function getStringValue(node) {
15
+ if (ts.isStringLiteral(node)) return node.text;
16
+ if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
17
+ return null;
22
18
  }
23
- function generatePerModuleTypesSource(routes) {
24
- const valid = routes.filter(({ name }) => {
25
- if (!name || /["'\\`\n\r]/.test(name)) {
26
- console.warn(`[rsc-router] Skipping route with invalid name: ${JSON.stringify(name)}`);
27
- return false;
28
- }
29
- return true;
30
- });
31
- const deduped = /* @__PURE__ */ new Map();
32
- for (const { name, pattern, search } of valid) {
33
- deduped.set(name, { pattern, search });
19
+ function extractObjectStringProperties(node) {
20
+ const result = {};
21
+ for (const prop of node.properties) {
22
+ if (!ts.isPropertyAssignment(prop)) continue;
23
+ const key = ts.isIdentifier(prop.name) ? prop.name.text : ts.isStringLiteral(prop.name) ? prop.name.text : null;
24
+ if (!key) continue;
25
+ const val = getStringValue(prop.initializer);
26
+ if (val !== null) result[key] = val;
34
27
  }
35
- const sorted = [...deduped.entries()].sort(([a], [b]) => a.localeCompare(b));
36
- const body = sorted.map(([name, { pattern, search }]) => {
37
- const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
38
- if (search && Object.keys(search).length > 0) {
39
- const searchBody = Object.entries(search).map(([k, v]) => `${k}: "${v}"`).join(", ");
40
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
41
- }
42
- return ` ${key}: "${pattern}",`;
43
- }).join("\n");
44
- return `// Auto-generated by @rangojs/router - do not edit
45
- export const routes = {
46
- ${body}
47
- } as const;
48
- export type routes = typeof routes;
49
- `;
28
+ return result;
50
29
  }
51
- function isWhitespace(ch) {
52
- return ch === " " || ch === " " || ch === "\n" || ch === "\r";
53
- }
54
- function readString(code, pos) {
55
- const quote = code[pos];
56
- if (quote !== '"' && quote !== "'") return null;
57
- let value = "";
58
- pos++;
59
- while (pos < code.length) {
60
- if (code[pos] === "\\") {
61
- pos++;
62
- if (pos < code.length) {
63
- value += code[pos];
64
- pos++;
65
- }
66
- continue;
67
- }
68
- if (code[pos] === quote) {
69
- return { value, end: pos + 1 };
70
- }
71
- value += code[pos];
72
- pos++;
30
+ function extractParamsFromPattern(pattern) {
31
+ const params = {};
32
+ const regex = /:([a-zA-Z_$][\w$]*)(?:\([^)]+\))?(\?)?/g;
33
+ let match;
34
+ while ((match = regex.exec(pattern)) !== null) {
35
+ params[match[1]] = match[2] ? "string?" : "string";
73
36
  }
74
- return null;
37
+ return Object.keys(params).length > 0 ? params : void 0;
75
38
  }
76
- function skipStringLiteral(code, pos) {
77
- const quote = code[pos];
78
- if (quote === "`") {
79
- pos++;
80
- while (pos < code.length) {
81
- if (code[pos] === "\\") {
82
- pos += 2;
83
- continue;
84
- }
85
- if (code[pos] === "`") return pos + 1;
86
- if (code[pos] === "$" && pos + 1 < code.length && code[pos + 1] === "{") {
87
- pos += 2;
88
- let braceDepth = 1;
89
- while (pos < code.length && braceDepth > 0) {
90
- if (code[pos] === "{") braceDepth++;
91
- else if (code[pos] === "}") braceDepth--;
92
- else if (code[pos] === "\\") pos++;
93
- else if (code[pos] === '"' || code[pos] === "'" || code[pos] === "`") {
94
- pos = skipStringLiteral(code, pos);
95
- continue;
96
- }
97
- if (braceDepth > 0) pos++;
98
- }
99
- continue;
100
- }
101
- pos++;
102
- }
103
- return pos;
104
- }
105
- pos++;
106
- while (pos < code.length) {
107
- if (code[pos] === "\\") {
108
- pos += 2;
109
- continue;
110
- }
111
- if (code[pos] === quote) return pos + 1;
112
- pos++;
39
+ function formatRouteEntry(key, pattern, _params, search) {
40
+ const hasSearch = search && Object.keys(search).length > 0;
41
+ if (!hasSearch) {
42
+ return ` ${key}: "${pattern}",`;
113
43
  }
114
- return pos;
44
+ const searchBody = Object.entries(search).map(([k, v]) => `${k}: "${v}"`).join(", ");
45
+ return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
115
46
  }
116
- function matchesNameColon(code, pos) {
117
- if (code.slice(pos, pos + 4) !== "name") return false;
118
- if (pos > 0 && /\w/.test(code[pos - 1])) return false;
119
- const afterName = pos + 4;
120
- if (afterName < code.length && /\w/.test(code[afterName])) return false;
121
- let checkPos = afterName;
122
- while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
123
- return code[checkPos] === ":";
124
- }
125
- function extractNameValue(code, pos) {
126
- pos += 4;
127
- while (pos < code.length && isWhitespace(code[pos])) pos++;
128
- pos++;
129
- while (pos < code.length && isWhitespace(code[pos])) pos++;
130
- return readString(code, pos);
131
- }
132
- function matchesSearchColon(code, pos) {
133
- if (code.slice(pos, pos + 6) !== "search") return false;
134
- if (pos > 0 && /\w/.test(code[pos - 1])) return false;
135
- const afterSearch = pos + 6;
136
- if (afterSearch < code.length && /\w/.test(code[afterSearch])) return false;
137
- let checkPos = afterSearch;
138
- while (checkPos < code.length && isWhitespace(code[checkPos])) checkPos++;
139
- return code[checkPos] === ":";
140
- }
141
- function extractSearchValue(code, pos) {
142
- pos += 6;
143
- while (pos < code.length && isWhitespace(code[pos])) pos++;
144
- pos++;
145
- while (pos < code.length && isWhitespace(code[pos])) pos++;
146
- if (code[pos] !== "{") return null;
147
- pos++;
148
- const schema = {};
149
- while (pos < code.length) {
150
- while (pos < code.length && isWhitespace(code[pos])) pos++;
151
- if (code[pos] === "}") return { value: schema, end: pos + 1 };
152
- if (code[pos] === ",") {
153
- pos++;
154
- continue;
47
+ function extractRoutesFromSource(code) {
48
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
49
+ const routes = [];
50
+ function visit(node) {
51
+ if (ts.isCallExpression(node)) {
52
+ const callee = node.expression;
53
+ const isPath = ts.isIdentifier(callee) && callee.text === "path" || ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "path";
54
+ if (isPath && node.arguments.length >= 1) {
55
+ const route = extractRouteFromCallExpression(node);
56
+ if (route) routes.push(route);
57
+ }
155
58
  }
156
- let key;
157
- if (code[pos] === '"' || code[pos] === "'") {
158
- const keyStr = readString(code, pos);
159
- if (!keyStr) return null;
160
- key = keyStr.value;
161
- pos = keyStr.end;
162
- } else {
163
- const keyStart = pos;
164
- while (pos < code.length && /[\w$]/.test(code[pos])) pos++;
165
- if (pos === keyStart) return null;
166
- key = code.slice(keyStart, pos);
167
- }
168
- while (pos < code.length && isWhitespace(code[pos])) pos++;
169
- if (code[pos] !== ":") return null;
170
- pos++;
171
- while (pos < code.length && isWhitespace(code[pos])) pos++;
172
- const valStr = readString(code, pos);
173
- if (!valStr) return null;
174
- schema[key] = valStr.value;
175
- pos = valStr.end;
59
+ ts.forEachChild(node, visit);
176
60
  }
177
- return null;
61
+ visit(sourceFile);
62
+ return routes;
178
63
  }
179
- function parsePathCall(code, pos) {
180
- while (pos < code.length && isWhitespace(code[pos])) pos++;
181
- const patternStr = readString(code, pos);
182
- if (!patternStr) return null;
183
- const pattern = patternStr.value;
184
- pos = patternStr.end;
185
- let depth = 1;
64
+ function extractRouteFromCallExpression(node) {
65
+ const patternNode = node.arguments[0];
66
+ const pattern = getStringValue(patternNode);
67
+ if (pattern === null) return null;
186
68
  let name = null;
187
69
  let search;
188
- while (pos < code.length && depth > 0) {
189
- const ch = code[pos];
190
- if (isWhitespace(ch)) {
191
- pos++;
192
- continue;
193
- }
194
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
195
- pos += 2;
196
- while (pos < code.length && code[pos] !== "\n") pos++;
197
- continue;
198
- }
199
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
200
- pos += 2;
201
- while (pos < code.length - 1 && !(code[pos] === "*" && code[pos + 1] === "/"))
202
- pos++;
203
- pos += 2;
204
- continue;
205
- }
206
- if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
207
- const nameResult = extractNameValue(code, pos);
208
- if (nameResult) {
209
- name = nameResult.value;
210
- pos = nameResult.end;
211
- continue;
212
- }
213
- }
214
- if (depth === 2 && ch === "s" && matchesSearchColon(code, pos)) {
215
- const searchResult = extractSearchValue(code, pos);
216
- if (searchResult) {
217
- search = searchResult.value;
218
- pos = searchResult.end;
219
- continue;
70
+ for (let i = 1; i < node.arguments.length; i++) {
71
+ const arg = node.arguments[i];
72
+ if (ts.isObjectLiteralExpression(arg)) {
73
+ for (const prop of arg.properties) {
74
+ if (!ts.isPropertyAssignment(prop)) continue;
75
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null;
76
+ if (propName === "name") {
77
+ name = getStringValue(prop.initializer);
78
+ } else if (propName === "search" && ts.isObjectLiteralExpression(prop.initializer)) {
79
+ search = extractObjectStringProperties(prop.initializer);
80
+ }
220
81
  }
221
82
  }
222
- if (ch === '"' || ch === "`" || ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1]))) {
223
- pos = skipStringLiteral(code, pos);
224
- continue;
225
- }
226
- if (ch === "(" || ch === "{" || ch === "[") depth++;
227
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
228
- pos++;
229
83
  }
230
- if (name === null) return null;
231
- return { name, pattern, ...search ? { search } : {} };
84
+ if (!name) return null;
85
+ const params = extractParamsFromPattern(pattern);
86
+ return {
87
+ name,
88
+ pattern,
89
+ ...params ? { params } : {},
90
+ ...search && Object.keys(search).length > 0 ? { search } : {}
91
+ };
232
92
  }
233
93
  function generateRouteTypesSource(routeManifest, searchSchemas) {
234
94
  const entries = Object.entries(routeManifest).sort(
@@ -236,12 +96,9 @@ function generateRouteTypesSource(routeManifest, searchSchemas) {
236
96
  );
237
97
  const objectBody = entries.map(([name, pattern]) => {
238
98
  const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
99
+ const params = extractParamsFromPattern(pattern);
239
100
  const search = searchSchemas?.[name];
240
- if (search && Object.keys(search).length > 0) {
241
- const searchBody = Object.entries(search).map(([k, v]) => `${k}: "${v}"`).join(", ");
242
- return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
243
- }
244
- return ` ${key}: "${pattern}",`;
101
+ return formatRouteEntry(key, pattern, params, search);
245
102
  }).join("\n");
246
103
  return `// Auto-generated by @rangojs/router - do not edit
247
104
  export const NamedRoutes = {
@@ -299,88 +156,41 @@ function findTsFiles(dir, filter) {
299
156
  }
300
157
  return results;
301
158
  }
302
- function writePerModuleRouteTypes(root, filter) {
303
- const files = findTsFiles(root, filter);
304
- for (const filePath of files) {
305
- writePerModuleRouteTypesForFile(filePath);
306
- }
307
- }
308
- function writePerModuleRouteTypesForFile(filePath) {
309
- try {
310
- const source = readFileSync(filePath, "utf-8");
311
- if (!source.includes("urls(")) return;
312
- const routes = extractRoutesFromSource(source);
313
- if (routes.length === 0) return;
314
- const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
315
- const genSource = generatePerModuleTypesSource(routes);
316
- const existing = existsSync(genPath) ? readFileSync(genPath, "utf-8") : null;
317
- if (existing !== genSource) {
318
- writeFileSync(genPath, genSource);
319
- console.log(`[rsc-router] Generated route types -> ${genPath}`);
320
- }
321
- } catch (err) {
322
- console.warn(`[rsc-router] Failed to generate route types for ${filePath}: ${err.message}`);
323
- }
324
- }
325
159
  function extractIncludesFromSource(code) {
160
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
326
161
  const results = [];
327
- const regex = /\binclude\s*\(/g;
328
- let match;
329
- while ((match = regex.exec(code)) !== null) {
330
- const result = parseIncludeCall(code, match.index + match[0].length);
331
- if (result) results.push(result);
162
+ function visit(node) {
163
+ if (ts.isCallExpression(node)) {
164
+ const callee = node.expression;
165
+ if (ts.isIdentifier(callee) && callee.text === "include") {
166
+ const result = extractIncludeFromCallExpression(node);
167
+ if (result) results.push(result);
168
+ }
169
+ }
170
+ ts.forEachChild(node, visit);
332
171
  }
172
+ visit(sourceFile);
333
173
  return results;
334
174
  }
335
- function parseIncludeCall(code, pos) {
336
- while (pos < code.length && isWhitespace(code[pos])) pos++;
337
- const prefixStr = readString(code, pos);
338
- if (!prefixStr) return null;
339
- const pathPrefix = prefixStr.value;
340
- pos = prefixStr.end;
341
- while (pos < code.length && isWhitespace(code[pos])) pos++;
342
- if (pos >= code.length || code[pos] !== ",") return null;
343
- pos++;
344
- while (pos < code.length && isWhitespace(code[pos])) pos++;
345
- const varStart = pos;
346
- while (pos < code.length && /[\w$]/.test(code[pos])) pos++;
347
- if (pos === varStart) return null;
348
- const variableName = code.slice(varStart, pos);
175
+ function extractIncludeFromCallExpression(node) {
176
+ if (node.arguments.length < 2) return null;
177
+ const pathPrefix = getStringValue(node.arguments[0]);
178
+ if (pathPrefix === null) return null;
179
+ const secondArg = node.arguments[1];
180
+ if (!ts.isIdentifier(secondArg)) return null;
181
+ const variableName = secondArg.text;
349
182
  let namePrefix = null;
350
- let depth = 1;
351
- while (pos < code.length && depth > 0) {
352
- const ch = code[pos];
353
- if (isWhitespace(ch)) {
354
- pos++;
355
- continue;
356
- }
357
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
358
- pos += 2;
359
- while (pos < code.length && code[pos] !== "\n") pos++;
360
- continue;
361
- }
362
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
363
- pos += 2;
364
- while (pos < code.length - 1 && !(code[pos] === "*" && code[pos + 1] === "/"))
365
- pos++;
366
- pos += 2;
367
- continue;
368
- }
369
- if (depth === 2 && ch === "n" && matchesNameColon(code, pos)) {
370
- const nameResult = extractNameValue(code, pos);
371
- if (nameResult) {
372
- namePrefix = nameResult.value;
373
- pos = nameResult.end;
374
- continue;
183
+ if (node.arguments.length >= 3) {
184
+ const thirdArg = node.arguments[2];
185
+ if (ts.isObjectLiteralExpression(thirdArg)) {
186
+ for (const prop of thirdArg.properties) {
187
+ if (!ts.isPropertyAssignment(prop)) continue;
188
+ const propName = ts.isIdentifier(prop.name) ? prop.name.text : null;
189
+ if (propName === "name") {
190
+ namePrefix = getStringValue(prop.initializer);
191
+ }
375
192
  }
376
193
  }
377
- if (ch === '"' || ch === "`" || ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1]))) {
378
- pos = skipStringLiteral(code, pos);
379
- continue;
380
- }
381
- if (ch === "(" || ch === "{" || ch === "[") depth++;
382
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
383
- pos++;
384
194
  }
385
195
  return { pathPrefix, variableName, namePrefix };
386
196
  }
@@ -417,41 +227,22 @@ function resolveImportPath(importSpec, fromFile) {
417
227
  }
418
228
  return null;
419
229
  }
420
- function escapeRegExp(s) {
421
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
422
- }
423
230
  function extractUrlsBlockForVariable(code, varName) {
424
- const pattern = new RegExp(
425
- `(?:export\\s+)?(?:const|let|var)\\s+${escapeRegExp(varName)}\\s*=\\s*urls\\s*\\(`
426
- );
427
- const match = pattern.exec(code);
428
- if (!match) return null;
429
- const openParen = match.index + match[0].length - 1;
430
- let depth = 1;
431
- let pos = openParen + 1;
432
- while (pos < code.length && depth > 0) {
433
- const ch = code[pos];
434
- if (ch === '"' || ch === "`" || ch === "'" && (pos === 0 || !/\w/.test(code[pos - 1]))) {
435
- pos = skipStringLiteral(code, pos);
436
- continue;
437
- }
438
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "/") {
439
- pos += 2;
440
- while (pos < code.length && code[pos] !== "\n") pos++;
441
- continue;
442
- }
443
- if (ch === "/" && pos + 1 < code.length && code[pos + 1] === "*") {
444
- pos += 2;
445
- while (pos < code.length - 1 && !(code[pos] === "*" && code[pos + 1] === "/"))
446
- pos++;
447
- pos += 2;
448
- continue;
231
+ const sourceFile = ts.createSourceFile("input.tsx", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
232
+ let result = null;
233
+ function visit(node) {
234
+ if (result) return;
235
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === varName && node.initializer && ts.isCallExpression(node.initializer)) {
236
+ const callee = node.initializer.expression;
237
+ if (ts.isIdentifier(callee) && callee.text === "urls") {
238
+ result = node.initializer.getText(sourceFile);
239
+ return;
240
+ }
449
241
  }
450
- if (ch === "(" || ch === "{" || ch === "[") depth++;
451
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
452
- pos++;
242
+ ts.forEachChild(node, visit);
453
243
  }
454
- return code.slice(openParen, pos);
244
+ visit(sourceFile);
245
+ return result;
455
246
  }
456
247
  function buildRouteMapFromBlock(block, fullSource, filePath, visited, searchSchemasOut) {
457
248
  const routeMap = {};
@@ -499,7 +290,10 @@ function buildCombinedRouteMapWithSearch(filePath, variableName, visited) {
499
290
  visited = visited ?? /* @__PURE__ */ new Set();
500
291
  const realPath = resolve(filePath);
501
292
  const key = variableName ? `${realPath}:${variableName}` : realPath;
502
- if (visited.has(key)) return { routes: {}, searchSchemas: {} };
293
+ if (visited.has(key)) {
294
+ console.warn(`[rsc-router] Circular include detected, skipping: ${key}`);
295
+ return { routes: {}, searchSchemas: {} };
296
+ }
503
297
  visited.add(key);
504
298
  let source;
505
299
  try {
@@ -609,7 +403,7 @@ function writeCombinedRouteTypes(root, knownRouterFiles, opts) {
609
403
  );
610
404
  if (existing !== source) {
611
405
  if (opts?.preserveIfLarger && existing) {
612
- const existingCount = (existing.match(/^\s+["a-zA-Z_$][^:]*:\s*"/gm) || []).length;
406
+ const existingCount = (existing.match(/^\s+["a-zA-Z_$][^:]*:\s*["{]/gm) || []).length;
613
407
  const newCount = Object.keys(result.routes).length;
614
408
  if (existingCount > newCount) {
615
409
  continue;
@@ -1167,7 +961,7 @@ var STRICT_CREATE_CONFIGS = [
1167
961
  { fnName: "createHandle" },
1168
962
  { fnName: "createLocationState" }
1169
963
  ];
1170
- function escapeRegExp2(input) {
964
+ function escapeRegExp(input) {
1171
965
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1172
966
  }
1173
967
  function isExportOnlyFile(code, bindings) {
@@ -1200,7 +994,7 @@ function isExportOnlyFile(code, bindings) {
1200
994
  }
1201
995
  function countCreateCallsForNames(code, fnNames) {
1202
996
  const pattern = new RegExp(
1203
- `\\b(?:${fnNames.map(escapeRegExp2).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
997
+ `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
1204
998
  "g"
1205
999
  );
1206
1000
  return (code.match(pattern) || []).length;
@@ -1233,7 +1027,7 @@ function getCalledIdentifierFromCall(callExpr) {
1233
1027
  return null;
1234
1028
  }
1235
1029
  function collectCreateExportBindingsFallback(code, fnNames) {
1236
- const alternation = fnNames.map(escapeRegExp2).join("|");
1030
+ const alternation = fnNames.map(escapeRegExp).join("|");
1237
1031
  const exportConstPattern = new RegExp(
1238
1032
  `export\\s+const\\s+(\\w+)\\s*=\\s*(?:${alternation})\\s*(?:<[^>]*>)?\\s*\\(`,
1239
1033
  "g"
@@ -1484,7 +1278,7 @@ ${binding.localName}.$$id = "${handlerId}";`;
1484
1278
  }
1485
1279
  function transformRouter(code, filePath, routerFnNames) {
1486
1280
  const pat = new RegExp(
1487
- `\\b(?:${routerFnNames.map(escapeRegExp2).join("|")})\\s*(?:<[^>]*>)?\\s*\\(`,
1281
+ `\\b(?:${routerFnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>)?\\s*\\(`,
1488
1282
  "g"
1489
1283
  );
1490
1284
  let match;
@@ -1984,7 +1778,7 @@ import { resolve as resolve2 } from "node:path";
1984
1778
  // package.json
1985
1779
  var package_default = {
1986
1780
  name: "@rangojs/router",
1987
- version: "0.0.0-experimental.13",
1781
+ version: "0.0.0-experimental.15",
1988
1782
  type: "module",
1989
1783
  description: "Django-inspired RSC router with composable URL patterns",
1990
1784
  author: "Ivo Todorov",
@@ -2717,7 +2511,6 @@ Set an explicit \`id\` on createRouter() or check the call site.`
2717
2511
  });
2718
2512
  }
2719
2513
  if (opts?.staticRouteTypesGeneration !== false) {
2720
- writePerModuleRouteTypes(projectRoot, scanFilter);
2721
2514
  cachedRouterFiles = findRouterFiles(projectRoot, scanFilter);
2722
2515
  writeCombinedRouteTypes(projectRoot, cachedRouterFiles, { preserveIfLarger: true });
2723
2516
  }
@@ -2870,9 +2663,6 @@ ${err.stack}`
2870
2663
  const hasUrls = source.includes("urls(");
2871
2664
  const hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
2872
2665
  if (!hasUrls && !hasCreateRouter) return;
2873
- if (hasUrls) {
2874
- writePerModuleRouteTypesForFile(filePath);
2875
- }
2876
2666
  if (hasCreateRouter) {
2877
2667
  cachedRouterFiles = void 0;
2878
2668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.13",
3
+ "version": "0.0.0-experimental.15",
4
4
  "type": "module",
5
5
  "description": "Django-inspired RSC router with composable URL patterns",
6
6
  "author": "Ivo Todorov",
@@ -30,6 +30,7 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
30
30
  | `/response-routes` | JSON/text/HTML/XML/stream endpoints with `path.json()`, `path.text()` |
31
31
  | `/mime-routes` | Content negotiation — same URL, different response types via Accept header |
32
32
  | `/fonts` | Load web fonts with preload hints |
33
+ | `/testing` | Unit test route trees with `buildRouteTree()` |
33
34
 
34
35
  ## Quick Start
35
36
 
@@ -52,3 +53,65 @@ export default createRouter({ document: Document }).urls(urlpatterns);
52
53
  ```
53
54
 
54
55
  Use `/typesafety` for type-safe href and environment setup.
56
+
57
+ ## CLI: `npx rango generate`
58
+
59
+ Single command to generate `.gen.ts` route type files. Auto-detects file type and
60
+ generates the appropriate output.
61
+
62
+ ```bash
63
+ # Single file
64
+ npx rango generate src/urls.tsx
65
+
66
+ # Multiple files
67
+ npx rango generate src/router.tsx src/urls.tsx
68
+
69
+ # Directory (recursive scan)
70
+ npx rango generate src/
71
+
72
+ # Mix of files and directories
73
+ npx rango generate src/urls.tsx src/api/
74
+ ```
75
+
76
+ ### Auto-detection
77
+
78
+ Each file is classified by its contents:
79
+
80
+ | Contains | Generated output |
81
+ |----------|-----------------|
82
+ | `urls(` | Per-module `*.gen.ts` with route names, patterns, params, search |
83
+ | `createRouter` | Per-router `*.named-routes.gen.ts` with global route map |
84
+ | Both | Both files |
85
+
86
+ Directories are scanned recursively for `.ts`/`.tsx` files, skipping `node_modules`,
87
+ dotfiles, and existing `.gen.` files.
88
+
89
+ ### Recursive includes
90
+
91
+ The generator follows `include()` calls across files, resolving imports to build
92
+ the full route tree. Circular includes are detected and warned about.
93
+
94
+ ### First-wins deduplication
95
+
96
+ When a route name appears more than once, the first definition wins and duplicates
97
+ are dropped with a warning. This applies only to the generated `.gen.ts` type files.
98
+ Define the primary route before any fallback variant that reuses the same name.
99
+
100
+ Content negotiation (see `/mime-routes`) is unaffected — negotiated routes use
101
+ distinct names (e.g. `"product"` and `"productJson"`) and the Accept header
102
+ dispatching happens at runtime in the trie, not in the type generator.
103
+
104
+ ### Limitations
105
+
106
+ The CLI uses static source analysis (AST walking), not runtime execution. It cannot
107
+ extract routes defined dynamically:
108
+
109
+ - `Array.from()` or `.map()` generating path() calls
110
+ - Conditional routes behind `import.meta.env` or feature flags
111
+ - Routes computed from external data (databases, config files)
112
+ - Template literal patterns with interpolated variables
113
+
114
+ These routes are only discovered by the Vite plugin's runtime discovery during
115
+ `pnpm dev` or `pnpm build`. The CLI-generated `.gen.ts` may have fewer routes
116
+ than the runtime-generated version. During dev, the `preserveIfLarger` guard
117
+ prevents the static parser from overwriting a larger runtime-discovered file.