@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/vite/index.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
37
|
+
return Object.keys(params).length > 0 ? params : void 0;
|
|
75
38
|
}
|
|
76
|
-
function
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
|
|
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
|
-
|
|
44
|
+
const searchBody = Object.entries(search).map(([k, v]) => `${k}: "${v}"`).join(", ");
|
|
45
|
+
return ` ${key}: { path: "${pattern}", search: { ${searchBody} } },`;
|
|
115
46
|
}
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
visit(sourceFile);
|
|
62
|
+
return routes;
|
|
178
63
|
}
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
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
|
-
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
425
|
-
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
451
|
-
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
|
452
|
-
pos++;
|
|
242
|
+
ts.forEachChild(node, visit);
|
|
453
243
|
}
|
|
454
|
-
|
|
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))
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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
package/skills/rango/SKILL.md
CHANGED
|
@@ -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.
|