@kimesh/router-generator 0.0.1
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 +3 -0
- package/dist/index.d.mts +505 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2029 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2029 @@
|
|
|
1
|
+
import * as path from "pathe";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { watch } from "chokidar";
|
|
4
|
+
import fg from "fast-glob";
|
|
5
|
+
import { parse } from "@vue/compiler-sfc";
|
|
6
|
+
import { parseSync } from "oxc-parser";
|
|
7
|
+
import { consola } from "consola";
|
|
8
|
+
|
|
9
|
+
//#region src/parser.ts
|
|
10
|
+
/**
|
|
11
|
+
* @kimesh/router-generator - OXC-Powered Route Parser
|
|
12
|
+
*
|
|
13
|
+
* Parse Vue SFC files to extract route definition information using OXC.
|
|
14
|
+
* This replaces the previous regex-based approach for more accurate parsing.
|
|
15
|
+
*/
|
|
16
|
+
const logger$2 = consola.withTag("kimesh:router:parser");
|
|
17
|
+
/**
|
|
18
|
+
* Parse a Vue SFC file to extract route definition information using OXC
|
|
19
|
+
*/
|
|
20
|
+
function parseRouteFile(code, filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const { descriptor, errors } = parse(code, { filename: filePath });
|
|
23
|
+
if (errors.length > 0) {
|
|
24
|
+
logger$2.debug(`SFC parse errors in ${filePath}:`, errors);
|
|
25
|
+
return createEmptyParsedRoute(filePath);
|
|
26
|
+
}
|
|
27
|
+
const isLayout = filePath.includes("_layout") || filePath.includes("layout.vue");
|
|
28
|
+
const scriptContent = descriptor.script?.content || "";
|
|
29
|
+
const result = parseWithOxc(scriptContent, descriptor.scriptSetup?.content || "", filePath);
|
|
30
|
+
return {
|
|
31
|
+
hasRouteDefinition: result.hasRouteDefinition ?? false,
|
|
32
|
+
routePath: result.routePath ?? null,
|
|
33
|
+
hasLoader: result.hasLoader ?? false,
|
|
34
|
+
hasMeta: result.hasMeta ?? false,
|
|
35
|
+
hasValidateSearch: result.hasValidateSearch ?? false,
|
|
36
|
+
isLayout,
|
|
37
|
+
routeScriptContent: result.hasRouteDefinition ? scriptContent : null,
|
|
38
|
+
loaderOptions: result.loaderOptions,
|
|
39
|
+
middleware: result.middleware,
|
|
40
|
+
pageMeta: result.pageMeta
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
logger$2.debug(`Failed to parse ${filePath}:`, error);
|
|
44
|
+
return createEmptyParsedRoute(filePath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse script content with OXC
|
|
49
|
+
*/
|
|
50
|
+
function parseWithOxc(scriptContent, scriptSetupContent, filePath) {
|
|
51
|
+
const result = {
|
|
52
|
+
hasRouteDefinition: false,
|
|
53
|
+
routePath: null,
|
|
54
|
+
hasLoader: false,
|
|
55
|
+
hasMeta: false,
|
|
56
|
+
hasValidateSearch: false
|
|
57
|
+
};
|
|
58
|
+
if (scriptContent) try {
|
|
59
|
+
const parsed = parseSync(filePath + ".ts", scriptContent, { sourceType: "module" });
|
|
60
|
+
if (parsed.errors.length === 0) extractFromAST(parsed.program, result);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
logger$2.debug(`OXC parse error for script in ${filePath}:`, e);
|
|
63
|
+
}
|
|
64
|
+
if (scriptSetupContent) try {
|
|
65
|
+
const parsed = parseSync(filePath + ".setup.ts", scriptSetupContent, { sourceType: "module" });
|
|
66
|
+
if (parsed.errors.length === 0) extractFromSetupAST(parsed.program, result);
|
|
67
|
+
} catch (e) {}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract route metadata from regular script AST
|
|
72
|
+
*/
|
|
73
|
+
function extractFromAST(program, result) {
|
|
74
|
+
for (const node of program.body) {
|
|
75
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration) {
|
|
76
|
+
const decl = node.declaration;
|
|
77
|
+
if (decl.type === "VariableDeclaration") for (const declarator of decl.declarations) {
|
|
78
|
+
if (declarator.id?.name === "Route" && declarator.init) {
|
|
79
|
+
const init = declarator.init;
|
|
80
|
+
if (init.type === "CallExpression" && init.callee?.type === "CallExpression") {
|
|
81
|
+
const inner = init.callee;
|
|
82
|
+
if (isCallTo(inner, "createFileRoute")) {
|
|
83
|
+
const arg = inner.arguments?.[0];
|
|
84
|
+
if (arg?.type === "Literal" && typeof arg.value === "string") result.routePath = arg.value;
|
|
85
|
+
const routeOptions = init.arguments?.[0];
|
|
86
|
+
if (routeOptions?.type === "ObjectExpression") {
|
|
87
|
+
result.hasRouteDefinition = true;
|
|
88
|
+
for (const prop of routeOptions.properties || []) {
|
|
89
|
+
if (prop.type !== "Property" || prop.key?.type !== "Identifier") continue;
|
|
90
|
+
switch (prop.key.name) {
|
|
91
|
+
case "loader":
|
|
92
|
+
result.hasLoader = true;
|
|
93
|
+
break;
|
|
94
|
+
case "meta":
|
|
95
|
+
result.hasMeta = true;
|
|
96
|
+
break;
|
|
97
|
+
case "validateSearch":
|
|
98
|
+
result.hasValidateSearch = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (init.type === "CallExpression" && isCallTo(init, "createFileRoute")) {
|
|
106
|
+
const arg = init.arguments?.[0];
|
|
107
|
+
if (arg?.type === "Literal" && typeof arg.value === "string") result.routePath = arg.value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (declarator.id?.name === "loader") {
|
|
111
|
+
result.hasLoader = true;
|
|
112
|
+
if (isCallTo(declarator.init, "defineLoader")) result.loaderOptions = extractLoaderOptions(declarator.init);
|
|
113
|
+
}
|
|
114
|
+
if (declarator.id?.name === "middleware") {
|
|
115
|
+
if (isCallTo(declarator.init, "defineMiddleware")) result.middleware = extractMiddlewareNames(declarator.init);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (node.type === "ExpressionStatement") {
|
|
120
|
+
const expr = node.expression;
|
|
121
|
+
if (expr.type === "CallExpression") {
|
|
122
|
+
if (isCallTo(expr, "definePageMeta")) {
|
|
123
|
+
result.hasMeta = true;
|
|
124
|
+
result.pageMeta = extractPageMetaOptions(expr);
|
|
125
|
+
}
|
|
126
|
+
if (isCallTo(expr, "defineRoute")) {
|
|
127
|
+
result.hasRouteDefinition = true;
|
|
128
|
+
extractDefineRouteOptions(expr, result);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Extract from setup script AST
|
|
136
|
+
*/
|
|
137
|
+
function extractFromSetupAST(program, result) {
|
|
138
|
+
for (const node of program.body) if (node.type === "ExpressionStatement") {
|
|
139
|
+
const expr = node.expression;
|
|
140
|
+
if (expr.type === "CallExpression") {
|
|
141
|
+
if (isCallTo(expr, "definePageMeta")) {
|
|
142
|
+
result.hasMeta = true;
|
|
143
|
+
result.pageMeta = extractPageMetaOptions(expr);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Check if a CallExpression calls a specific function
|
|
150
|
+
*/
|
|
151
|
+
function isCallTo(node, name) {
|
|
152
|
+
if (!node || node.type !== "CallExpression") return false;
|
|
153
|
+
if (node.callee?.type === "Identifier") return node.callee.name === name;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Extract loader options from defineLoader call
|
|
158
|
+
*/
|
|
159
|
+
function extractLoaderOptions(call) {
|
|
160
|
+
const options = {
|
|
161
|
+
isAsync: false,
|
|
162
|
+
hasBeforeLoad: false,
|
|
163
|
+
dependencies: []
|
|
164
|
+
};
|
|
165
|
+
const arg = call.arguments?.[0];
|
|
166
|
+
if (!arg) return options;
|
|
167
|
+
if (isFunctionExpression(arg)) {
|
|
168
|
+
options.isAsync = arg.async === true;
|
|
169
|
+
return options;
|
|
170
|
+
}
|
|
171
|
+
if (arg.type === "ObjectExpression") extractLoaderObjectOptions(arg, options);
|
|
172
|
+
return options;
|
|
173
|
+
}
|
|
174
|
+
const FUNCTION_EXPRESSION_TYPES = new Set(["ArrowFunctionExpression", "FunctionExpression"]);
|
|
175
|
+
function isFunctionExpression(node) {
|
|
176
|
+
return FUNCTION_EXPRESSION_TYPES.has(node.type);
|
|
177
|
+
}
|
|
178
|
+
function extractLoaderObjectOptions(arg, options) {
|
|
179
|
+
for (const prop of arg.properties || []) {
|
|
180
|
+
if (prop.type !== "Property" || prop.key?.type !== "Identifier") continue;
|
|
181
|
+
switch (prop.key.name) {
|
|
182
|
+
case "loader":
|
|
183
|
+
if (isFunctionExpression(prop.value)) options.isAsync = prop.value.async === true;
|
|
184
|
+
break;
|
|
185
|
+
case "beforeLoad":
|
|
186
|
+
options.hasBeforeLoad = true;
|
|
187
|
+
break;
|
|
188
|
+
case "dependencies":
|
|
189
|
+
if (prop.value?.type === "ArrayExpression") options.dependencies = extractStringArrayElements(prop.value.elements);
|
|
190
|
+
break;
|
|
191
|
+
case "cache":
|
|
192
|
+
if (prop.value?.type === "Literal" && typeof prop.value.value === "string") options.cacheStrategy = prop.value.value;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function extractStringArrayElements(elements) {
|
|
198
|
+
return elements.filter((e) => e?.type === "Literal" && typeof e.value === "string").map((e) => e.value);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Extract middleware names from defineMiddleware call
|
|
202
|
+
*/
|
|
203
|
+
function extractMiddlewareNames(call) {
|
|
204
|
+
const names = [];
|
|
205
|
+
const arg = call.arguments?.[0];
|
|
206
|
+
if (!arg) return names;
|
|
207
|
+
if (arg.type === "ArrayExpression") {
|
|
208
|
+
for (const elem of arg.elements || []) if (elem?.type === "Literal" && typeof elem.value === "string") names.push(elem.value);
|
|
209
|
+
}
|
|
210
|
+
if (arg.type === "Literal" && typeof arg.value === "string") names.push(arg.value);
|
|
211
|
+
return names;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Extract page meta options from definePageMeta call
|
|
215
|
+
*/
|
|
216
|
+
function extractPageMetaOptions(call) {
|
|
217
|
+
const options = {};
|
|
218
|
+
const arg = call.arguments?.[0];
|
|
219
|
+
if (!arg || arg.type !== "ObjectExpression") return options;
|
|
220
|
+
for (const prop of arg.properties || []) {
|
|
221
|
+
if (prop.type !== "Property" || prop.key?.type !== "Identifier") continue;
|
|
222
|
+
const key = prop.key.name;
|
|
223
|
+
const { value, serializable } = extractSerializableValue(prop.value);
|
|
224
|
+
if (!serializable) continue;
|
|
225
|
+
assignPageMetaOption(options, key, value);
|
|
226
|
+
}
|
|
227
|
+
return options;
|
|
228
|
+
}
|
|
229
|
+
function assignPageMetaOption(options, key, value) {
|
|
230
|
+
switch (key) {
|
|
231
|
+
case "name":
|
|
232
|
+
case "path":
|
|
233
|
+
case "redirect":
|
|
234
|
+
if (typeof value === "string") options[key] = value;
|
|
235
|
+
break;
|
|
236
|
+
case "layout":
|
|
237
|
+
if (typeof value === "string" || value === false) options.layout = value;
|
|
238
|
+
break;
|
|
239
|
+
case "alias":
|
|
240
|
+
if (typeof value === "string" || Array.isArray(value)) options.alias = value;
|
|
241
|
+
break;
|
|
242
|
+
case "transition":
|
|
243
|
+
case "keepAlive":
|
|
244
|
+
if (typeof value === "boolean" || typeof value === "string") options[key] = value;
|
|
245
|
+
break;
|
|
246
|
+
case "meta":
|
|
247
|
+
if (typeof value === "object" && value !== null) options.meta = value;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Extract options from defineRoute call
|
|
253
|
+
*/
|
|
254
|
+
function extractDefineRouteOptions(call, result) {
|
|
255
|
+
const arg = call.arguments?.[0];
|
|
256
|
+
if (!arg || arg.type !== "ObjectExpression") return;
|
|
257
|
+
for (const prop of arg.properties || []) {
|
|
258
|
+
if (prop.type !== "Property" || prop.key?.type !== "Identifier") continue;
|
|
259
|
+
switch (prop.key.name) {
|
|
260
|
+
case "path":
|
|
261
|
+
if (prop.value?.type === "Literal" && typeof prop.value.value === "string") result.routePath = prop.value.value;
|
|
262
|
+
break;
|
|
263
|
+
case "loader":
|
|
264
|
+
result.hasLoader = true;
|
|
265
|
+
break;
|
|
266
|
+
case "meta":
|
|
267
|
+
result.hasMeta = true;
|
|
268
|
+
break;
|
|
269
|
+
case "validateSearch":
|
|
270
|
+
result.hasValidateSearch = true;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const NOT_SERIALIZABLE = {
|
|
276
|
+
value: void 0,
|
|
277
|
+
serializable: false
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Extract JSON-serializable value from AST node
|
|
281
|
+
*/
|
|
282
|
+
function extractSerializableValue(node) {
|
|
283
|
+
if (!node) return NOT_SERIALIZABLE;
|
|
284
|
+
switch (node.type) {
|
|
285
|
+
case "Literal":
|
|
286
|
+
case "BooleanLiteral": return {
|
|
287
|
+
value: node.value,
|
|
288
|
+
serializable: true
|
|
289
|
+
};
|
|
290
|
+
case "ArrayExpression": return extractArrayValue(node);
|
|
291
|
+
case "ObjectExpression": return extractObjectValue(node);
|
|
292
|
+
case "UnaryExpression": return extractUnaryValue(node);
|
|
293
|
+
case "Identifier": return extractIdentifierValue(node);
|
|
294
|
+
default: return NOT_SERIALIZABLE;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function extractArrayValue(node) {
|
|
298
|
+
const values = [];
|
|
299
|
+
for (const element of node.elements || []) {
|
|
300
|
+
if (!element) continue;
|
|
301
|
+
const result = extractSerializableValue(element);
|
|
302
|
+
if (!result.serializable) return NOT_SERIALIZABLE;
|
|
303
|
+
values.push(result.value);
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
value: values,
|
|
307
|
+
serializable: true
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function extractObjectValue(node) {
|
|
311
|
+
const obj = {};
|
|
312
|
+
for (const prop of node.properties || []) {
|
|
313
|
+
if (prop.type !== "Property") continue;
|
|
314
|
+
const key = getPropertyKey(prop);
|
|
315
|
+
if (!key) continue;
|
|
316
|
+
const result = extractSerializableValue(prop.value);
|
|
317
|
+
if (!result.serializable) return NOT_SERIALIZABLE;
|
|
318
|
+
obj[key] = result.value;
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
value: obj,
|
|
322
|
+
serializable: true
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function getPropertyKey(prop) {
|
|
326
|
+
if (prop.key?.type === "Identifier") return prop.key.name;
|
|
327
|
+
if (prop.key?.type === "Literal") return String(prop.key.value);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
function extractUnaryValue(node) {
|
|
331
|
+
if (node.operator === "-" && node.argument?.type === "Literal") return {
|
|
332
|
+
value: -node.argument.value,
|
|
333
|
+
serializable: true
|
|
334
|
+
};
|
|
335
|
+
return NOT_SERIALIZABLE;
|
|
336
|
+
}
|
|
337
|
+
function extractIdentifierValue(node) {
|
|
338
|
+
if (node.name === "undefined") return {
|
|
339
|
+
value: void 0,
|
|
340
|
+
serializable: true
|
|
341
|
+
};
|
|
342
|
+
return NOT_SERIALIZABLE;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Create an empty parsed route
|
|
346
|
+
*/
|
|
347
|
+
function createEmptyParsedRoute(filePath) {
|
|
348
|
+
return {
|
|
349
|
+
hasRouteDefinition: false,
|
|
350
|
+
routePath: null,
|
|
351
|
+
hasLoader: false,
|
|
352
|
+
hasMeta: false,
|
|
353
|
+
hasValidateSearch: false,
|
|
354
|
+
isLayout: filePath.includes("_layout") || filePath.includes("layout.vue"),
|
|
355
|
+
routeScriptContent: null
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/scaffolder.ts
|
|
361
|
+
/**
|
|
362
|
+
* Check if file content needs scaffolding (empty or invalid SFC)
|
|
363
|
+
*/
|
|
364
|
+
function needsScaffolding(content) {
|
|
365
|
+
const trimmed = content.trim();
|
|
366
|
+
if (!trimmed) return true;
|
|
367
|
+
if (!trimmed.includes("<template") && !trimmed.includes("<script")) return true;
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Generate Vue SFC boilerplate for a route file
|
|
372
|
+
*/
|
|
373
|
+
function generateRouteScaffold(options) {
|
|
374
|
+
const { routePath, isLayout } = options;
|
|
375
|
+
if (isLayout) return `<script setup lang="ts">
|
|
376
|
+
<\/script>
|
|
377
|
+
|
|
378
|
+
<template>
|
|
379
|
+
<div>
|
|
380
|
+
<slot />
|
|
381
|
+
</div>
|
|
382
|
+
</template>
|
|
383
|
+
`;
|
|
384
|
+
return `<script lang="ts">
|
|
385
|
+
export const Route = createFileRoute('${routePath}')({})
|
|
386
|
+
<\/script>
|
|
387
|
+
|
|
388
|
+
<script setup lang="ts">
|
|
389
|
+
<\/script>
|
|
390
|
+
|
|
391
|
+
<template>
|
|
392
|
+
<div>
|
|
393
|
+
<h1>Hello "${routePath === "/" ? "Home" : routePath}"!</h1>
|
|
394
|
+
</div>
|
|
395
|
+
</template>
|
|
396
|
+
`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/route-utils.ts
|
|
401
|
+
/**
|
|
402
|
+
* Check if directory path is root (empty or ".")
|
|
403
|
+
*/
|
|
404
|
+
function isRootDir(dirPath) {
|
|
405
|
+
return dirPath === "." || dirPath === "";
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Extract dynamic param name from segment (e.g., "$id" -> "id")
|
|
409
|
+
*/
|
|
410
|
+
function extractDynamicParam(segment) {
|
|
411
|
+
const match = segment.match(/^\$(.+)$/);
|
|
412
|
+
return match ? match[1] : null;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Check if filename uses dot notation for flat routes
|
|
416
|
+
*/
|
|
417
|
+
function isDotNotationRoute(fileName) {
|
|
418
|
+
return fileName.includes(".") && !fileName.startsWith(".") && !fileName.includes("[");
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Unescape bracket notation in filenames
|
|
422
|
+
* e.g., script[.]js -> script.js, api[.]v1 -> api.v1
|
|
423
|
+
*/
|
|
424
|
+
function unescapeBrackets(segment) {
|
|
425
|
+
return segment.replace(/\[(.)\]/g, "$1");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Process a single path segment for dynamic params and special conventions
|
|
429
|
+
*
|
|
430
|
+
* Conventions:
|
|
431
|
+
* - (auth) -> pathless group folder, returns empty string
|
|
432
|
+
* - _auth -> pathless layout folder, returns empty string
|
|
433
|
+
* - posts_ -> layout escape, strips trailing underscore
|
|
434
|
+
* - $id -> dynamic param :id
|
|
435
|
+
* - -folder -> excluded from routing (dash prefix)
|
|
436
|
+
* - [x] -> escape special characters
|
|
437
|
+
*/
|
|
438
|
+
function processPathSegment(segment) {
|
|
439
|
+
if (segment.startsWith("-")) return "";
|
|
440
|
+
if (segment.startsWith("(") && segment.endsWith(")")) return "";
|
|
441
|
+
if (segment.startsWith("_") && !segment.includes("$")) return "";
|
|
442
|
+
if (segment.endsWith("_") && segment.length > 1) return segment.slice(0, -1);
|
|
443
|
+
const dynamicParam = extractDynamicParam(segment);
|
|
444
|
+
if (dynamicParam) return `:${dynamicParam}`;
|
|
445
|
+
return unescapeBrackets(segment);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Process directory path into route segments
|
|
449
|
+
*/
|
|
450
|
+
function processDirectoryPath(dirPath) {
|
|
451
|
+
return dirPath.split("/").filter((part) => Boolean(part) && part !== ".").map(processPathSegment).filter(Boolean).join("/");
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Build route path from directory and segment
|
|
455
|
+
*/
|
|
456
|
+
function buildRoutePath(dirPath, segment) {
|
|
457
|
+
if (isRootDir(dirPath)) return segment ? `/${segment}` : "/";
|
|
458
|
+
const processedDir = processDirectoryPath(dirPath);
|
|
459
|
+
const basePath = processedDir ? `/${processedDir}` : "";
|
|
460
|
+
if (!segment) return basePath || "/";
|
|
461
|
+
return `${basePath}/${segment}`;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Create a standard RouteParam for a dynamic parameter
|
|
465
|
+
*/
|
|
466
|
+
function createDynamicParam(name) {
|
|
467
|
+
return {
|
|
468
|
+
name,
|
|
469
|
+
optional: false,
|
|
470
|
+
isSplat: false,
|
|
471
|
+
repeatable: false
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Create a catch-all RouteParam
|
|
476
|
+
*/
|
|
477
|
+
function createCatchAllParam() {
|
|
478
|
+
return {
|
|
479
|
+
name: "pathMatch",
|
|
480
|
+
optional: false,
|
|
481
|
+
isSplat: true,
|
|
482
|
+
repeatable: true
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Build a glob pattern for route file scanning
|
|
487
|
+
*/
|
|
488
|
+
function buildGlobPattern(extensions) {
|
|
489
|
+
const exts = extensions.map((e) => e.startsWith(".") ? e : `.${e}`);
|
|
490
|
+
if (exts.length === 1) return `**/*${exts[0]}`;
|
|
491
|
+
return `**/*.{${exts.map((e) => e.slice(1)).join(",")}}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/scanner.ts
|
|
496
|
+
/**
|
|
497
|
+
* Route file scanner
|
|
498
|
+
* Scans the routes directory and builds route nodes
|
|
499
|
+
*/
|
|
500
|
+
var RouteScanner = class {
|
|
501
|
+
config;
|
|
502
|
+
routeNodes = /* @__PURE__ */ new Map();
|
|
503
|
+
constructor(config) {
|
|
504
|
+
this.config = config;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Scan routes directory and return route nodes
|
|
508
|
+
*/
|
|
509
|
+
async scan() {
|
|
510
|
+
const files = await fg(this.buildGlobPattern(), {
|
|
511
|
+
cwd: this.config.routesDirPath,
|
|
512
|
+
onlyFiles: true,
|
|
513
|
+
ignore: ["**/node_modules/**"]
|
|
514
|
+
});
|
|
515
|
+
const directories = await fg("**/", {
|
|
516
|
+
cwd: this.config.routesDirPath,
|
|
517
|
+
onlyDirectories: true,
|
|
518
|
+
ignore: ["**/node_modules/**"]
|
|
519
|
+
});
|
|
520
|
+
const dirSet = new Set(directories.map((d) => d.replace(/\/$/, "")));
|
|
521
|
+
this.routeNodes.clear();
|
|
522
|
+
for (const file of files.sort()) {
|
|
523
|
+
const node = await this.processFile(file, dirSet);
|
|
524
|
+
if (node) this.routeNodes.set(file, node);
|
|
525
|
+
}
|
|
526
|
+
return Array.from(this.routeNodes.values());
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Process a single route file
|
|
530
|
+
*/
|
|
531
|
+
async processFile(filePath, dirSet) {
|
|
532
|
+
const fullPath = path.join(this.config.routesDirPath, filePath);
|
|
533
|
+
const fileName = path.basename(filePath, path.extname(filePath));
|
|
534
|
+
const dirPath = path.dirname(filePath);
|
|
535
|
+
const potentialDirPath = isRootDir(dirPath) ? fileName : `${dirPath}/${fileName}`;
|
|
536
|
+
const hasMatchingDirectory = dirSet.has(potentialDirPath);
|
|
537
|
+
const { type, routePath, params, rawSegment } = this.parseFileName(fileName, dirPath, hasMatchingDirectory);
|
|
538
|
+
let content = fs.readFileSync(fullPath, "utf-8");
|
|
539
|
+
if (needsScaffolding(content)) {
|
|
540
|
+
const isLayout = type === "layout";
|
|
541
|
+
const isDynamic = type === "dynamic";
|
|
542
|
+
const paramName = params[0]?.name;
|
|
543
|
+
content = generateRouteScaffold({
|
|
544
|
+
routePath,
|
|
545
|
+
isLayout,
|
|
546
|
+
isDynamic,
|
|
547
|
+
paramName
|
|
548
|
+
});
|
|
549
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
550
|
+
console.log(`[kimesh:router] Scaffolded route: ${filePath}`);
|
|
551
|
+
}
|
|
552
|
+
const parsed = parseRouteFile(content, fullPath);
|
|
553
|
+
return {
|
|
554
|
+
filePath,
|
|
555
|
+
fullPath,
|
|
556
|
+
routePath,
|
|
557
|
+
variableName: this.generateVariableName(filePath),
|
|
558
|
+
type,
|
|
559
|
+
routeName: this.generateRouteName(routePath),
|
|
560
|
+
isLazy: this.shouldBeLazy(filePath),
|
|
561
|
+
params,
|
|
562
|
+
parsed,
|
|
563
|
+
children: [],
|
|
564
|
+
rawSegment
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Parse filename to determine route type and path
|
|
569
|
+
*
|
|
570
|
+
* Kimesh file-based routing conventions:
|
|
571
|
+
* - __root.vue → root layout (wraps everything)
|
|
572
|
+
* - index.vue → index route
|
|
573
|
+
* - about.vue → regular page (/about)
|
|
574
|
+
* - $id.vue → dynamic param (/users/:id)
|
|
575
|
+
* - $.vue → catch-all (splat route)
|
|
576
|
+
* - _auth.vue → pathless layout (underscore prefix, no URL segment)
|
|
577
|
+
* - posts.vue + posts/ → layout route (file + matching directory)
|
|
578
|
+
* - posts.index.vue → flat route notation (dot separator)
|
|
579
|
+
*/
|
|
580
|
+
parseFileName(fileName, dirPath, hasMatchingDirectory = false) {
|
|
581
|
+
const rawSegment = fileName;
|
|
582
|
+
if (fileName === "__root") return {
|
|
583
|
+
type: "layout",
|
|
584
|
+
routePath: "/",
|
|
585
|
+
params: [],
|
|
586
|
+
rawSegment
|
|
587
|
+
};
|
|
588
|
+
if (fileName.startsWith("_")) return {
|
|
589
|
+
type: "layout",
|
|
590
|
+
routePath: buildRoutePath(dirPath, ""),
|
|
591
|
+
params: [],
|
|
592
|
+
rawSegment
|
|
593
|
+
};
|
|
594
|
+
if (fileName === "index") return {
|
|
595
|
+
type: "index",
|
|
596
|
+
routePath: buildRoutePath(dirPath, ""),
|
|
597
|
+
params: [],
|
|
598
|
+
rawSegment
|
|
599
|
+
};
|
|
600
|
+
if (isDotNotationRoute(fileName)) return this.parseDotNotationRoute(fileName, dirPath, rawSegment);
|
|
601
|
+
if (fileName === "$") return {
|
|
602
|
+
type: "catch-all",
|
|
603
|
+
routePath: buildRoutePath(dirPath, ":pathMatch(.*)*"),
|
|
604
|
+
params: [createCatchAllParam()],
|
|
605
|
+
rawSegment
|
|
606
|
+
};
|
|
607
|
+
const dynamicParam = extractDynamicParam(fileName);
|
|
608
|
+
if (dynamicParam) return {
|
|
609
|
+
type: "dynamic",
|
|
610
|
+
routePath: buildRoutePath(dirPath, `:${dynamicParam}`),
|
|
611
|
+
params: [createDynamicParam(dynamicParam)],
|
|
612
|
+
rawSegment
|
|
613
|
+
};
|
|
614
|
+
if (hasMatchingDirectory) return {
|
|
615
|
+
type: "layout",
|
|
616
|
+
routePath: buildRoutePath(dirPath, fileName),
|
|
617
|
+
params: [],
|
|
618
|
+
rawSegment
|
|
619
|
+
};
|
|
620
|
+
return {
|
|
621
|
+
type: "page",
|
|
622
|
+
routePath: buildRoutePath(dirPath, fileName),
|
|
623
|
+
params: [],
|
|
624
|
+
rawSegment
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
parseDotNotationRoute(fileName, dirPath, rawSegment) {
|
|
628
|
+
const segments = fileName.split(".");
|
|
629
|
+
const lastSegment = segments[segments.length - 1];
|
|
630
|
+
const parentSegments = segments.slice(0, -1);
|
|
631
|
+
const virtualDir = isRootDir(dirPath) ? parentSegments.join("/") : `${dirPath}/${parentSegments.join("/")}`;
|
|
632
|
+
if (lastSegment === "index") return {
|
|
633
|
+
type: "index",
|
|
634
|
+
routePath: buildRoutePath(virtualDir, ""),
|
|
635
|
+
params: [],
|
|
636
|
+
rawSegment
|
|
637
|
+
};
|
|
638
|
+
const dynamicParam = extractDynamicParam(lastSegment);
|
|
639
|
+
if (dynamicParam) return {
|
|
640
|
+
type: "dynamic",
|
|
641
|
+
routePath: buildRoutePath(virtualDir, `:${dynamicParam}`),
|
|
642
|
+
params: [createDynamicParam(dynamicParam)],
|
|
643
|
+
rawSegment
|
|
644
|
+
};
|
|
645
|
+
return {
|
|
646
|
+
type: "page",
|
|
647
|
+
routePath: buildRoutePath(virtualDir, lastSegment),
|
|
648
|
+
params: [],
|
|
649
|
+
rawSegment
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Generate a valid JavaScript variable name from file path
|
|
654
|
+
*/
|
|
655
|
+
generateVariableName(filePath) {
|
|
656
|
+
return filePath.replace(/\.[^/.]+$/, "").replace(/[\/\\]/g, "_").replace(/[\[\]$().+-]/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_").replace(/^(\d)/, "_$1");
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Generate route name from route path
|
|
660
|
+
*/
|
|
661
|
+
generateRouteName(routePath) {
|
|
662
|
+
if (routePath === "/") return "index";
|
|
663
|
+
return routePath.slice(1).replace(/\//g, "-").replace(/:/g, "").replace(/\(.*?\)\*/g, "catchAll").replace(/\?/g, "");
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Check if route should be lazy loaded based on config
|
|
667
|
+
*/
|
|
668
|
+
shouldBeLazy(filePath) {
|
|
669
|
+
const { importMode } = this.config;
|
|
670
|
+
if (typeof importMode === "function") return importMode(filePath) === "async";
|
|
671
|
+
return importMode === "async";
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Get all scanned route nodes
|
|
675
|
+
*/
|
|
676
|
+
getNodes() {
|
|
677
|
+
return Array.from(this.routeNodes.values());
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Build glob pattern for file scanning based on configured extensions
|
|
681
|
+
*/
|
|
682
|
+
buildGlobPattern() {
|
|
683
|
+
return buildGlobPattern(this.config.extensions);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
/**
|
|
687
|
+
* Create a route scanner instance
|
|
688
|
+
*/
|
|
689
|
+
function createScanner(config) {
|
|
690
|
+
return new RouteScanner(config);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/tree-builder.ts
|
|
695
|
+
/**
|
|
696
|
+
* Build a route tree from flat route nodes
|
|
697
|
+
*/
|
|
698
|
+
var RouteTreeBuilder = class {
|
|
699
|
+
config;
|
|
700
|
+
root;
|
|
701
|
+
layoutNodes = /* @__PURE__ */ new Map();
|
|
702
|
+
pathlessLayoutMap = /* @__PURE__ */ new Map();
|
|
703
|
+
constructor(config) {
|
|
704
|
+
this.config = config;
|
|
705
|
+
this.root = this.createTreeNode("", "/");
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Build route tree from scanned nodes
|
|
709
|
+
*/
|
|
710
|
+
build(nodes) {
|
|
711
|
+
const { layouts, pages } = this.separateLayoutsAndPages(nodes);
|
|
712
|
+
for (const layout of layouts) this.registerLayout(layout);
|
|
713
|
+
const { pathlessPages, regularPages } = this.groupPagesByPathlessLayout(pages);
|
|
714
|
+
for (const page of regularPages) this.insertNode(page);
|
|
715
|
+
const routes = this.flattenTree();
|
|
716
|
+
this.addPathlessLayoutRoutes(routes, pathlessPages);
|
|
717
|
+
return routes;
|
|
718
|
+
}
|
|
719
|
+
separateLayoutsAndPages(nodes) {
|
|
720
|
+
const layouts = [];
|
|
721
|
+
const pages = [];
|
|
722
|
+
for (const node of nodes) if (node.type === "layout") layouts.push(node);
|
|
723
|
+
else pages.push(node);
|
|
724
|
+
return {
|
|
725
|
+
layouts,
|
|
726
|
+
pages
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
registerLayout(node) {
|
|
730
|
+
const fileName = path.basename(node.filePath, path.extname(node.filePath));
|
|
731
|
+
const dir = path.dirname(node.filePath);
|
|
732
|
+
if (fileName === "__root") {
|
|
733
|
+
this.layoutNodes.set("", node);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const layoutDir = isRootDir(dir) ? fileName : `${dir}/${fileName}`;
|
|
737
|
+
if (fileName.startsWith("_") && !fileName.startsWith("__")) {
|
|
738
|
+
this.layoutNodes.set(layoutDir, node);
|
|
739
|
+
const folderPath = isRootDir(dir) ? `${fileName}/` : `${dir}/${fileName}/`;
|
|
740
|
+
this.pathlessLayoutMap.set(folderPath, layoutDir);
|
|
741
|
+
} else this.layoutNodes.set(layoutDir, node);
|
|
742
|
+
}
|
|
743
|
+
groupPagesByPathlessLayout(pages) {
|
|
744
|
+
const pathlessPages = /* @__PURE__ */ new Map();
|
|
745
|
+
const regularPages = [];
|
|
746
|
+
for (const page of pages) {
|
|
747
|
+
const matchedPathless = this.findMatchingPathlessLayout(page);
|
|
748
|
+
if (matchedPathless) {
|
|
749
|
+
if (!pathlessPages.has(matchedPathless)) pathlessPages.set(matchedPathless, []);
|
|
750
|
+
pathlessPages.get(matchedPathless).push(page);
|
|
751
|
+
} else regularPages.push(page);
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
pathlessPages,
|
|
755
|
+
regularPages
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
findMatchingPathlessLayout(page) {
|
|
759
|
+
for (const [folderPath, layoutKey] of this.pathlessLayoutMap) if (page.filePath.startsWith(folderPath)) return layoutKey;
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
addPathlessLayoutRoutes(routes, pathlessPages) {
|
|
763
|
+
if (pathlessPages.size === 0) return;
|
|
764
|
+
const rootLayout = routes.find((r) => r.type === "layout" && r.routePath === "/");
|
|
765
|
+
for (const [layoutKey, layoutPages] of pathlessPages) {
|
|
766
|
+
const layout = this.layoutNodes.get(layoutKey);
|
|
767
|
+
if (!layout) continue;
|
|
768
|
+
layout.children = layoutPages.map((page) => {
|
|
769
|
+
page.parent = layout;
|
|
770
|
+
return page;
|
|
771
|
+
});
|
|
772
|
+
if (rootLayout) {
|
|
773
|
+
layout.parent = rootLayout;
|
|
774
|
+
rootLayout.children = rootLayout.children || [];
|
|
775
|
+
rootLayout.children.push(layout);
|
|
776
|
+
} else routes.push(layout);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Insert a node into the tree
|
|
781
|
+
*/
|
|
782
|
+
insertNode(node) {
|
|
783
|
+
const segments = this.getPathSegments(node.routePath);
|
|
784
|
+
let current = this.root;
|
|
785
|
+
for (let i = 0; i < segments.length; i++) {
|
|
786
|
+
const segment = segments[i];
|
|
787
|
+
const isLast = i === segments.length - 1;
|
|
788
|
+
if (!current.children.has(segment)) {
|
|
789
|
+
const routePath = "/" + segments.slice(0, i + 1).join("/");
|
|
790
|
+
current.children.set(segment, this.createTreeNode(segment, routePath, current));
|
|
791
|
+
}
|
|
792
|
+
current = current.children.get(segment);
|
|
793
|
+
if (isLast) {
|
|
794
|
+
current.node = node;
|
|
795
|
+
node.parent = current.parent?.node;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (segments.length === 0 && node.type === "index") this.root.node = node;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Get path segments from route path
|
|
802
|
+
*/
|
|
803
|
+
getPathSegments(routePath) {
|
|
804
|
+
return routePath.split("/").filter(Boolean);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Create a tree node
|
|
808
|
+
*/
|
|
809
|
+
createTreeNode(segment, routePath, parent) {
|
|
810
|
+
return {
|
|
811
|
+
segment,
|
|
812
|
+
routePath,
|
|
813
|
+
children: /* @__PURE__ */ new Map(),
|
|
814
|
+
parent
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Flatten tree to route array
|
|
819
|
+
*/
|
|
820
|
+
flattenTree() {
|
|
821
|
+
const routes = [];
|
|
822
|
+
const rootLayout = this.layoutNodes.get("");
|
|
823
|
+
if (rootLayout) {
|
|
824
|
+
const children = this.flattenChildren(this.root);
|
|
825
|
+
if (this.root.node) {
|
|
826
|
+
this.root.node.parent = rootLayout;
|
|
827
|
+
children.unshift(this.root.node);
|
|
828
|
+
}
|
|
829
|
+
for (const child of children) child.parent = rootLayout;
|
|
830
|
+
rootLayout.children = children;
|
|
831
|
+
routes.push(rootLayout);
|
|
832
|
+
} else {
|
|
833
|
+
const children = this.flattenChildren(this.root);
|
|
834
|
+
if (this.root.node) children.unshift(this.root.node);
|
|
835
|
+
routes.push(...children);
|
|
836
|
+
}
|
|
837
|
+
return routes;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Flatten children of a tree node
|
|
841
|
+
*/
|
|
842
|
+
flattenChildren(treeNode, currentDir = "") {
|
|
843
|
+
const children = [];
|
|
844
|
+
const sortedEntries = Array.from(treeNode.children.entries()).sort(([a], [b]) => this.compareSegments(a, b));
|
|
845
|
+
for (const [segment, child] of sortedEntries) {
|
|
846
|
+
const childDir = currentDir ? `${currentDir}/${segment}` : segment;
|
|
847
|
+
const flattenedChildren = this.flattenChildNode(child, childDir);
|
|
848
|
+
children.push(...flattenedChildren);
|
|
849
|
+
}
|
|
850
|
+
return children;
|
|
851
|
+
}
|
|
852
|
+
flattenChildNode(child, childDir) {
|
|
853
|
+
const layout = this.layoutNodes.get(childDir);
|
|
854
|
+
if (layout) return [this.createLayoutWithChildren(layout, child, childDir)];
|
|
855
|
+
if (child.node) return this.flattenNodeWithChildren(child, childDir);
|
|
856
|
+
return this.flattenChildren(child, childDir);
|
|
857
|
+
}
|
|
858
|
+
createLayoutWithChildren(layout, child, childDir) {
|
|
859
|
+
layout.children = this.flattenChildren(child, childDir);
|
|
860
|
+
if (child.node && child.node.type !== "layout") {
|
|
861
|
+
child.node.parent = layout;
|
|
862
|
+
layout.children.unshift(child.node);
|
|
863
|
+
}
|
|
864
|
+
for (const layoutChild of layout.children) layoutChild.parent = layout;
|
|
865
|
+
return layout;
|
|
866
|
+
}
|
|
867
|
+
flattenNodeWithChildren(child, childDir) {
|
|
868
|
+
const node = child.node;
|
|
869
|
+
if (node.type === "layout") {
|
|
870
|
+
node.children = this.flattenChildren(child, childDir);
|
|
871
|
+
return [node];
|
|
872
|
+
}
|
|
873
|
+
return [node, ...this.flattenChildren(child, childDir)];
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Compare segments for sorting
|
|
877
|
+
* Order: static > dynamic > catch-all
|
|
878
|
+
*/
|
|
879
|
+
compareSegments(a, b) {
|
|
880
|
+
const aScore = this.getSegmentScore(a);
|
|
881
|
+
const bScore = this.getSegmentScore(b);
|
|
882
|
+
if (aScore !== bScore) return aScore - bScore;
|
|
883
|
+
return a.localeCompare(b);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Get sorting score for a segment
|
|
887
|
+
*/
|
|
888
|
+
getSegmentScore(segment) {
|
|
889
|
+
if (segment.includes("(.*)*") || segment.includes("pathMatch")) return 100;
|
|
890
|
+
if (segment.startsWith(":")) return 50;
|
|
891
|
+
return 0;
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* Create a route tree builder
|
|
896
|
+
*/
|
|
897
|
+
function createTreeBuilder(config) {
|
|
898
|
+
return new RouteTreeBuilder(config);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Build route tree from nodes
|
|
902
|
+
*/
|
|
903
|
+
function buildRouteTree(nodes, config) {
|
|
904
|
+
return new RouteTreeBuilder(config).build(nodes);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
//#endregion
|
|
908
|
+
//#region src/codegen/routes.ts
|
|
909
|
+
/**
|
|
910
|
+
* Generate routes.gen.ts content
|
|
911
|
+
*/
|
|
912
|
+
function generateRoutes(routes, config) {
|
|
913
|
+
const imports = [];
|
|
914
|
+
const importMap = /* @__PURE__ */ new Map();
|
|
915
|
+
const routeDefImportMap = /* @__PURE__ */ new Map();
|
|
916
|
+
collectImports(routes, config, imports, importMap);
|
|
917
|
+
collectRouteDefinitionImports(routes, config, imports, routeDefImportMap);
|
|
918
|
+
const routeRecords = generateRouteRecords(routes, config, importMap, routeDefImportMap);
|
|
919
|
+
return `// Auto-generated by @kimesh/router-generator
|
|
920
|
+
// Do not edit this file manually
|
|
921
|
+
|
|
922
|
+
${imports.join("\n")}
|
|
923
|
+
|
|
924
|
+
export const routes = ${routeRecords}
|
|
925
|
+
`;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Collect imports from route nodes
|
|
929
|
+
*/
|
|
930
|
+
function collectImports(routes, config, imports, importMap) {
|
|
931
|
+
for (const route of routes) {
|
|
932
|
+
if (!route.isLazy) {
|
|
933
|
+
const importPath = getImportPath(route, config);
|
|
934
|
+
const importName = route.variableName;
|
|
935
|
+
imports.push(`import ${importName} from '${importPath}'`);
|
|
936
|
+
importMap.set(route.filePath, importName);
|
|
937
|
+
}
|
|
938
|
+
if (route.children.length > 0) collectImports(route.children, config, imports, importMap);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Collect route definition imports for routes with loaders/meta
|
|
943
|
+
* Uses ?route query to extract route definitions at build time (Vite 8/rolldown compatible)
|
|
944
|
+
*/
|
|
945
|
+
function collectRouteDefinitionImports(routes, config, imports, routeDefImportMap) {
|
|
946
|
+
for (const route of routes) {
|
|
947
|
+
if (route.parsed.hasRouteDefinition) {
|
|
948
|
+
const importPath = getImportPath(route, config);
|
|
949
|
+
const defName = `${route.variableName}_def`;
|
|
950
|
+
imports.push(`import ${defName} from '${importPath}?route'`);
|
|
951
|
+
routeDefImportMap.set(route.filePath, defName);
|
|
952
|
+
}
|
|
953
|
+
if (route.children.length > 0) collectRouteDefinitionImports(route.children, config, imports, routeDefImportMap);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Get import path relative to generated file
|
|
958
|
+
*/
|
|
959
|
+
function getImportPath(route, config) {
|
|
960
|
+
const fromPath = config.generatedDirPath;
|
|
961
|
+
const toPath = route.fullPath;
|
|
962
|
+
let relativePath = path.relative(fromPath, toPath);
|
|
963
|
+
if (!relativePath.startsWith(".")) relativePath = "./" + relativePath;
|
|
964
|
+
return relativePath;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Generate route records array
|
|
968
|
+
*/
|
|
969
|
+
function generateRouteRecords(routes, config, importMap, routeDefImportMap, indent = 0) {
|
|
970
|
+
const ctx = {
|
|
971
|
+
config,
|
|
972
|
+
importMap,
|
|
973
|
+
routeDefImportMap
|
|
974
|
+
};
|
|
975
|
+
const pad = " ".repeat(indent);
|
|
976
|
+
const innerPad = " ".repeat(indent + 1);
|
|
977
|
+
return `[\n${routes.map((route) => generateSingleRouteRecord(route, ctx, innerPad, indent)).join(",\n")}\n${pad}]`;
|
|
978
|
+
}
|
|
979
|
+
function generateSingleRouteRecord(route, ctx, innerPad, indent) {
|
|
980
|
+
const lines = [`${innerPad}{`];
|
|
981
|
+
const routePath = route.parent ? getRelativePath(route) : route.routePath;
|
|
982
|
+
lines.push(`${innerPad} path: '${routePath}',`);
|
|
983
|
+
if (shouldIncludeName(route)) lines.push(`${innerPad} name: '${route.routeName}',`);
|
|
984
|
+
lines.push(generateComponentLine(route, ctx, innerPad));
|
|
985
|
+
const routeDefName = ctx.routeDefImportMap.get(route.filePath);
|
|
986
|
+
const metaProps = [];
|
|
987
|
+
if (routeDefName) metaProps.push(`__kimesh: ${routeDefName}`);
|
|
988
|
+
if (route.layer) metaProps.push(`__kimeshLayer: '${route.layer}'`);
|
|
989
|
+
if (metaProps.length > 0) lines.push(`${innerPad} meta: { ${metaProps.join(", ")} },`);
|
|
990
|
+
if (route.children.length > 0) {
|
|
991
|
+
const childRecords = generateRouteRecords(route.children, ctx.config, ctx.importMap, ctx.routeDefImportMap, indent + 2);
|
|
992
|
+
lines.push(`${innerPad} children: ${childRecords},`);
|
|
993
|
+
}
|
|
994
|
+
lines.push(`${innerPad}}`);
|
|
995
|
+
return lines.join("\n");
|
|
996
|
+
}
|
|
997
|
+
function shouldIncludeName(route) {
|
|
998
|
+
return route.type !== "layout" || route.children.length === 0;
|
|
999
|
+
}
|
|
1000
|
+
function generateComponentLine(route, ctx, innerPad) {
|
|
1001
|
+
if (route.isLazy) return `${innerPad} component: () => import('${getImportPath(route, ctx.config)}'),`;
|
|
1002
|
+
return `${innerPad} component: ${ctx.importMap.get(route.filePath) || route.variableName},`;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Get path relative to parent route
|
|
1006
|
+
*/
|
|
1007
|
+
function getRelativePath(route) {
|
|
1008
|
+
if (!route.parent) return route.routePath;
|
|
1009
|
+
const parentPath = route.parent.routePath;
|
|
1010
|
+
const routePath = route.routePath;
|
|
1011
|
+
if (parentPath === "/") return routePath === "/" ? "" : stripLeadingSlash(routePath);
|
|
1012
|
+
let relativePath = routePath;
|
|
1013
|
+
if (relativePath.startsWith(parentPath)) relativePath = relativePath.slice(parentPath.length);
|
|
1014
|
+
relativePath = stripLeadingSlash(relativePath);
|
|
1015
|
+
return !relativePath && route.type === "index" ? "" : relativePath;
|
|
1016
|
+
}
|
|
1017
|
+
function stripLeadingSlash(path$1) {
|
|
1018
|
+
return path$1.startsWith("/") ? path$1.slice(1) : path$1;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region src/codegen/types.ts
|
|
1023
|
+
/**
|
|
1024
|
+
* Generate typed-routes.d.ts content with comprehensive type safety
|
|
1025
|
+
*/
|
|
1026
|
+
function generateRouteTypes(routes) {
|
|
1027
|
+
const routeInfos = collectAllRoutes(routes);
|
|
1028
|
+
const routeMapEntries = routeInfos.map((r) => generateRouteRecordInfo(r)).join("\n");
|
|
1029
|
+
const routePathsUnion = routeInfos.length > 0 ? routeInfos.map((r) => `'${r.path}'`).join(" | ") : "never";
|
|
1030
|
+
return `/* eslint-disable */
|
|
1031
|
+
/* prettier-ignore */
|
|
1032
|
+
// @ts-nocheck
|
|
1033
|
+
// Auto-generated by @kimesh/router-generator
|
|
1034
|
+
// Do not edit this file manually
|
|
1035
|
+
|
|
1036
|
+
import type {
|
|
1037
|
+
RouteRecordInfo,
|
|
1038
|
+
ParamValue,
|
|
1039
|
+
ParamValueOneOrMore,
|
|
1040
|
+
ParamValueZeroOrMore,
|
|
1041
|
+
ParamValueZeroOrOne,
|
|
1042
|
+
} from 'vue-router'
|
|
1043
|
+
|
|
1044
|
+
// Augment @kimesh/router-runtime for typed composables
|
|
1045
|
+
declare module '@kimesh/router-runtime' {
|
|
1046
|
+
export interface RouteNamedMap {
|
|
1047
|
+
${routeMapEntries}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Augment vue-router for typed router.push(), useRoute(), etc.
|
|
1052
|
+
declare module 'vue-router' {
|
|
1053
|
+
export interface TypesConfig {
|
|
1054
|
+
RouteNamedMap: import('@kimesh/router-runtime').RouteNamedMap
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Union of all route names/paths */
|
|
1059
|
+
export type RouteNames = ${routePathsUnion}
|
|
1060
|
+
|
|
1061
|
+
/** Union of all route paths */
|
|
1062
|
+
export type RoutePaths = ${routePathsUnion}
|
|
1063
|
+
`;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Collect all routes recursively into flat array
|
|
1067
|
+
*/
|
|
1068
|
+
function collectAllRoutes(routes) {
|
|
1069
|
+
const infos = [];
|
|
1070
|
+
collectRoutesRecursively(routes, "", infos);
|
|
1071
|
+
return infos;
|
|
1072
|
+
}
|
|
1073
|
+
function collectRoutesRecursively(nodes, parentPath, infos) {
|
|
1074
|
+
for (const route of nodes) {
|
|
1075
|
+
const fullPath = computeFullPath(route.routePath, parentPath);
|
|
1076
|
+
if (route.type === "layout") {
|
|
1077
|
+
if (route.children.length > 0) {
|
|
1078
|
+
const childParentPath = fullPath === "/" ? "" : fullPath;
|
|
1079
|
+
collectRoutesRecursively(route.children, childParentPath, infos);
|
|
1080
|
+
}
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
infos.push({
|
|
1084
|
+
name: fullPath,
|
|
1085
|
+
path: fullPath,
|
|
1086
|
+
params: collectParamsFromPath(fullPath),
|
|
1087
|
+
children: collectChildPaths(route.children, fullPath)
|
|
1088
|
+
});
|
|
1089
|
+
if (route.children.length > 0) collectRoutesRecursively(route.children, fullPath, infos);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function computeFullPath(routePath, parentPath) {
|
|
1093
|
+
if (routePath === "" || routePath === "/") return parentPath || "/";
|
|
1094
|
+
if (routePath.startsWith("/")) return routePath;
|
|
1095
|
+
return parentPath === "/" ? `/${routePath}` : `${parentPath}/${routePath}`;
|
|
1096
|
+
}
|
|
1097
|
+
function collectChildPaths(children, parentFullPath) {
|
|
1098
|
+
return children.filter((c) => c.type !== "layout").map((c) => computeFullPath(c.routePath, parentFullPath));
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Extract params from a route path
|
|
1102
|
+
* e.g., '/users/:userId/posts/:postId' -> [{ name: 'userId', ... }, { name: 'postId', ... }]
|
|
1103
|
+
*/
|
|
1104
|
+
function collectParamsFromPath(routePath) {
|
|
1105
|
+
const params = [];
|
|
1106
|
+
const regex = /:([a-zA-Z_][a-zA-Z0-9_]*)(\?)?(\+)?(\*)?/g;
|
|
1107
|
+
let match;
|
|
1108
|
+
while ((match = regex.exec(routePath)) !== null) {
|
|
1109
|
+
const name = match[1];
|
|
1110
|
+
const hasQuestion = match[2] === "?";
|
|
1111
|
+
const hasPlus = match[3] === "+";
|
|
1112
|
+
const hasStar = match[4] === "*";
|
|
1113
|
+
params.push({
|
|
1114
|
+
name,
|
|
1115
|
+
optional: hasQuestion || hasStar,
|
|
1116
|
+
isSplat: hasStar,
|
|
1117
|
+
repeatable: hasPlus || hasStar
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
return params;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Generate RouteRecordInfo entry for a route
|
|
1124
|
+
*/
|
|
1125
|
+
function generateRouteRecordInfo(route) {
|
|
1126
|
+
const rawParams = generateParamsType(route.params, true);
|
|
1127
|
+
const params = generateParamsType(route.params, false);
|
|
1128
|
+
const children = route.children.length > 0 ? route.children.map((c) => `'${c}'`).join(" | ") : "never";
|
|
1129
|
+
return ` '${route.name}': RouteRecordInfo<
|
|
1130
|
+
'${route.name}',
|
|
1131
|
+
'${route.path}',
|
|
1132
|
+
${rawParams},
|
|
1133
|
+
${params},
|
|
1134
|
+
${children}
|
|
1135
|
+
>,`;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Generate params type object
|
|
1139
|
+
*/
|
|
1140
|
+
function generateParamsType(params, isRaw) {
|
|
1141
|
+
if (params.length === 0) return "Record<never, never>";
|
|
1142
|
+
return `{ ${params.map((p) => formatParamEntry(p, isRaw)).join("; ")} }`;
|
|
1143
|
+
}
|
|
1144
|
+
function formatParamEntry(param, isRaw) {
|
|
1145
|
+
const optional = param.optional ? "?" : "";
|
|
1146
|
+
const type = getParamValueType(param, isRaw);
|
|
1147
|
+
return `${param.name}${optional}: ${type}`;
|
|
1148
|
+
}
|
|
1149
|
+
function getParamValueType(param, isRaw) {
|
|
1150
|
+
if (param.repeatable) return param.optional ? `ParamValueZeroOrMore<${isRaw}>` : `ParamValueOneOrMore<${isRaw}>`;
|
|
1151
|
+
if (param.optional) return `ParamValueZeroOrOne<${isRaw}>`;
|
|
1152
|
+
return `ParamValue<${isRaw}>`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
//#endregion
|
|
1156
|
+
//#region src/extract-route.ts
|
|
1157
|
+
const ROUTE_QUERY_RE = /[?&]route\b/;
|
|
1158
|
+
const CREATE_FILE_ROUTE_START_RE = /export\s+const\s+Route\s*=\s*createFileRoute\s*\(\s*['"`][^'"`]*['"`]\s*\)\s*\(/;
|
|
1159
|
+
const EMPTY_EXPORT = "export default {}";
|
|
1160
|
+
/**
|
|
1161
|
+
* Check if the id has a ?route query parameter
|
|
1162
|
+
*/
|
|
1163
|
+
function isRouteQuery(id) {
|
|
1164
|
+
return ROUTE_QUERY_RE.test(id);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Get the base path without the ?route query
|
|
1168
|
+
*/
|
|
1169
|
+
function getBasePath(id) {
|
|
1170
|
+
return id.split("?")[0];
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Extract route definition from a Vue SFC file
|
|
1174
|
+
* Returns the route config object as a module with default export
|
|
1175
|
+
*/
|
|
1176
|
+
function extractRouteDefinition(filePath) {
|
|
1177
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
1178
|
+
const match = CREATE_FILE_ROUTE_START_RE.exec(code);
|
|
1179
|
+
if (!match) return EMPTY_EXPORT;
|
|
1180
|
+
const routeObject = extractBalancedObject(code, match.index + match[0].length);
|
|
1181
|
+
if (!routeObject) return EMPTY_EXPORT;
|
|
1182
|
+
return `${extractUsedImports(code, routeObject, path.dirname(filePath))}\nexport default ${routeObject}`;
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Extract a balanced object starting from a position in the code
|
|
1186
|
+
* Handles nested braces, strings, template literals, and comments
|
|
1187
|
+
*/
|
|
1188
|
+
function extractBalancedObject(code, startPos) {
|
|
1189
|
+
let i = skipWhitespace(code, startPos);
|
|
1190
|
+
if (code[i] !== "{") return null;
|
|
1191
|
+
const objectStart = i;
|
|
1192
|
+
const state = {
|
|
1193
|
+
inString: null,
|
|
1194
|
+
inTemplateString: false,
|
|
1195
|
+
inLineComment: false,
|
|
1196
|
+
inBlockComment: false,
|
|
1197
|
+
depth: 0
|
|
1198
|
+
};
|
|
1199
|
+
for (; i < code.length; i++) {
|
|
1200
|
+
const result = processCharacter(code, i, state);
|
|
1201
|
+
if (result.skip) {
|
|
1202
|
+
i = result.newIndex ?? i;
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (result.foundEnd) return code.slice(objectStart, i + 1);
|
|
1206
|
+
}
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
function skipWhitespace(code, pos) {
|
|
1210
|
+
while (pos < code.length && /\s/.test(code[pos])) pos++;
|
|
1211
|
+
return pos;
|
|
1212
|
+
}
|
|
1213
|
+
function processCharacter(code, i, state) {
|
|
1214
|
+
const char = code[i];
|
|
1215
|
+
const nextChar = code[i + 1];
|
|
1216
|
+
const prevChar = code[i - 1];
|
|
1217
|
+
if (state.inLineComment) {
|
|
1218
|
+
if (char === "\n") state.inLineComment = false;
|
|
1219
|
+
return { skip: true };
|
|
1220
|
+
}
|
|
1221
|
+
if (state.inBlockComment) {
|
|
1222
|
+
if (char === "*" && nextChar === "/") {
|
|
1223
|
+
state.inBlockComment = false;
|
|
1224
|
+
return {
|
|
1225
|
+
skip: true,
|
|
1226
|
+
newIndex: i + 1
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
return { skip: true };
|
|
1230
|
+
}
|
|
1231
|
+
if (state.inTemplateString) {
|
|
1232
|
+
if (char === "`") state.inTemplateString = false;
|
|
1233
|
+
return { skip: true };
|
|
1234
|
+
}
|
|
1235
|
+
if (state.inString) {
|
|
1236
|
+
if (char === state.inString && prevChar !== "\\") state.inString = null;
|
|
1237
|
+
return { skip: true };
|
|
1238
|
+
}
|
|
1239
|
+
if (char === "/" && nextChar === "/") {
|
|
1240
|
+
state.inLineComment = true;
|
|
1241
|
+
return { skip: true };
|
|
1242
|
+
}
|
|
1243
|
+
if (char === "/" && nextChar === "*") {
|
|
1244
|
+
state.inBlockComment = true;
|
|
1245
|
+
return {
|
|
1246
|
+
skip: true,
|
|
1247
|
+
newIndex: i + 1
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
if (char === "`") {
|
|
1251
|
+
state.inTemplateString = true;
|
|
1252
|
+
return { skip: true };
|
|
1253
|
+
}
|
|
1254
|
+
if (char === "\"" || char === "'") {
|
|
1255
|
+
state.inString = char;
|
|
1256
|
+
return { skip: true };
|
|
1257
|
+
}
|
|
1258
|
+
if (char === "{") {
|
|
1259
|
+
state.depth++;
|
|
1260
|
+
return { skip: false };
|
|
1261
|
+
}
|
|
1262
|
+
if (char === "}") {
|
|
1263
|
+
state.depth--;
|
|
1264
|
+
if (state.depth === 0) return {
|
|
1265
|
+
skip: false,
|
|
1266
|
+
foundEnd: true
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
return { skip: false };
|
|
1270
|
+
}
|
|
1271
|
+
const IMPORT_REGEX = /import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/g;
|
|
1272
|
+
const SKIP_IMPORT_SOURCES = new Set(["vue", "@kimesh/router-runtime"]);
|
|
1273
|
+
/**
|
|
1274
|
+
* Extract imports that are used in the route object
|
|
1275
|
+
* Converts relative imports to absolute paths for virtual module compatibility
|
|
1276
|
+
*/
|
|
1277
|
+
function extractUsedImports(code, routeObject, fileDir) {
|
|
1278
|
+
const namedImports = /* @__PURE__ */ new Map();
|
|
1279
|
+
const defaultImports = /* @__PURE__ */ new Map();
|
|
1280
|
+
let match;
|
|
1281
|
+
while ((match = IMPORT_REGEX.exec(code)) !== null) processImportMatch(match, routeObject, fileDir, namedImports, defaultImports);
|
|
1282
|
+
return buildImportStatements(namedImports, defaultImports);
|
|
1283
|
+
}
|
|
1284
|
+
function processImportMatch(match, routeObject, fileDir, namedImports, defaultImports) {
|
|
1285
|
+
const namedImportStr = match[1];
|
|
1286
|
+
const defaultImport = match[2];
|
|
1287
|
+
let source = match[3];
|
|
1288
|
+
if (shouldSkipImport(source)) return;
|
|
1289
|
+
source = resolveImportSource(source, fileDir);
|
|
1290
|
+
if (namedImportStr) addUsedNamedImports(namedImportStr, source, routeObject, namedImports);
|
|
1291
|
+
else if (defaultImport && routeObject.includes(defaultImport)) defaultImports.set(source, defaultImport);
|
|
1292
|
+
}
|
|
1293
|
+
function shouldSkipImport(source) {
|
|
1294
|
+
for (const skip of SKIP_IMPORT_SOURCES) if (source.includes(skip)) return true;
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
function resolveImportSource(source, fileDir) {
|
|
1298
|
+
if (source.startsWith(".")) return path.resolve(fileDir, source);
|
|
1299
|
+
return source;
|
|
1300
|
+
}
|
|
1301
|
+
function addUsedNamedImports(namedImportStr, source, routeObject, namedImports) {
|
|
1302
|
+
const usedImports = namedImportStr.split(",").map((s) => s.trim().split(" as ")[0].trim()).filter((name) => {
|
|
1303
|
+
return (/* @__PURE__ */ new RegExp(`\\b${name}\\b`)).test(routeObject);
|
|
1304
|
+
});
|
|
1305
|
+
if (usedImports.length === 0) return;
|
|
1306
|
+
if (!namedImports.has(source)) namedImports.set(source, /* @__PURE__ */ new Set());
|
|
1307
|
+
for (const imp of usedImports) namedImports.get(source).add(imp);
|
|
1308
|
+
}
|
|
1309
|
+
function buildImportStatements(namedImports, defaultImports) {
|
|
1310
|
+
const statements = [];
|
|
1311
|
+
for (const [source, specifiers] of namedImports) statements.push(`import { ${Array.from(specifiers).join(", ")} } from '${source}'`);
|
|
1312
|
+
for (const [source, name] of defaultImports) statements.push(`import ${name} from '${source}'`);
|
|
1313
|
+
return statements.join("\n");
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
//#endregion
|
|
1317
|
+
//#region src/layer-collector.ts
|
|
1318
|
+
/**
|
|
1319
|
+
* @kimesh/router-generator - Layer Route Collector
|
|
1320
|
+
*
|
|
1321
|
+
* Collects routes from multiple layers with priority-based resolution.
|
|
1322
|
+
*/
|
|
1323
|
+
const logger$1 = consola.withTag("kimesh:router:layer-collector");
|
|
1324
|
+
/**
|
|
1325
|
+
* Collect routes from all configured layers and build proper route trees
|
|
1326
|
+
*/
|
|
1327
|
+
async function collectLayerRoutes(sources, config) {
|
|
1328
|
+
const startTime = performance.now();
|
|
1329
|
+
const allRoutes = [];
|
|
1330
|
+
const byLayer = /* @__PURE__ */ new Map();
|
|
1331
|
+
const perLayerTiming = /* @__PURE__ */ new Map();
|
|
1332
|
+
const scanPromises = [...sources].sort((a, b) => a.priority - b.priority).map(async (source) => {
|
|
1333
|
+
const layerStartTime = performance.now();
|
|
1334
|
+
return {
|
|
1335
|
+
source,
|
|
1336
|
+
routes: buildLayerRouteTree(await scanLayerRoutes(source, config), source),
|
|
1337
|
+
time: performance.now() - layerStartTime
|
|
1338
|
+
};
|
|
1339
|
+
});
|
|
1340
|
+
const results = await Promise.all(scanPromises);
|
|
1341
|
+
for (const { source, routes, time } of results) {
|
|
1342
|
+
allRoutes.push(...routes);
|
|
1343
|
+
byLayer.set(source.layer, routes);
|
|
1344
|
+
perLayerTiming.set(source.layer, time);
|
|
1345
|
+
logger$1.debug(`Layer '${source.layer}': ${routes.length} route tree(s) in ${time.toFixed(1)}ms`);
|
|
1346
|
+
}
|
|
1347
|
+
const totalTime = performance.now() - startTime;
|
|
1348
|
+
logger$1.info(`Collected ${allRoutes.length} routes from ${sources.length} layers in ${totalTime.toFixed(1)}ms`);
|
|
1349
|
+
return {
|
|
1350
|
+
routes: allRoutes,
|
|
1351
|
+
byLayer,
|
|
1352
|
+
timing: {
|
|
1353
|
+
totalMs: totalTime,
|
|
1354
|
+
perLayer: perLayerTiming
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Build a proper route tree for a layer's routes
|
|
1360
|
+
* Layouts become parents with children, pages become leaves
|
|
1361
|
+
*
|
|
1362
|
+
* Routes are organized by their file path which directly maps to URL path.
|
|
1363
|
+
* Example: routes/blog/_layout.vue → /blog layout
|
|
1364
|
+
* routes/blog/index.vue → /blog (index under /blog layout)
|
|
1365
|
+
* routes/blog/$postId.vue → /blog/:postId (child under /blog layout)
|
|
1366
|
+
*/
|
|
1367
|
+
function buildLayerRouteTree(flatRoutes, source) {
|
|
1368
|
+
if (flatRoutes.length === 0) return [];
|
|
1369
|
+
const layoutMap = /* @__PURE__ */ new Map();
|
|
1370
|
+
const pages = [];
|
|
1371
|
+
for (const route of flatRoutes) if (route.type === "layout") {
|
|
1372
|
+
const layoutPath = route.finalRoutePath;
|
|
1373
|
+
layoutMap.set(layoutPath, route);
|
|
1374
|
+
} else pages.push(route);
|
|
1375
|
+
if (layoutMap.size === 0) return pages.map((page) => ({
|
|
1376
|
+
...page,
|
|
1377
|
+
routePath: page.finalRoutePath
|
|
1378
|
+
}));
|
|
1379
|
+
let rootLayout = null;
|
|
1380
|
+
let rootLayoutPath = "";
|
|
1381
|
+
for (const [layoutPath, layout] of layoutMap) if (!rootLayout || layoutPath.length < rootLayoutPath.length) {
|
|
1382
|
+
rootLayout = layout;
|
|
1383
|
+
rootLayoutPath = layoutPath;
|
|
1384
|
+
}
|
|
1385
|
+
if (!rootLayout) return pages.map((page) => ({
|
|
1386
|
+
...page,
|
|
1387
|
+
routePath: page.finalRoutePath
|
|
1388
|
+
}));
|
|
1389
|
+
const children = [];
|
|
1390
|
+
const normalizedBase = rootLayoutPath;
|
|
1391
|
+
for (const page of pages) {
|
|
1392
|
+
const pagePath = page.finalRoutePath;
|
|
1393
|
+
if (pagePath === normalizedBase || pagePath === normalizedBase + "/") {
|
|
1394
|
+
const indexRoute = {
|
|
1395
|
+
...page,
|
|
1396
|
+
routePath: "",
|
|
1397
|
+
parent: rootLayout
|
|
1398
|
+
};
|
|
1399
|
+
children.unshift(indexRoute);
|
|
1400
|
+
} else if (pagePath.startsWith(normalizedBase + "/") || normalizedBase === "/" && pagePath.startsWith("/")) {
|
|
1401
|
+
const relativePath = normalizedBase === "/" ? pagePath.slice(1) : pagePath.slice(normalizedBase.length + 1);
|
|
1402
|
+
const childRoute = {
|
|
1403
|
+
...page,
|
|
1404
|
+
routePath: relativePath,
|
|
1405
|
+
parent: rootLayout
|
|
1406
|
+
};
|
|
1407
|
+
children.push(childRoute);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return [{
|
|
1411
|
+
...rootLayout,
|
|
1412
|
+
routePath: rootLayout.finalRoutePath,
|
|
1413
|
+
children
|
|
1414
|
+
}];
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Scan routes from a single layer
|
|
1418
|
+
*/
|
|
1419
|
+
async function scanLayerRoutes(source, config) {
|
|
1420
|
+
const { layer, priority, routesDir, basePath } = source;
|
|
1421
|
+
if (!fs.existsSync(routesDir)) {
|
|
1422
|
+
logger$1.debug(`Routes directory not found for layer '${layer}': ${routesDir}`);
|
|
1423
|
+
return [];
|
|
1424
|
+
}
|
|
1425
|
+
const files = await fg(buildGlobPattern(config.extensions), {
|
|
1426
|
+
cwd: routesDir,
|
|
1427
|
+
onlyFiles: true,
|
|
1428
|
+
ignore: ["**/node_modules/**"]
|
|
1429
|
+
});
|
|
1430
|
+
const directories = await fg("**/", {
|
|
1431
|
+
cwd: routesDir,
|
|
1432
|
+
onlyDirectories: true,
|
|
1433
|
+
ignore: ["**/node_modules/**"]
|
|
1434
|
+
});
|
|
1435
|
+
const dirSet = new Set(directories.map((d) => d.replace(/\/$/, "")));
|
|
1436
|
+
const routes = [];
|
|
1437
|
+
for (const file of files.sort()) {
|
|
1438
|
+
const node = await processLayerFile(file, routesDir, source, config, dirSet);
|
|
1439
|
+
if (node) routes.push(node);
|
|
1440
|
+
}
|
|
1441
|
+
return routes;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Process a single route file from a layer
|
|
1445
|
+
*
|
|
1446
|
+
* Supports layout route pattern: posts.vue + posts/ directory
|
|
1447
|
+
* When a file like posts.vue has a matching posts/ directory, it becomes a layout
|
|
1448
|
+
*/
|
|
1449
|
+
async function processLayerFile(filePath, routesDir, source, config, dirSet) {
|
|
1450
|
+
const fullPath = path.join(routesDir, filePath);
|
|
1451
|
+
const fileName = path.basename(filePath, path.extname(filePath));
|
|
1452
|
+
const dirPath = path.dirname(filePath);
|
|
1453
|
+
if (fileName.startsWith("-")) return null;
|
|
1454
|
+
const potentialDirPath = isRootDir(dirPath) ? fileName : `${dirPath}/${fileName}`;
|
|
1455
|
+
const hasMatchingDirectory = dirSet.has(potentialDirPath);
|
|
1456
|
+
let { type, routePath, params, rawSegment, isPathlessLayout } = parseFileName(fileName, dirPath);
|
|
1457
|
+
if (hasMatchingDirectory && type === "page") {
|
|
1458
|
+
type = "layout";
|
|
1459
|
+
routePath = buildRoutePath(dirPath, fileName);
|
|
1460
|
+
logger$1.debug(`Layout route detected: ${fileName} -> ${routePath}`);
|
|
1461
|
+
}
|
|
1462
|
+
let content;
|
|
1463
|
+
try {
|
|
1464
|
+
content = fs.readFileSync(fullPath, "utf-8");
|
|
1465
|
+
} catch {
|
|
1466
|
+
logger$1.warn(`Could not read file: ${fullPath}`);
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
if (needsScaffolding(content)) {
|
|
1470
|
+
const isLayout = type === "layout";
|
|
1471
|
+
const isDynamic = type === "dynamic";
|
|
1472
|
+
const paramName = params[0]?.name;
|
|
1473
|
+
content = generateRouteScaffold({
|
|
1474
|
+
routePath,
|
|
1475
|
+
isLayout,
|
|
1476
|
+
isDynamic,
|
|
1477
|
+
paramName
|
|
1478
|
+
});
|
|
1479
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
1480
|
+
logger$1.debug(`Scaffolded route: ${source.layer}:${filePath}`);
|
|
1481
|
+
}
|
|
1482
|
+
const parsed = parseRouteFile(content, fullPath);
|
|
1483
|
+
const variableName = generateVariableName(filePath, source.layer);
|
|
1484
|
+
const finalRoutePath = applyBasePath(routePath, source.basePath);
|
|
1485
|
+
const routeName = generateRouteName(finalRoutePath, source.layer);
|
|
1486
|
+
const isLazy = fileName.endsWith(".lazy") || shouldBeLazy(filePath, config);
|
|
1487
|
+
return {
|
|
1488
|
+
filePath,
|
|
1489
|
+
fullPath,
|
|
1490
|
+
routePath,
|
|
1491
|
+
variableName,
|
|
1492
|
+
type,
|
|
1493
|
+
routeName,
|
|
1494
|
+
isLazy,
|
|
1495
|
+
params,
|
|
1496
|
+
parsed,
|
|
1497
|
+
children: [],
|
|
1498
|
+
rawSegment,
|
|
1499
|
+
layer: source.layer,
|
|
1500
|
+
layerPriority: source.priority,
|
|
1501
|
+
layerBasePath: source.basePath,
|
|
1502
|
+
finalRoutePath
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Parse filename to determine route type and path
|
|
1507
|
+
*
|
|
1508
|
+
* File-based routing conventions:
|
|
1509
|
+
* - __root.vue → root layout (wraps all routes)
|
|
1510
|
+
* - index.vue → index route
|
|
1511
|
+
* - route.vue → alternative index route
|
|
1512
|
+
* - about.vue → regular page
|
|
1513
|
+
* - $id.vue → dynamic param
|
|
1514
|
+
* - $.vue → catch-all (splat route)
|
|
1515
|
+
* - (group)/ → pathless group folder (route group)
|
|
1516
|
+
* - _pathless.vue + _pathless/ → pathless layout (underscore prefix)
|
|
1517
|
+
* - posts.vue + posts/ → layout route
|
|
1518
|
+
* - posts.index.vue → flat route notation (dot separator)
|
|
1519
|
+
* - posts_/ → layout escape (underscore suffix)
|
|
1520
|
+
* - -utils.vue → excluded from routing (dash prefix)
|
|
1521
|
+
* - [x] → escape special characters (e.g., script[.]js.vue → /script.js)
|
|
1522
|
+
*/
|
|
1523
|
+
function parseFileName(fileName, dirPath) {
|
|
1524
|
+
let rawSegment = fileName;
|
|
1525
|
+
if (fileName.endsWith(".lazy")) {
|
|
1526
|
+
fileName = fileName.slice(0, -5);
|
|
1527
|
+
rawSegment = fileName;
|
|
1528
|
+
}
|
|
1529
|
+
if (fileName.startsWith("-")) return {
|
|
1530
|
+
type: "page",
|
|
1531
|
+
routePath: "",
|
|
1532
|
+
params: [],
|
|
1533
|
+
rawSegment
|
|
1534
|
+
};
|
|
1535
|
+
if (fileName === "__root") return {
|
|
1536
|
+
type: "layout",
|
|
1537
|
+
routePath: "/",
|
|
1538
|
+
params: [],
|
|
1539
|
+
rawSegment
|
|
1540
|
+
};
|
|
1541
|
+
if (fileName.startsWith("_") && !fileName.startsWith("__")) return {
|
|
1542
|
+
type: "layout",
|
|
1543
|
+
routePath: buildRoutePath(dirPath, ""),
|
|
1544
|
+
params: [],
|
|
1545
|
+
rawSegment,
|
|
1546
|
+
isPathlessLayout: true
|
|
1547
|
+
};
|
|
1548
|
+
if (fileName === "index" || fileName === "route") return {
|
|
1549
|
+
type: "index",
|
|
1550
|
+
routePath: buildRoutePath(dirPath, ""),
|
|
1551
|
+
params: [],
|
|
1552
|
+
rawSegment
|
|
1553
|
+
};
|
|
1554
|
+
if (isDotNotationRoute(fileName)) return parseDotNotationRoute(fileName, dirPath, rawSegment);
|
|
1555
|
+
if (fileName === "$") return {
|
|
1556
|
+
type: "catch-all",
|
|
1557
|
+
routePath: buildRoutePath(dirPath, ":pathMatch(.*)*"),
|
|
1558
|
+
params: [createCatchAllParam()],
|
|
1559
|
+
rawSegment
|
|
1560
|
+
};
|
|
1561
|
+
const dynamicParam = extractDynamicParam(fileName);
|
|
1562
|
+
if (dynamicParam) return {
|
|
1563
|
+
type: "dynamic",
|
|
1564
|
+
routePath: buildRoutePath(dirPath, `:${dynamicParam}`),
|
|
1565
|
+
params: [createDynamicParam(dynamicParam)],
|
|
1566
|
+
rawSegment
|
|
1567
|
+
};
|
|
1568
|
+
return {
|
|
1569
|
+
type: "page",
|
|
1570
|
+
routePath: buildRoutePath(dirPath, unescapeBrackets(fileName)),
|
|
1571
|
+
params: [],
|
|
1572
|
+
rawSegment
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
function parseDotNotationRoute(fileName, dirPath, rawSegment) {
|
|
1576
|
+
const segments = fileName.split(".");
|
|
1577
|
+
const lastSegment = segments[segments.length - 1];
|
|
1578
|
+
const parentSegments = segments.slice(0, -1);
|
|
1579
|
+
const virtualDir = isRootDir(dirPath) ? parentSegments.join("/") : `${dirPath}/${parentSegments.join("/")}`;
|
|
1580
|
+
if (lastSegment === "index") return {
|
|
1581
|
+
type: "index",
|
|
1582
|
+
routePath: buildRoutePath(virtualDir, ""),
|
|
1583
|
+
params: [],
|
|
1584
|
+
rawSegment
|
|
1585
|
+
};
|
|
1586
|
+
const dynamicParam = extractDynamicParam(lastSegment);
|
|
1587
|
+
if (dynamicParam) return {
|
|
1588
|
+
type: "dynamic",
|
|
1589
|
+
routePath: buildRoutePath(virtualDir, `:${dynamicParam}`),
|
|
1590
|
+
params: [createDynamicParam(dynamicParam)],
|
|
1591
|
+
rawSegment
|
|
1592
|
+
};
|
|
1593
|
+
return {
|
|
1594
|
+
type: "page",
|
|
1595
|
+
routePath: buildRoutePath(virtualDir, unescapeBrackets(lastSegment)),
|
|
1596
|
+
params: [],
|
|
1597
|
+
rawSegment
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Apply basePath prefix to route path
|
|
1602
|
+
*/
|
|
1603
|
+
function applyBasePath(routePath, basePath) {
|
|
1604
|
+
if (!basePath) return routePath;
|
|
1605
|
+
const normalizedBase = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
1606
|
+
const base = normalizedBase.endsWith("/") ? normalizedBase.slice(0, -1) : normalizedBase;
|
|
1607
|
+
if (routePath === "/") return base || "/";
|
|
1608
|
+
return `${base}${routePath}`;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Generate a unique variable name for a route
|
|
1612
|
+
*/
|
|
1613
|
+
function generateVariableName(filePath, layer) {
|
|
1614
|
+
const withoutExt = filePath.replace(/\.[^/.]+$/, "");
|
|
1615
|
+
return (layer === "app" ? "" : `${sanitizeForVariable(layer)}_`) + withoutExt.replace(/[\/\\]/g, "_").replace(/[\[\]$().+-]/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_").replace(/^(\d)/, "_$1");
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Sanitize string for use in variable name
|
|
1619
|
+
*/
|
|
1620
|
+
function sanitizeForVariable(str) {
|
|
1621
|
+
return str.replace(/^@/, "").replace(/[\/\\@-]/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Generate route name from route path
|
|
1625
|
+
*/
|
|
1626
|
+
function generateRouteName(routePath, layer) {
|
|
1627
|
+
const layerPrefix = layer === "app" ? "" : `${layer.replace(/[@\/]/g, "-")}-`;
|
|
1628
|
+
if (routePath === "/") return `${layerPrefix}index`;
|
|
1629
|
+
return `${layerPrefix}${routePath.slice(1).replace(/\//g, "-").replace(/:/g, "").replace(/\(.*?\)\*/g, "catchAll").replace(/\?/g, "")}`;
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Check if route should be lazy loaded
|
|
1633
|
+
*/
|
|
1634
|
+
function shouldBeLazy(filePath, config) {
|
|
1635
|
+
const { importMode } = config;
|
|
1636
|
+
if (typeof importMode === "function") return importMode(filePath) === "async";
|
|
1637
|
+
return importMode === "async";
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/plugin.ts
|
|
1642
|
+
const VIRTUAL_ROUTES_ID = "\0virtual:kimesh-routes";
|
|
1643
|
+
const VIRTUAL_ROUTE_PREFIX = "\0kimesh-route:";
|
|
1644
|
+
const DEFAULT_CONFIG = {
|
|
1645
|
+
srcDir: "src",
|
|
1646
|
+
routesDir: "routes",
|
|
1647
|
+
generatedDir: ".kimesh",
|
|
1648
|
+
extensions: [".vue"],
|
|
1649
|
+
importMode: "async"
|
|
1650
|
+
};
|
|
1651
|
+
/**
|
|
1652
|
+
* Kimesh Router Generator Vite Plugin
|
|
1653
|
+
*/
|
|
1654
|
+
function kimeshRouterGenerator(options = {}) {
|
|
1655
|
+
const debug = options.debug ?? false;
|
|
1656
|
+
let config;
|
|
1657
|
+
let server = null;
|
|
1658
|
+
let scanner;
|
|
1659
|
+
let routes = [];
|
|
1660
|
+
const log = (...args) => {
|
|
1661
|
+
if (debug) console.log("[kimesh:router]", ...args);
|
|
1662
|
+
};
|
|
1663
|
+
/**
|
|
1664
|
+
* Resolve layer routes - supports both static config and lazy getter
|
|
1665
|
+
*/
|
|
1666
|
+
const resolveLayerRoutes = () => {
|
|
1667
|
+
if (options.getLayerRoutes) return options.getLayerRoutes();
|
|
1668
|
+
return options.layerRoutes ?? [];
|
|
1669
|
+
};
|
|
1670
|
+
return {
|
|
1671
|
+
name: "kimesh:router-generator",
|
|
1672
|
+
enforce: "pre",
|
|
1673
|
+
configResolved(viteConfig) {
|
|
1674
|
+
const root = viteConfig.root;
|
|
1675
|
+
const mergedConfig = {
|
|
1676
|
+
...DEFAULT_CONFIG,
|
|
1677
|
+
...options
|
|
1678
|
+
};
|
|
1679
|
+
config = {
|
|
1680
|
+
...mergedConfig,
|
|
1681
|
+
root,
|
|
1682
|
+
routesDirPath: path.resolve(root, mergedConfig.srcDir, mergedConfig.routesDir),
|
|
1683
|
+
generatedDirPath: path.resolve(root, mergedConfig.generatedDir)
|
|
1684
|
+
};
|
|
1685
|
+
scanner = new RouteScanner(config);
|
|
1686
|
+
log("Config resolved:", {
|
|
1687
|
+
routesDirPath: config.routesDirPath,
|
|
1688
|
+
generatedDirPath: config.generatedDirPath,
|
|
1689
|
+
layerCount: resolveLayerRoutes().length
|
|
1690
|
+
});
|
|
1691
|
+
},
|
|
1692
|
+
async buildStart() {
|
|
1693
|
+
await generateRoutesOnce();
|
|
1694
|
+
},
|
|
1695
|
+
configureServer(_server) {
|
|
1696
|
+
server = _server;
|
|
1697
|
+
const watchPaths = [config.routesDirPath];
|
|
1698
|
+
const layerRoutes = resolveLayerRoutes();
|
|
1699
|
+
for (const layer of layerRoutes) if (fs.existsSync(layer.routesDir)) watchPaths.push(layer.routesDir);
|
|
1700
|
+
const watcher = watch(watchPaths, {
|
|
1701
|
+
ignoreInitial: true,
|
|
1702
|
+
ignored: ["**/.kimesh/**", "**/node_modules/**"]
|
|
1703
|
+
});
|
|
1704
|
+
watcher.on("add", handleFileChange);
|
|
1705
|
+
watcher.on("unlink", handleFileChange);
|
|
1706
|
+
log("Watching routes at:", watchPaths);
|
|
1707
|
+
},
|
|
1708
|
+
resolveId(id, importer) {
|
|
1709
|
+
if (id === "virtual:kimesh-routes" || id === "~kimesh/routes") return VIRTUAL_ROUTES_ID;
|
|
1710
|
+
if (isRouteQuery(id)) {
|
|
1711
|
+
const basePath = getBasePath(id);
|
|
1712
|
+
return VIRTUAL_ROUTE_PREFIX + (importer ? path.resolve(path.dirname(importer.replace(/\0/, "")), basePath) : basePath);
|
|
1713
|
+
}
|
|
1714
|
+
return null;
|
|
1715
|
+
},
|
|
1716
|
+
load(id) {
|
|
1717
|
+
if (id === VIRTUAL_ROUTES_ID) return generateRoutes(routes, config);
|
|
1718
|
+
if (id.startsWith(VIRTUAL_ROUTE_PREFIX)) {
|
|
1719
|
+
const filePath = id.slice(14);
|
|
1720
|
+
log("Extracting route definition from:", filePath);
|
|
1721
|
+
return extractRouteDefinition(filePath);
|
|
1722
|
+
}
|
|
1723
|
+
return null;
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
async function generateRoutesOnce() {
|
|
1727
|
+
try {
|
|
1728
|
+
if (!fs.existsSync(config.routesDirPath)) {
|
|
1729
|
+
log("Routes directory does not exist:", config.routesDirPath);
|
|
1730
|
+
routes = [];
|
|
1731
|
+
}
|
|
1732
|
+
log("Scanning routes in:", config.routesDirPath);
|
|
1733
|
+
const scannedNodes = fs.existsSync(config.routesDirPath) ? await scanner.scan() : [];
|
|
1734
|
+
log("Scanned app nodes:", scannedNodes.length, scannedNodes.map((n) => n.filePath));
|
|
1735
|
+
let appRoutes = buildRouteTree(scannedNodes, config);
|
|
1736
|
+
log("Built app routes:", appRoutes.length, appRoutes.map((r) => r.routePath));
|
|
1737
|
+
const layerRoutes = resolveLayerRoutes();
|
|
1738
|
+
if (layerRoutes.length > 0) {
|
|
1739
|
+
const layerResult = await collectLayerRoutes(layerRoutes.map((l) => ({
|
|
1740
|
+
layer: l.layerName,
|
|
1741
|
+
routesDir: l.routesDir,
|
|
1742
|
+
basePath: l.basePath,
|
|
1743
|
+
priority: l.priority
|
|
1744
|
+
})), config);
|
|
1745
|
+
log("Collected layer routes:", layerResult.routes.length);
|
|
1746
|
+
routes = mergeRoutes(appRoutes, layerResult.routes, config);
|
|
1747
|
+
} else routes = appRoutes;
|
|
1748
|
+
log("Final merged routes:", routes.length, routes.map((r) => r.routePath));
|
|
1749
|
+
await writeGeneratedFiles();
|
|
1750
|
+
console.log(`[kimesh:router] Generated ${routes.length} route(s)`);
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
console.error("[kimesh:router] Error generating routes:", error);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
async function writeGeneratedFiles() {
|
|
1756
|
+
if (!fs.existsSync(config.generatedDirPath)) fs.mkdirSync(config.generatedDirPath, { recursive: true });
|
|
1757
|
+
const routesCode = generateRoutes(routes, config);
|
|
1758
|
+
const routesPath = path.join(config.generatedDirPath, "routes.gen.ts");
|
|
1759
|
+
fs.writeFileSync(routesPath, routesCode, "utf-8");
|
|
1760
|
+
const typesCode = generateRouteTypes(routes);
|
|
1761
|
+
const typesPath = path.join(config.generatedDirPath, "typed-routes.d.ts");
|
|
1762
|
+
fs.writeFileSync(typesPath, typesCode, "utf-8");
|
|
1763
|
+
const layerRoutes = resolveLayerRoutes();
|
|
1764
|
+
for (const layer of layerRoutes) {
|
|
1765
|
+
let layerRoot = layer.layerPath || path.dirname(layer.routesDir);
|
|
1766
|
+
try {
|
|
1767
|
+
layerRoot = fs.realpathSync(layerRoot);
|
|
1768
|
+
} catch {}
|
|
1769
|
+
const layerKimeshDir = path.join(layerRoot, ".kimesh");
|
|
1770
|
+
if (!fs.existsSync(layerKimeshDir)) fs.mkdirSync(layerKimeshDir, { recursive: true });
|
|
1771
|
+
const layerTypesPath = path.join(layerKimeshDir, "typed-routes.d.ts");
|
|
1772
|
+
fs.writeFileSync(layerTypesPath, typesCode, "utf-8");
|
|
1773
|
+
log("Generated layer types:", {
|
|
1774
|
+
layer: layer.layerName,
|
|
1775
|
+
path: layerTypesPath
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
log("Generated files:", {
|
|
1779
|
+
routesPath,
|
|
1780
|
+
typesPath
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
async function handleFileChange(filePath) {
|
|
1784
|
+
log("File changed:", filePath);
|
|
1785
|
+
await generateRoutesOnce();
|
|
1786
|
+
if (server) {
|
|
1787
|
+
const routesModule = server.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID);
|
|
1788
|
+
if (routesModule) server.moduleGraph.invalidateModule(routesModule);
|
|
1789
|
+
server.ws.send({
|
|
1790
|
+
type: "full-reload",
|
|
1791
|
+
path: "*"
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Merge app routes with layer routes
|
|
1798
|
+
* App routes (priority 0) take precedence over layer routes
|
|
1799
|
+
* Layer routes are nested under the host's root layout if one exists
|
|
1800
|
+
* If app has a layout route for a path, layer routes become its children
|
|
1801
|
+
*/
|
|
1802
|
+
function mergeRoutes(appRoutes, layerRoutes, _config) {
|
|
1803
|
+
const rootLayout = findRootLayout(appRoutes);
|
|
1804
|
+
if (rootLayout && rootLayout.children) return mergeWithRootLayout(appRoutes, layerRoutes, rootLayout);
|
|
1805
|
+
return mergeWithoutRootLayout(appRoutes, layerRoutes);
|
|
1806
|
+
}
|
|
1807
|
+
function findRootLayout(routes) {
|
|
1808
|
+
return routes.find((r) => r.routePath === "/" && r.type === "layout") || null;
|
|
1809
|
+
}
|
|
1810
|
+
function mergeWithRootLayout(appRoutes, layerRoutes, rootLayout) {
|
|
1811
|
+
const layoutRoutesByPath = buildLayoutRoutesMap(rootLayout.children);
|
|
1812
|
+
for (const layerRoute of layerRoutes) {
|
|
1813
|
+
const relativePath = normalizeRoutePath(layerRoute.routePath);
|
|
1814
|
+
const hostLayoutRoute = layoutRoutesByPath.get(relativePath);
|
|
1815
|
+
if (hostLayoutRoute) nestLayerUnderHost(layerRoute, hostLayoutRoute);
|
|
1816
|
+
else addLayerAsSibling(layerRoute, relativePath, rootLayout, layoutRoutesByPath);
|
|
1817
|
+
}
|
|
1818
|
+
sortRouteChildren(rootLayout.children);
|
|
1819
|
+
return appRoutes;
|
|
1820
|
+
}
|
|
1821
|
+
function buildLayoutRoutesMap(children) {
|
|
1822
|
+
const map = /* @__PURE__ */ new Map();
|
|
1823
|
+
for (const child of children) if (child.routePath && !child.routePath.includes(":")) {
|
|
1824
|
+
const normalizedPath = normalizeRoutePath(child.routePath);
|
|
1825
|
+
map.set(normalizedPath, child);
|
|
1826
|
+
}
|
|
1827
|
+
return map;
|
|
1828
|
+
}
|
|
1829
|
+
function normalizeRoutePath(routePath) {
|
|
1830
|
+
return routePath.startsWith("/") ? routePath.slice(1) : routePath;
|
|
1831
|
+
}
|
|
1832
|
+
function nestLayerUnderHost(layerRoute, hostLayoutRoute) {
|
|
1833
|
+
hostLayoutRoute.children = hostLayoutRoute.children || [];
|
|
1834
|
+
hostLayoutRoute.type = "layout";
|
|
1835
|
+
const layerWrapper = {
|
|
1836
|
+
...layerRoute,
|
|
1837
|
+
routePath: "",
|
|
1838
|
+
parent: hostLayoutRoute
|
|
1839
|
+
};
|
|
1840
|
+
hostLayoutRoute.children.push(layerWrapper);
|
|
1841
|
+
}
|
|
1842
|
+
function addLayerAsSibling(layerRoute, relativePath, rootLayout, layoutRoutesByPath) {
|
|
1843
|
+
const nestedRoute = {
|
|
1844
|
+
...layerRoute,
|
|
1845
|
+
routePath: relativePath,
|
|
1846
|
+
parent: rootLayout
|
|
1847
|
+
};
|
|
1848
|
+
rootLayout.children.push(nestedRoute);
|
|
1849
|
+
layoutRoutesByPath.set(relativePath, nestedRoute);
|
|
1850
|
+
}
|
|
1851
|
+
function sortRouteChildren(children) {
|
|
1852
|
+
children.sort((a, b) => {
|
|
1853
|
+
if (a.routePath === "") return -1;
|
|
1854
|
+
if (b.routePath === "") return 1;
|
|
1855
|
+
return a.routePath.localeCompare(b.routePath);
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
function mergeWithoutRootLayout(appRoutes, layerRoutes) {
|
|
1859
|
+
const merged = [...appRoutes];
|
|
1860
|
+
const usedPaths = new Set(appRoutes.map((r) => r.routePath));
|
|
1861
|
+
for (const layerRoute of layerRoutes) if (!usedPaths.has(layerRoute.routePath)) {
|
|
1862
|
+
merged.push(layerRoute);
|
|
1863
|
+
usedPaths.add(layerRoute.routePath);
|
|
1864
|
+
}
|
|
1865
|
+
merged.sort((a, b) => a.routePath.localeCompare(b.routePath));
|
|
1866
|
+
return merged;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
//#endregion
|
|
1870
|
+
//#region src/layout-resolver.ts
|
|
1871
|
+
/**
|
|
1872
|
+
* @kimesh/router-generator - Layout Resolver
|
|
1873
|
+
*
|
|
1874
|
+
* Resolves layout hierarchy using nested wrapping strategy.
|
|
1875
|
+
* Host layouts wrap layer layouts (outer → inner).
|
|
1876
|
+
*/
|
|
1877
|
+
const logger = consola.withTag("kimesh:router:layout");
|
|
1878
|
+
/**
|
|
1879
|
+
* Layout resolver for nested wrapping
|
|
1880
|
+
*
|
|
1881
|
+
* Key principle: Host layouts WRAP layer layouts (outer → inner)
|
|
1882
|
+
* - Host root layout is always outermost
|
|
1883
|
+
* - Host path-specific layouts come next
|
|
1884
|
+
* - Layer layouts are innermost (closest to page content)
|
|
1885
|
+
*/
|
|
1886
|
+
var LayoutResolver = class {
|
|
1887
|
+
hostLayouts = /* @__PURE__ */ new Map();
|
|
1888
|
+
layerLayouts = /* @__PURE__ */ new Map();
|
|
1889
|
+
layerMounts = /* @__PURE__ */ new Map();
|
|
1890
|
+
/**
|
|
1891
|
+
* Build layout chains for all routes
|
|
1892
|
+
*/
|
|
1893
|
+
resolveLayoutChains(routes, layerMounts) {
|
|
1894
|
+
this.layerMounts = layerMounts;
|
|
1895
|
+
this.categorizeLayouts(routes);
|
|
1896
|
+
const chains = /* @__PURE__ */ new Map();
|
|
1897
|
+
const pageRoutes = routes.filter((r) => r.type !== "layout");
|
|
1898
|
+
for (const page of pageRoutes) {
|
|
1899
|
+
const chain = this.buildLayoutChain(page);
|
|
1900
|
+
chains.set(page.finalRoutePath, chain);
|
|
1901
|
+
}
|
|
1902
|
+
logger.debug(`Resolved layout chains for ${chains.size} pages`);
|
|
1903
|
+
return chains;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Categorize layouts into host and layer layouts
|
|
1907
|
+
*/
|
|
1908
|
+
categorizeLayouts(routes) {
|
|
1909
|
+
this.hostLayouts.clear();
|
|
1910
|
+
this.layerLayouts.clear();
|
|
1911
|
+
const layoutRoutes = routes.filter((r) => r.type === "layout");
|
|
1912
|
+
for (const route of layoutRoutes) {
|
|
1913
|
+
const layoutName = this.extractLayoutName(route.filePath);
|
|
1914
|
+
const layout = {
|
|
1915
|
+
filePath: route.fullPath,
|
|
1916
|
+
name: layoutName,
|
|
1917
|
+
routePath: route.routePath,
|
|
1918
|
+
layer: route.layer,
|
|
1919
|
+
priority: route.layerPriority,
|
|
1920
|
+
source: route.layer === "app" ? "host" : "layer",
|
|
1921
|
+
mountPath: route.layerBasePath
|
|
1922
|
+
};
|
|
1923
|
+
if (route.layer === "app") {
|
|
1924
|
+
const key = this.makeLayoutKey(route.routePath, layoutName);
|
|
1925
|
+
this.hostLayouts.set(key, layout);
|
|
1926
|
+
logger.debug(`Host layout: ${key} -> ${route.fullPath}`);
|
|
1927
|
+
} else {
|
|
1928
|
+
if (!this.layerLayouts.has(route.layer)) this.layerLayouts.set(route.layer, /* @__PURE__ */ new Map());
|
|
1929
|
+
const key = this.makeLayoutKey(route.routePath, layoutName);
|
|
1930
|
+
this.layerLayouts.get(route.layer).set(key, layout);
|
|
1931
|
+
logger.debug(`Layer layout: ${route.layer}:${key} -> ${route.fullPath}`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Build the complete layout chain for a page
|
|
1937
|
+
*
|
|
1938
|
+
* Order: Host root → Host path-specific → Layer root → Layer path-specific → Page
|
|
1939
|
+
*/
|
|
1940
|
+
buildLayoutChain(page) {
|
|
1941
|
+
const layouts = [];
|
|
1942
|
+
const pageMeta = page.parsed.pageMeta;
|
|
1943
|
+
if (pageMeta?.layout === false) return {
|
|
1944
|
+
layouts: [],
|
|
1945
|
+
page
|
|
1946
|
+
};
|
|
1947
|
+
const requestedLayout = pageMeta?.layout;
|
|
1948
|
+
const hostChain = this.collectHostLayouts(page.finalRoutePath, requestedLayout);
|
|
1949
|
+
layouts.push(...hostChain);
|
|
1950
|
+
if (page.layer !== "app") {
|
|
1951
|
+
const layerChain = this.collectLayerLayouts(page.layer, page.routePath, page.layerBasePath, requestedLayout);
|
|
1952
|
+
layouts.push(...layerChain);
|
|
1953
|
+
}
|
|
1954
|
+
return {
|
|
1955
|
+
layouts,
|
|
1956
|
+
page
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Collect host layouts from root to most specific path
|
|
1961
|
+
*/
|
|
1962
|
+
collectHostLayouts(routePath, requestedLayout) {
|
|
1963
|
+
const layouts = [];
|
|
1964
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
1965
|
+
const rootLayout = this.hostLayouts.get(this.makeLayoutKey("/", requestedLayout)) || this.hostLayouts.get("/");
|
|
1966
|
+
if (rootLayout) layouts.push(rootLayout);
|
|
1967
|
+
let currentPath = "";
|
|
1968
|
+
for (const segment of segments) {
|
|
1969
|
+
if (segment.startsWith(":")) continue;
|
|
1970
|
+
currentPath += "/" + segment;
|
|
1971
|
+
const layout = this.hostLayouts.get(this.makeLayoutKey(currentPath, requestedLayout)) || this.hostLayouts.get(currentPath);
|
|
1972
|
+
if (layout && !layouts.includes(layout)) layouts.push(layout);
|
|
1973
|
+
}
|
|
1974
|
+
return layouts;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Collect layer layouts from layer root to most specific path
|
|
1978
|
+
*/
|
|
1979
|
+
collectLayerLayouts(layerName, routePath, mountPath, requestedLayout) {
|
|
1980
|
+
const layouts = [];
|
|
1981
|
+
const layerLayoutMap = this.layerLayouts.get(layerName);
|
|
1982
|
+
if (!layerLayoutMap) return layouts;
|
|
1983
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
1984
|
+
const rootLayout = layerLayoutMap.get(this.makeLayoutKey("/", requestedLayout)) || layerLayoutMap.get("/");
|
|
1985
|
+
if (rootLayout) layouts.push(rootLayout);
|
|
1986
|
+
let currentPath = "";
|
|
1987
|
+
for (const segment of segments) {
|
|
1988
|
+
if (segment.startsWith(":")) continue;
|
|
1989
|
+
currentPath += "/" + segment;
|
|
1990
|
+
const layout = layerLayoutMap.get(this.makeLayoutKey(currentPath, requestedLayout)) || layerLayoutMap.get(currentPath);
|
|
1991
|
+
if (layout && !layouts.includes(layout)) layouts.push(layout);
|
|
1992
|
+
}
|
|
1993
|
+
return layouts;
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Extract layout name from file path
|
|
1997
|
+
* _layout.admin.vue → 'admin'
|
|
1998
|
+
* _layout.vue → undefined
|
|
1999
|
+
*/
|
|
2000
|
+
extractLayoutName(filePath) {
|
|
2001
|
+
const basename = path.basename(filePath, path.extname(filePath));
|
|
2002
|
+
if (basename.startsWith("_layout.") && basename !== "_layout") return basename.replace("_layout.", "");
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Make a unique key for layout lookup
|
|
2006
|
+
*/
|
|
2007
|
+
makeLayoutKey(routePath, layoutName) {
|
|
2008
|
+
if (layoutName) return `${routePath}:${layoutName}`;
|
|
2009
|
+
return routePath;
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
/**
|
|
2013
|
+
* Generate nested layout component structure for Vue Router
|
|
2014
|
+
*
|
|
2015
|
+
* This generates the component tree where layouts wrap their children
|
|
2016
|
+
*/
|
|
2017
|
+
function generateLayoutStructure(chain) {
|
|
2018
|
+
if (chain.layouts.length === 0) return { component: chain.page.fullPath };
|
|
2019
|
+
let current = { component: chain.page.fullPath };
|
|
2020
|
+
for (let i = chain.layouts.length - 1; i >= 0; i--) current = {
|
|
2021
|
+
component: chain.layouts[i].filePath,
|
|
2022
|
+
children: [current]
|
|
2023
|
+
};
|
|
2024
|
+
return current;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
//#endregion
|
|
2028
|
+
export { LayoutResolver, RouteScanner, RouteTreeBuilder, buildGlobPattern, buildRoutePath, buildRouteTree, collectLayerRoutes, createCatchAllParam, createDynamicParam, createEmptyParsedRoute, createScanner, createTreeBuilder, extractDynamicParam, generateLayoutStructure, generateRouteTypes, generateRoutes, isDotNotationRoute, isRootDir, kimeshRouterGenerator, parseRouteFile, processDirectoryPath, processPathSegment, unescapeBrackets };
|
|
2029
|
+
//# sourceMappingURL=index.mjs.map
|