@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
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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]
|
|
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 <
|
|
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
|
-
|
|
285
|
-
|
|
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
|
}
|