@screenbook/cli 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +1882 -240
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { cli, define } from "gunshi";
|
|
4
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
4
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { cli, define } from "gunshi";
|
|
6
7
|
import { createJiti } from "jiti";
|
|
7
8
|
import { glob } from "tinyglobby";
|
|
8
9
|
import { defineConfig } from "@screenbook/core";
|
|
9
10
|
import pc from "picocolors";
|
|
10
11
|
import { execSync, spawn } from "node:child_process";
|
|
11
12
|
import prompts from "prompts";
|
|
13
|
+
import { parse } from "@babel/parser";
|
|
12
14
|
import { minimatch } from "minimatch";
|
|
13
15
|
|
|
14
16
|
//#region src/utils/config.ts
|
|
@@ -166,14 +168,34 @@ function getCycleSummary(result) {
|
|
|
166
168
|
*/
|
|
167
169
|
const ERRORS = {
|
|
168
170
|
ROUTES_PATTERN_MISSING: {
|
|
169
|
-
title: "
|
|
170
|
-
suggestion: "Add routesPattern
|
|
171
|
+
title: "Routes configuration not found",
|
|
172
|
+
suggestion: "Add routesPattern (for file-based routing) or routesFile (for config-based routing) to your screenbook.config.ts.",
|
|
171
173
|
example: `import { defineConfig } from "@screenbook/core"
|
|
172
174
|
|
|
175
|
+
// Option 1: File-based routing (Next.js, Nuxt, etc.)
|
|
176
|
+
export default defineConfig({
|
|
177
|
+
routesPattern: "src/pages/**/page.tsx",
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Option 2: Config-based routing (Vue Router, React Router, etc.)
|
|
173
181
|
export default defineConfig({
|
|
174
|
-
|
|
182
|
+
routesFile: "src/router/routes.ts",
|
|
175
183
|
})`
|
|
176
184
|
},
|
|
185
|
+
ROUTES_FILE_NOT_FOUND: (filePath) => ({
|
|
186
|
+
title: `Routes file not found: ${filePath}`,
|
|
187
|
+
suggestion: "Check the routesFile path in your screenbook.config.ts. The file should export a routes array.",
|
|
188
|
+
example: `import { defineConfig } from "@screenbook/core"
|
|
189
|
+
|
|
190
|
+
export default defineConfig({
|
|
191
|
+
routesFile: "src/router/routes.ts", // Make sure this file exists
|
|
192
|
+
})`
|
|
193
|
+
}),
|
|
194
|
+
ROUTES_FILE_PARSE_ERROR: (filePath, error) => ({
|
|
195
|
+
title: `Failed to parse routes file: ${filePath}`,
|
|
196
|
+
message: error,
|
|
197
|
+
suggestion: "Ensure the file exports a valid routes array. Check for syntax errors or unsupported patterns."
|
|
198
|
+
}),
|
|
177
199
|
CONFIG_NOT_FOUND: {
|
|
178
200
|
title: "Configuration file not found",
|
|
179
201
|
suggestion: "Run 'screenbook init' to create a screenbook.config.ts file, or create one manually.",
|
|
@@ -883,8 +905,8 @@ async function checkVersionCompatibility(cwd) {
|
|
|
883
905
|
status: "warn",
|
|
884
906
|
message: "Cannot check - packages not installed"
|
|
885
907
|
};
|
|
886
|
-
const extractMajor = (version) => {
|
|
887
|
-
return version.replace(/^[\^~>=<]+/, "").split(".")[0] ?? "0";
|
|
908
|
+
const extractMajor = (version$1) => {
|
|
909
|
+
return version$1.replace(/^[\^~>=<]+/, "").split(".")[0] ?? "0";
|
|
888
910
|
};
|
|
889
911
|
if (extractMajor(coreVersion) !== extractMajor(cliVersion)) return {
|
|
890
912
|
name: "Version compatibility",
|
|
@@ -944,267 +966,1659 @@ function displayResults(results, verbose) {
|
|
|
944
966
|
}
|
|
945
967
|
|
|
946
968
|
//#endregion
|
|
947
|
-
//#region src/
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
run: async (ctx) => {
|
|
977
|
-
const config = await loadConfig(ctx.values.config);
|
|
978
|
-
const cwd = process.cwd();
|
|
979
|
-
const dryRun = ctx.values.dryRun ?? false;
|
|
980
|
-
const force = ctx.values.force ?? false;
|
|
981
|
-
const interactive = ctx.values.interactive ?? false;
|
|
982
|
-
if (!config.routesPattern) {
|
|
983
|
-
logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
|
|
984
|
-
process.exit(1);
|
|
985
|
-
}
|
|
986
|
-
logger.info("Scanning for route files...");
|
|
987
|
-
logger.blank();
|
|
988
|
-
const routeFiles = await glob(config.routesPattern, {
|
|
989
|
-
cwd,
|
|
990
|
-
ignore: config.ignore
|
|
969
|
+
//#region src/utils/routeParserUtils.ts
|
|
970
|
+
/**
|
|
971
|
+
* Resolve relative import path to absolute path
|
|
972
|
+
*/
|
|
973
|
+
function resolveImportPath(importPath, baseDir) {
|
|
974
|
+
if (importPath.startsWith(".")) return resolve(baseDir, importPath);
|
|
975
|
+
return importPath;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Flatten nested routes into a flat list with computed properties
|
|
979
|
+
*/
|
|
980
|
+
function flattenRoutes(routes, parentPath = "", depth = 0) {
|
|
981
|
+
const result = [];
|
|
982
|
+
for (const route of routes) {
|
|
983
|
+
if (route.redirect && !route.component) continue;
|
|
984
|
+
let fullPath;
|
|
985
|
+
if (route.path.startsWith("/")) fullPath = route.path;
|
|
986
|
+
else if (parentPath === "/") fullPath = `/${route.path}`;
|
|
987
|
+
else fullPath = parentPath ? `${parentPath}/${route.path}` : `/${route.path}`;
|
|
988
|
+
fullPath = fullPath.replace(/\/+/g, "/");
|
|
989
|
+
if (fullPath !== "/" && fullPath.endsWith("/")) fullPath = fullPath.slice(0, -1);
|
|
990
|
+
if (fullPath === "") fullPath = parentPath || "/";
|
|
991
|
+
if (route.component || !route.children) result.push({
|
|
992
|
+
fullPath,
|
|
993
|
+
name: route.name,
|
|
994
|
+
componentPath: route.component,
|
|
995
|
+
screenId: pathToScreenId(fullPath),
|
|
996
|
+
screenTitle: pathToScreenTitle(fullPath),
|
|
997
|
+
depth
|
|
991
998
|
});
|
|
992
|
-
if (
|
|
993
|
-
|
|
994
|
-
|
|
999
|
+
if (route.children) result.push(...flattenRoutes(route.children, fullPath, depth + 1));
|
|
1000
|
+
}
|
|
1001
|
+
return result;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Convert route path to screen ID
|
|
1005
|
+
* /user/:id/profile -> user.id.profile
|
|
1006
|
+
*/
|
|
1007
|
+
function pathToScreenId(path) {
|
|
1008
|
+
if (path === "/" || path === "") return "home";
|
|
1009
|
+
return path.replace(/^\//, "").replace(/\/$/, "").split("/").map((segment) => {
|
|
1010
|
+
if (segment.startsWith(":")) return segment.slice(1);
|
|
1011
|
+
if (segment.startsWith("*")) {
|
|
1012
|
+
if (segment === "**") return "catchall";
|
|
1013
|
+
return segment.slice(1) || "catchall";
|
|
995
1014
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1015
|
+
return segment;
|
|
1016
|
+
}).join(".");
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Convert route path to screen title
|
|
1020
|
+
* /user/:id/profile -> Profile
|
|
1021
|
+
*/
|
|
1022
|
+
function pathToScreenTitle(path) {
|
|
1023
|
+
if (path === "/" || path === "") return "Home";
|
|
1024
|
+
const segments = path.replace(/^\//, "").replace(/\/$/, "").split("/").filter((s) => !s.startsWith(":") && !s.startsWith("*"));
|
|
1025
|
+
return (segments[segments.length - 1] || "Home").split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region src/utils/angularRouterParser.ts
|
|
1030
|
+
/**
|
|
1031
|
+
* Parse Angular Router configuration file and extract routes.
|
|
1032
|
+
* Supports both Standalone (Angular 14+) and NgModule patterns.
|
|
1033
|
+
*
|
|
1034
|
+
* Supported patterns:
|
|
1035
|
+
* - `export const routes: Routes = [...]`
|
|
1036
|
+
* - `const routes: Routes = [...]`
|
|
1037
|
+
* - `RouterModule.forRoot([...])`
|
|
1038
|
+
* - `RouterModule.forChild([...])`
|
|
1039
|
+
* - `export default [...]`
|
|
1040
|
+
* - `export default [...] satisfies Routes`
|
|
1041
|
+
*
|
|
1042
|
+
* @param filePath - Path to the router configuration file
|
|
1043
|
+
* @param preloadedContent - Optional pre-read file content. When provided, the file is not read from disk,
|
|
1044
|
+
* enabling testing with virtual content or avoiding duplicate file reads.
|
|
1045
|
+
* @returns ParseResult containing extracted routes and any warnings
|
|
1046
|
+
* @throws Error if the file cannot be read or contains syntax errors
|
|
1047
|
+
*/
|
|
1048
|
+
function parseAngularRouterConfig(filePath, preloadedContent) {
|
|
1049
|
+
const absolutePath = resolve(filePath);
|
|
1050
|
+
const routesFileDir = dirname(absolutePath);
|
|
1051
|
+
const warnings = [];
|
|
1052
|
+
let content;
|
|
1053
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1054
|
+
else try {
|
|
1055
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1058
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1059
|
+
}
|
|
1060
|
+
let ast;
|
|
1061
|
+
try {
|
|
1062
|
+
ast = parse(content, {
|
|
1063
|
+
sourceType: "module",
|
|
1064
|
+
plugins: ["typescript", ["decorators", { decoratorsBeforeExport: true }]]
|
|
1065
|
+
});
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1068
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1069
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1070
|
+
}
|
|
1071
|
+
const routes = [];
|
|
1072
|
+
for (const node of ast.program.body) {
|
|
1073
|
+
if (node.type === "VariableDeclaration") {
|
|
1074
|
+
for (const decl of node.declarations) if (decl.id.type === "Identifier" && decl.init?.type === "ArrayExpression") {
|
|
1075
|
+
const typeAnnotation = decl.id.typeAnnotation?.typeAnnotation;
|
|
1076
|
+
if (decl.id.name.toLowerCase().includes("route") || typeAnnotation?.type === "TSTypeReference" && typeAnnotation.typeName?.type === "Identifier" && typeAnnotation.typeName.name === "Routes") {
|
|
1077
|
+
const parsed = parseRoutesArray$3(decl.init, routesFileDir, warnings);
|
|
1078
|
+
routes.push(...parsed);
|
|
1008
1079
|
}
|
|
1009
|
-
logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
|
|
1010
|
-
skipped++;
|
|
1011
|
-
continue;
|
|
1012
1080
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
}
|
|
1021
|
-
const content = generateScreenMetaContent(result.meta, {
|
|
1022
|
-
owner: result.owner,
|
|
1023
|
-
tags: result.tags
|
|
1024
|
-
});
|
|
1025
|
-
if (dryRun) {
|
|
1026
|
-
logger.step(`Would create: ${logger.path(metaPath)}`);
|
|
1027
|
-
logger.log(` ${logger.dim(`id: "${result.meta.id}"`)}`);
|
|
1028
|
-
logger.log(` ${logger.dim(`title: "${result.meta.title}"`)}`);
|
|
1029
|
-
logger.log(` ${logger.dim(`route: "${result.meta.route}"`)}`);
|
|
1030
|
-
if (result.owner.length > 0) logger.log(` ${logger.dim(`owner: [${result.owner.map((o) => `"${o}"`).join(", ")}]`)}`);
|
|
1031
|
-
if (result.tags.length > 0) logger.log(` ${logger.dim(`tags: [${result.tags.map((t) => `"${t}"`).join(", ")}]`)}`);
|
|
1032
|
-
logger.blank();
|
|
1033
|
-
} else {
|
|
1034
|
-
writeFileSync(absoluteMetaPath, content);
|
|
1035
|
-
logger.itemSuccess(`Created: ${logger.path(metaPath)}`);
|
|
1036
|
-
}
|
|
1037
|
-
} else {
|
|
1038
|
-
const content = generateScreenMetaContent(screenMeta);
|
|
1039
|
-
if (dryRun) {
|
|
1040
|
-
logger.step(`Would create: ${logger.path(metaPath)}`);
|
|
1041
|
-
logger.log(` ${logger.dim(`id: "${screenMeta.id}"`)}`);
|
|
1042
|
-
logger.log(` ${logger.dim(`title: "${screenMeta.title}"`)}`);
|
|
1043
|
-
logger.log(` ${logger.dim(`route: "${screenMeta.route}"`)}`);
|
|
1044
|
-
logger.blank();
|
|
1045
|
-
} else {
|
|
1046
|
-
writeFileSync(absoluteMetaPath, content);
|
|
1047
|
-
logger.itemSuccess(`Created: ${logger.path(metaPath)}`);
|
|
1081
|
+
}
|
|
1082
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
1083
|
+
for (const decl of node.declaration.declarations) if (decl.id.type === "Identifier" && decl.init?.type === "ArrayExpression") {
|
|
1084
|
+
const typeAnnotation = decl.id.typeAnnotation?.typeAnnotation;
|
|
1085
|
+
if (decl.id.name.toLowerCase().includes("route") || typeAnnotation?.type === "TSTypeReference" && typeAnnotation.typeName?.type === "Identifier" && typeAnnotation.typeName.name === "Routes") {
|
|
1086
|
+
const parsed = parseRoutesArray$3(decl.init, routesFileDir, warnings);
|
|
1087
|
+
routes.push(...parsed);
|
|
1048
1088
|
}
|
|
1049
1089
|
}
|
|
1050
|
-
created++;
|
|
1051
1090
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1091
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
1092
|
+
const parsed = parseRoutesArray$3(node.declaration, routesFileDir, warnings);
|
|
1093
|
+
routes.push(...parsed);
|
|
1094
|
+
}
|
|
1095
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "TSSatisfiesExpression" && node.declaration.expression.type === "ArrayExpression") {
|
|
1096
|
+
const parsed = parseRoutesArray$3(node.declaration.expression, routesFileDir, warnings);
|
|
1097
|
+
routes.push(...parsed);
|
|
1098
|
+
}
|
|
1099
|
+
let classNode = null;
|
|
1100
|
+
if (node.type === "ClassDeclaration") classNode = node;
|
|
1101
|
+
else if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "ClassDeclaration") classNode = node.declaration;
|
|
1102
|
+
if (classNode) {
|
|
1103
|
+
const decorators = classNode.decorators || [];
|
|
1104
|
+
for (const decorator of decorators) if (decorator.expression?.type === "CallExpression") {
|
|
1105
|
+
const routesFromDecorator = extractRoutesFromNgModule(decorator.expression, routesFileDir, warnings);
|
|
1106
|
+
routes.push(...routesFromDecorator);
|
|
1064
1107
|
}
|
|
1065
1108
|
}
|
|
1109
|
+
if (node.type === "ExpressionStatement") {
|
|
1110
|
+
const routesFromExpr = extractRoutesFromExpression(node.expression, routesFileDir, warnings);
|
|
1111
|
+
routes.push(...routesFromExpr);
|
|
1112
|
+
}
|
|
1066
1113
|
}
|
|
1067
|
-
|
|
1114
|
+
if (routes.length === 0) warnings.push("No routes found. Supported patterns: 'export const routes: Routes = [...]', 'RouterModule.forRoot([...])', or 'RouterModule.forChild([...])'");
|
|
1115
|
+
return {
|
|
1116
|
+
routes,
|
|
1117
|
+
warnings
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1068
1120
|
/**
|
|
1069
|
-
*
|
|
1121
|
+
* Extract routes from @NgModule decorator
|
|
1070
1122
|
*/
|
|
1071
|
-
function
|
|
1072
|
-
|
|
1073
|
-
|
|
1123
|
+
function extractRoutesFromNgModule(callExpr, baseDir, warnings) {
|
|
1124
|
+
const routes = [];
|
|
1125
|
+
if (callExpr.callee?.type === "Identifier" && callExpr.callee.name === "NgModule") {
|
|
1126
|
+
const arg = callExpr.arguments[0];
|
|
1127
|
+
if (arg?.type === "ObjectExpression") {
|
|
1128
|
+
for (const prop of arg.properties) if (prop.type === "ObjectProperty" && prop.key?.type === "Identifier" && prop.key.name === "imports") {
|
|
1129
|
+
if (prop.value?.type === "ArrayExpression") for (const element of prop.value.elements) {
|
|
1130
|
+
if (!element) continue;
|
|
1131
|
+
const extracted = extractRoutesFromExpression(element, baseDir, warnings);
|
|
1132
|
+
routes.push(...extracted);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return routes;
|
|
1074
1138
|
}
|
|
1075
1139
|
/**
|
|
1076
|
-
*
|
|
1140
|
+
* Extract routes from RouterModule.forRoot/forChild call expressions
|
|
1077
1141
|
*/
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
{
|
|
1088
|
-
type: "confirm",
|
|
1089
|
-
name: "proceed",
|
|
1090
|
-
message: "Generate this screen?",
|
|
1091
|
-
initial: true
|
|
1092
|
-
},
|
|
1093
|
-
{
|
|
1094
|
-
type: (prev) => prev ? "text" : null,
|
|
1095
|
-
name: "id",
|
|
1096
|
-
message: "ID",
|
|
1097
|
-
initial: inferred.id
|
|
1098
|
-
},
|
|
1099
|
-
{
|
|
1100
|
-
type: (_prev, values) => values.proceed ? "text" : null,
|
|
1101
|
-
name: "title",
|
|
1102
|
-
message: "Title",
|
|
1103
|
-
initial: inferred.title
|
|
1104
|
-
},
|
|
1105
|
-
{
|
|
1106
|
-
type: (_prev, values) => values.proceed ? "text" : null,
|
|
1107
|
-
name: "owner",
|
|
1108
|
-
message: "Owner (comma-separated)",
|
|
1109
|
-
initial: ""
|
|
1110
|
-
},
|
|
1111
|
-
{
|
|
1112
|
-
type: (_prev, values) => values.proceed ? "text" : null,
|
|
1113
|
-
name: "tags",
|
|
1114
|
-
message: "Tags (comma-separated)",
|
|
1115
|
-
initial: inferred.id.split(".")[0] || ""
|
|
1142
|
+
function extractRoutesFromExpression(node, baseDir, warnings) {
|
|
1143
|
+
const routes = [];
|
|
1144
|
+
if (node?.type !== "CallExpression") return routes;
|
|
1145
|
+
const callee = node.callee;
|
|
1146
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "RouterModule" && callee.property?.type === "Identifier" && (callee.property.name === "forRoot" || callee.property.name === "forChild")) {
|
|
1147
|
+
const routesArg = node.arguments[0];
|
|
1148
|
+
if (routesArg?.type === "ArrayExpression") {
|
|
1149
|
+
const parsed = parseRoutesArray$3(routesArg, baseDir, warnings);
|
|
1150
|
+
routes.push(...parsed);
|
|
1116
1151
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
skip: true,
|
|
1120
|
-
meta: inferred,
|
|
1121
|
-
owner: [],
|
|
1122
|
-
tags: []
|
|
1123
|
-
};
|
|
1124
|
-
return {
|
|
1125
|
-
skip: false,
|
|
1126
|
-
meta: {
|
|
1127
|
-
id: response.id || inferred.id,
|
|
1128
|
-
title: response.title || inferred.title,
|
|
1129
|
-
route: inferred.route
|
|
1130
|
-
},
|
|
1131
|
-
owner: parseCommaSeparated(response.owner || ""),
|
|
1132
|
-
tags: parseCommaSeparated(response.tags || "")
|
|
1133
|
-
};
|
|
1152
|
+
}
|
|
1153
|
+
return routes;
|
|
1134
1154
|
}
|
|
1135
1155
|
/**
|
|
1136
|
-
*
|
|
1156
|
+
* Parse an array expression containing route objects
|
|
1137
1157
|
*/
|
|
1138
|
-
function
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1158
|
+
function parseRoutesArray$3(arrayNode, baseDir, warnings) {
|
|
1159
|
+
const routes = [];
|
|
1160
|
+
for (const element of arrayNode.elements) {
|
|
1161
|
+
if (!element) continue;
|
|
1162
|
+
if (element.type === "SpreadElement") {
|
|
1163
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1164
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (element.type === "ObjectExpression") {
|
|
1168
|
+
const parsedRoute = parseRouteObject$3(element, baseDir, warnings);
|
|
1169
|
+
if (parsedRoute) routes.push(parsedRoute);
|
|
1170
|
+
} else {
|
|
1171
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1172
|
+
warnings.push(`Non-object route element (${element.type})${loc}. Only object literals can be statically analyzed.`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return routes;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Parse a single route object expression
|
|
1179
|
+
*/
|
|
1180
|
+
function parseRouteObject$3(objectNode, baseDir, warnings) {
|
|
1181
|
+
let path;
|
|
1182
|
+
let component;
|
|
1183
|
+
let children;
|
|
1184
|
+
let redirectTo;
|
|
1185
|
+
let hasPath = false;
|
|
1186
|
+
for (const prop of objectNode.properties) {
|
|
1187
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1188
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1189
|
+
switch (prop.key.name) {
|
|
1190
|
+
case "path":
|
|
1191
|
+
if (prop.value.type === "StringLiteral") {
|
|
1192
|
+
path = prop.value.value;
|
|
1193
|
+
hasPath = true;
|
|
1194
|
+
} else {
|
|
1195
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1196
|
+
warnings.push(`Dynamic path value (${prop.value.type})${loc}. Only string literal paths can be statically analyzed.`);
|
|
1197
|
+
}
|
|
1198
|
+
break;
|
|
1199
|
+
case "component":
|
|
1200
|
+
if (prop.value.type === "Identifier") component = prop.value.name;
|
|
1201
|
+
break;
|
|
1202
|
+
case "loadComponent":
|
|
1203
|
+
component = extractLazyComponent(prop.value, baseDir, warnings);
|
|
1204
|
+
break;
|
|
1205
|
+
case "loadChildren": {
|
|
1206
|
+
const lazyPath = extractLazyPath(prop.value, baseDir, warnings);
|
|
1207
|
+
if (lazyPath) component = `[lazy: ${lazyPath}]`;
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
case "children":
|
|
1211
|
+
if (prop.value.type === "ArrayExpression") children = parseRoutesArray$3(prop.value, baseDir, warnings);
|
|
1212
|
+
break;
|
|
1213
|
+
case "redirectTo":
|
|
1214
|
+
if (prop.value.type === "StringLiteral") redirectTo = prop.value.value;
|
|
1215
|
+
break;
|
|
1216
|
+
case "pathMatch":
|
|
1217
|
+
case "canActivate":
|
|
1218
|
+
case "canDeactivate":
|
|
1219
|
+
case "canMatch":
|
|
1220
|
+
case "resolve":
|
|
1221
|
+
case "data":
|
|
1222
|
+
case "title":
|
|
1223
|
+
case "providers":
|
|
1224
|
+
case "runGuardsAndResolvers":
|
|
1225
|
+
case "outlet": break;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (redirectTo && !component && !children) return null;
|
|
1229
|
+
if (!hasPath) {
|
|
1230
|
+
if (children && children.length > 0) return {
|
|
1231
|
+
path: "",
|
|
1232
|
+
component,
|
|
1233
|
+
children
|
|
1234
|
+
};
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1146
1237
|
return {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
if (s.startsWith("[...") && s.endsWith("]")) return "*";
|
|
1151
|
-
if (s.startsWith("[") && s.endsWith("]")) return `:${s.slice(1, -1)}`;
|
|
1152
|
-
return s;
|
|
1153
|
-
}).join("/")}`
|
|
1238
|
+
path: path || "",
|
|
1239
|
+
component,
|
|
1240
|
+
children
|
|
1154
1241
|
};
|
|
1155
1242
|
}
|
|
1156
1243
|
/**
|
|
1157
|
-
*
|
|
1244
|
+
* Extract component from lazy loadComponent pattern
|
|
1245
|
+
* loadComponent: () => import('./path').then(m => m.Component)
|
|
1158
1246
|
*/
|
|
1159
|
-
function
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
next: [],
|
|
1186
|
-
})
|
|
1187
|
-
`;
|
|
1247
|
+
function extractLazyComponent(node, baseDir, warnings) {
|
|
1248
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1249
|
+
const body = node.body;
|
|
1250
|
+
if (body.type === "CallExpression" && body.callee?.type === "MemberExpression" && body.callee.property?.type === "Identifier" && body.callee.property.name === "then") {
|
|
1251
|
+
const importCall = body.callee.object;
|
|
1252
|
+
const thenArg = body.arguments[0];
|
|
1253
|
+
if (importCall?.type === "CallExpression" && importCall.callee?.type === "Import") {
|
|
1254
|
+
if (importCall.arguments[0]?.type === "StringLiteral") {
|
|
1255
|
+
const importPath = resolveImportPath(importCall.arguments[0].value, baseDir);
|
|
1256
|
+
if (thenArg?.type === "ArrowFunctionExpression" && thenArg.body?.type === "MemberExpression" && thenArg.body.property?.type === "Identifier") return `${importPath}#${thenArg.body.property.name}`;
|
|
1257
|
+
return importPath;
|
|
1258
|
+
}
|
|
1259
|
+
const loc$1 = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1260
|
+
warnings.push(`Lazy loadComponent with dynamic path${loc$1}. Only string literal imports can be analyzed.`);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (body.type === "CallExpression" && body.callee?.type === "Import") {
|
|
1265
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1266
|
+
const loc$1 = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1267
|
+
warnings.push(`Lazy loadComponent with dynamic path${loc$1}. Only string literal imports can be analyzed.`);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1272
|
+
warnings.push(`Unrecognized loadComponent pattern (${node.type})${loc}. Expected arrow function with import().then().`);
|
|
1188
1273
|
}
|
|
1189
|
-
|
|
1190
|
-
//#endregion
|
|
1191
|
-
//#region src/utils/impactAnalysis.ts
|
|
1192
1274
|
/**
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1195
|
-
* - "InvoiceAPI.getDetail" matches "InvoiceAPI.getDetail"
|
|
1275
|
+
* Extract path from lazy loadChildren pattern
|
|
1276
|
+
* loadChildren: () => import('./path').then(m => m.routes)
|
|
1196
1277
|
*/
|
|
1197
|
-
function
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1278
|
+
function extractLazyPath(node, baseDir, warnings) {
|
|
1279
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1280
|
+
const body = node.body;
|
|
1281
|
+
if (body.type === "CallExpression" && body.callee?.type === "MemberExpression" && body.callee.property?.type === "Identifier" && body.callee.property.name === "then") {
|
|
1282
|
+
const importCall = body.callee.object;
|
|
1283
|
+
if (importCall?.type === "CallExpression" && importCall.callee?.type === "Import" && importCall.arguments[0]?.type === "StringLiteral") return resolveImportPath(importCall.arguments[0].value, baseDir);
|
|
1284
|
+
}
|
|
1285
|
+
if (body.type === "CallExpression" && body.callee?.type === "Import") {
|
|
1286
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1290
|
+
warnings.push(`Unrecognized loadChildren pattern (${node.type})${loc}. Expected arrow function with import().`);
|
|
1202
1291
|
}
|
|
1203
1292
|
/**
|
|
1204
|
-
*
|
|
1293
|
+
* Detect if content is Angular Router based on patterns.
|
|
1294
|
+
* Checks for @angular/router import, RouterModule patterns, or Routes type annotation.
|
|
1205
1295
|
*/
|
|
1206
|
-
function
|
|
1207
|
-
|
|
1296
|
+
function isAngularRouterContent(content) {
|
|
1297
|
+
if (content.includes("@angular/router")) return true;
|
|
1298
|
+
if (content.includes("RouterModule.forRoot") || content.includes("RouterModule.forChild")) return true;
|
|
1299
|
+
if (/:\s*Routes\s*[=[]/.test(content)) return true;
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
//#endregion
|
|
1304
|
+
//#region src/utils/solidRouterParser.ts
|
|
1305
|
+
/**
|
|
1306
|
+
* Parse Solid Router configuration file and extract routes.
|
|
1307
|
+
* Supports various export patterns including `export const routes`, `export default`,
|
|
1308
|
+
* and TypeScript's `satisfies` operator.
|
|
1309
|
+
*
|
|
1310
|
+
* @param filePath - Path to the router configuration file
|
|
1311
|
+
* @param preloadedContent - Optional pre-read file content to avoid duplicate file reads
|
|
1312
|
+
* @returns ParseResult containing extracted routes and any warnings
|
|
1313
|
+
* @throws Error if the file cannot be read or contains syntax errors
|
|
1314
|
+
*/
|
|
1315
|
+
function parseSolidRouterConfig(filePath, preloadedContent) {
|
|
1316
|
+
const absolutePath = resolve(filePath);
|
|
1317
|
+
const routesFileDir = dirname(absolutePath);
|
|
1318
|
+
const warnings = [];
|
|
1319
|
+
let content;
|
|
1320
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1321
|
+
else try {
|
|
1322
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1325
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1326
|
+
}
|
|
1327
|
+
let ast;
|
|
1328
|
+
try {
|
|
1329
|
+
ast = parse(content, {
|
|
1330
|
+
sourceType: "module",
|
|
1331
|
+
plugins: ["typescript", "jsx"]
|
|
1332
|
+
});
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1335
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1336
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1337
|
+
}
|
|
1338
|
+
const routes = [];
|
|
1339
|
+
for (const node of ast.program.body) {
|
|
1340
|
+
if (node.type === "VariableDeclaration") {
|
|
1341
|
+
for (const decl of node.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1342
|
+
const parsed = parseRoutesArray$2(decl.init, routesFileDir, warnings);
|
|
1343
|
+
routes.push(...parsed);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
1347
|
+
for (const decl of node.declaration.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1348
|
+
const parsed = parseRoutesArray$2(decl.init, routesFileDir, warnings);
|
|
1349
|
+
routes.push(...parsed);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
1353
|
+
const parsed = parseRoutesArray$2(node.declaration, routesFileDir, warnings);
|
|
1354
|
+
routes.push(...parsed);
|
|
1355
|
+
}
|
|
1356
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "TSSatisfiesExpression" && node.declaration.expression.type === "ArrayExpression") {
|
|
1357
|
+
const parsed = parseRoutesArray$2(node.declaration.expression, routesFileDir, warnings);
|
|
1358
|
+
routes.push(...parsed);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (routes.length === 0) warnings.push("No routes found. Supported patterns: 'export const routes = [...]' or 'export default [...]'");
|
|
1362
|
+
return {
|
|
1363
|
+
routes,
|
|
1364
|
+
warnings
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Parse an array expression containing route objects
|
|
1369
|
+
*/
|
|
1370
|
+
function parseRoutesArray$2(arrayNode, baseDir, warnings) {
|
|
1371
|
+
const routes = [];
|
|
1372
|
+
for (const element of arrayNode.elements) {
|
|
1373
|
+
if (!element) continue;
|
|
1374
|
+
if (element.type === "SpreadElement") {
|
|
1375
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1376
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (element.type === "ObjectExpression") {
|
|
1380
|
+
const parsedRoutes = parseRouteObject$2(element, baseDir, warnings);
|
|
1381
|
+
routes.push(...parsedRoutes);
|
|
1382
|
+
} else {
|
|
1383
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1384
|
+
warnings.push(`Non-object route element (${element.type})${loc}. Only object literals can be statically analyzed.`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return routes;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Parse a single route object expression
|
|
1391
|
+
* Returns array to handle multiple paths case: path: ["/a", "/b"]
|
|
1392
|
+
*/
|
|
1393
|
+
function parseRouteObject$2(objectNode, baseDir, warnings) {
|
|
1394
|
+
let paths = [];
|
|
1395
|
+
let component;
|
|
1396
|
+
let children;
|
|
1397
|
+
let hasPath = false;
|
|
1398
|
+
for (const prop of objectNode.properties) {
|
|
1399
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1400
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1401
|
+
switch (prop.key.name) {
|
|
1402
|
+
case "path":
|
|
1403
|
+
if (prop.value.type === "StringLiteral") {
|
|
1404
|
+
paths = [prop.value.value];
|
|
1405
|
+
hasPath = true;
|
|
1406
|
+
} else if (prop.value.type === "ArrayExpression") {
|
|
1407
|
+
const arrayElementCount = prop.value.elements.filter(Boolean).length;
|
|
1408
|
+
paths = extractPathArray(prop.value, warnings);
|
|
1409
|
+
hasPath = paths.length > 0;
|
|
1410
|
+
if (arrayElementCount > 0 && paths.length === 0) {
|
|
1411
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1412
|
+
warnings.push(`Path array contains only dynamic values${loc}. No static paths could be extracted.`);
|
|
1413
|
+
}
|
|
1414
|
+
} else {
|
|
1415
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1416
|
+
warnings.push(`Dynamic path value (${prop.value.type})${loc}. Only string literal paths can be statically analyzed.`);
|
|
1417
|
+
}
|
|
1418
|
+
break;
|
|
1419
|
+
case "component":
|
|
1420
|
+
component = extractComponent(prop.value, baseDir, warnings);
|
|
1421
|
+
break;
|
|
1422
|
+
case "children":
|
|
1423
|
+
if (prop.value.type === "ArrayExpression") children = parseRoutesArray$2(prop.value, baseDir, warnings);
|
|
1424
|
+
break;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (!hasPath) {
|
|
1428
|
+
if (children && children.length > 0) return [{
|
|
1429
|
+
path: "",
|
|
1430
|
+
component,
|
|
1431
|
+
children
|
|
1432
|
+
}];
|
|
1433
|
+
return [];
|
|
1434
|
+
}
|
|
1435
|
+
return paths.map((path) => ({
|
|
1436
|
+
path,
|
|
1437
|
+
component,
|
|
1438
|
+
children
|
|
1439
|
+
}));
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Extract paths from array expression
|
|
1443
|
+
* path: ["/login", "/register"] -> ["/login", "/register"]
|
|
1444
|
+
*/
|
|
1445
|
+
function extractPathArray(arrayNode, warnings) {
|
|
1446
|
+
const paths = [];
|
|
1447
|
+
for (const element of arrayNode.elements) {
|
|
1448
|
+
if (!element) continue;
|
|
1449
|
+
if (element.type === "StringLiteral") paths.push(element.value);
|
|
1450
|
+
else {
|
|
1451
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1452
|
+
warnings.push(`Non-string path in array (${element.type})${loc}. Only string literal paths can be analyzed.`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return paths;
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Extract component from various patterns
|
|
1459
|
+
* - Direct identifier: component: Home
|
|
1460
|
+
* - Lazy component: component: lazy(() => import("./Home"))
|
|
1461
|
+
*/
|
|
1462
|
+
function extractComponent(node, baseDir, warnings) {
|
|
1463
|
+
if (node.type === "Identifier") return node.name;
|
|
1464
|
+
if (node.type === "CallExpression") {
|
|
1465
|
+
const callee = node.callee;
|
|
1466
|
+
if (callee.type === "Identifier" && callee.name === "lazy") {
|
|
1467
|
+
const lazyArg = node.arguments[0];
|
|
1468
|
+
if (lazyArg) return extractLazyImportPath$2(lazyArg, baseDir, warnings);
|
|
1469
|
+
const loc$1 = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1470
|
+
warnings.push(`lazy() called without arguments${loc$1}. Expected arrow function with import().`);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1474
|
+
const calleeName = callee.type === "Identifier" ? callee.name : "unknown";
|
|
1475
|
+
warnings.push(`Unrecognized component pattern: ${calleeName}(...)${loc}. Only 'lazy(() => import(...))' is supported.`);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1479
|
+
if (node.body.type === "JSXElement") {
|
|
1480
|
+
const openingElement = node.body.openingElement;
|
|
1481
|
+
if (openingElement?.name?.type === "JSXIdentifier") return openingElement.name.name;
|
|
1482
|
+
if (openingElement?.name?.type === "JSXMemberExpression") {
|
|
1483
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1484
|
+
warnings.push(`Namespaced JSX component (e.g., <UI.Button />)${loc}. Component extraction not supported for member expressions. Consider using a direct component reference or create a wrapper component.`);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if (node.body.type === "BlockStatement") {
|
|
1489
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1490
|
+
warnings.push(`Arrow function with block body${loc}. Only concise arrow functions returning JSX directly can be analyzed.`);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (node.body.type === "JSXFragment") {
|
|
1494
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1495
|
+
warnings.push(`JSX Fragment detected${loc}. Cannot extract component name from fragments.`);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (node.body.type === "ConditionalExpression") {
|
|
1499
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1500
|
+
let componentInfo = "";
|
|
1501
|
+
const consequent = node.body.consequent;
|
|
1502
|
+
const alternate = node.body.alternate;
|
|
1503
|
+
if (consequent?.type === "JSXElement" && alternate?.type === "JSXElement") componentInfo = ` (${consequent.openingElement?.name?.name || "unknown"} or ${alternate.openingElement?.name?.name || "unknown"})`;
|
|
1504
|
+
warnings.push(`Conditional component${componentInfo}${loc}. Only static JSX elements can be analyzed. Consider extracting to a separate component.`);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
const arrowLoc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1508
|
+
warnings.push(`Unrecognized arrow function body (${node.body.type})${arrowLoc}. Component will not be extracted.`);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (node) {
|
|
1512
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1513
|
+
warnings.push(`Unrecognized component pattern (${node.type})${loc}. Component will not be extracted.`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Extract import path from lazy function argument
|
|
1518
|
+
* () => import("./pages/Dashboard") -> resolved path
|
|
1519
|
+
*/
|
|
1520
|
+
function extractLazyImportPath$2(node, baseDir, warnings) {
|
|
1521
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1522
|
+
const body = node.body;
|
|
1523
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
1524
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1525
|
+
const loc$1 = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1526
|
+
warnings.push(`Lazy import with dynamic path${loc$1}. Only string literal imports can be analyzed.`);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1531
|
+
warnings.push(`Unrecognized lazy pattern (${node.type})${loc}. Expected arrow function with import().`);
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Detect if content is Solid Router based on patterns.
|
|
1535
|
+
* Note: Called by detectRouterType() in reactRouterParser.ts before React Router detection
|
|
1536
|
+
* because Solid Router and React Router share similar syntax patterns (both use `path` and `component`).
|
|
1537
|
+
* The detection order matters: TanStack Router -> Solid Router -> Angular Router -> React Router -> Vue Router.
|
|
1538
|
+
*/
|
|
1539
|
+
function isSolidRouterContent(content) {
|
|
1540
|
+
if (content.includes("@solidjs/router")) return true;
|
|
1541
|
+
if (content.includes("solid-app-router")) return true;
|
|
1542
|
+
if (content.includes("solid-js") && /\blazy\s*\(/.test(content) && /\bcomponent\s*:/.test(content) && /\bpath\s*:/.test(content)) return true;
|
|
1543
|
+
return false;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
//#endregion
|
|
1547
|
+
//#region src/utils/tanstackRouterParser.ts
|
|
1548
|
+
/**
|
|
1549
|
+
* Parse TanStack Router configuration file and extract routes
|
|
1550
|
+
*/
|
|
1551
|
+
function parseTanStackRouterConfig(filePath, preloadedContent) {
|
|
1552
|
+
const absolutePath = resolve(filePath);
|
|
1553
|
+
const routesFileDir = dirname(absolutePath);
|
|
1554
|
+
const warnings = [];
|
|
1555
|
+
let content;
|
|
1556
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1557
|
+
else try {
|
|
1558
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1561
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1562
|
+
}
|
|
1563
|
+
let ast;
|
|
1564
|
+
try {
|
|
1565
|
+
ast = parse(content, {
|
|
1566
|
+
sourceType: "module",
|
|
1567
|
+
plugins: ["typescript", "jsx"]
|
|
1568
|
+
});
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1571
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1572
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1573
|
+
}
|
|
1574
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
1575
|
+
for (const node of ast.program.body) collectRouteDefinitions(node, routeMap, routesFileDir, warnings);
|
|
1576
|
+
for (const node of ast.program.body) processAddChildrenCalls(node, routeMap, warnings);
|
|
1577
|
+
const routes = buildRouteTree(routeMap, warnings);
|
|
1578
|
+
if (routes.length === 0) warnings.push("No routes found. Supported patterns: 'createRootRoute()', 'createRoute()', and '.addChildren([...])'");
|
|
1579
|
+
return {
|
|
1580
|
+
routes,
|
|
1581
|
+
warnings
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Collect route definitions from AST nodes
|
|
1586
|
+
*/
|
|
1587
|
+
function collectRouteDefinitions(node, routeMap, baseDir, warnings) {
|
|
1588
|
+
if (node.type === "VariableDeclaration") for (const decl of node.declarations) {
|
|
1589
|
+
if (decl.id.type !== "Identifier") continue;
|
|
1590
|
+
const variableName = decl.id.name;
|
|
1591
|
+
if (decl.init?.type === "CallExpression") {
|
|
1592
|
+
const routeDef = extractRouteFromCallExpression(decl.init, variableName, baseDir, warnings);
|
|
1593
|
+
if (routeDef) routeMap.set(variableName, routeDef);
|
|
1594
|
+
}
|
|
1595
|
+
if (decl.init?.type === "CallExpression" && decl.init.callee?.type === "MemberExpression" && decl.init.callee.property?.type === "Identifier" && decl.init.callee.property.name === "lazy") {
|
|
1596
|
+
const createRouteCall = decl.init.callee.object;
|
|
1597
|
+
if (createRouteCall?.type === "CallExpression") {
|
|
1598
|
+
const routeDef = extractRouteFromCallExpression(createRouteCall, variableName, baseDir, warnings);
|
|
1599
|
+
if (routeDef) {
|
|
1600
|
+
const lazyArg = decl.init.arguments[0];
|
|
1601
|
+
if (lazyArg) {
|
|
1602
|
+
const lazyPath = extractLazyImportPath$1(lazyArg, baseDir, warnings);
|
|
1603
|
+
if (lazyPath) routeDef.component = lazyPath;
|
|
1604
|
+
}
|
|
1605
|
+
routeMap.set(variableName, routeDef);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") for (const decl of node.declaration.declarations) {
|
|
1611
|
+
if (decl.id.type !== "Identifier") continue;
|
|
1612
|
+
const variableName = decl.id.name;
|
|
1613
|
+
if (decl.init?.type === "CallExpression") {
|
|
1614
|
+
const routeDef = extractRouteFromCallExpression(decl.init, variableName, baseDir, warnings);
|
|
1615
|
+
if (routeDef) routeMap.set(variableName, routeDef);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Extract route definition from a CallExpression (createRoute or createRootRoute)
|
|
1621
|
+
*/
|
|
1622
|
+
function extractRouteFromCallExpression(callNode, variableName, baseDir, warnings) {
|
|
1623
|
+
const callee = callNode.callee;
|
|
1624
|
+
let isRoot = false;
|
|
1625
|
+
let optionsArg = callNode.arguments[0];
|
|
1626
|
+
if (callee.type === "Identifier") {
|
|
1627
|
+
if (callee.name === "createRootRoute") isRoot = true;
|
|
1628
|
+
else if (callee.name === "createRootRouteWithContext") isRoot = true;
|
|
1629
|
+
else if (callee.name !== "createRoute") return null;
|
|
1630
|
+
} else if (callee.type === "CallExpression") {
|
|
1631
|
+
const innerCallee = callee.callee;
|
|
1632
|
+
if (innerCallee?.type === "Identifier" && innerCallee.name === "createRootRouteWithContext") {
|
|
1633
|
+
isRoot = true;
|
|
1634
|
+
optionsArg = callNode.arguments[0];
|
|
1635
|
+
} else return null;
|
|
1636
|
+
} else return null;
|
|
1637
|
+
const routeDef = {
|
|
1638
|
+
variableName,
|
|
1639
|
+
isRoot
|
|
1640
|
+
};
|
|
1641
|
+
if (optionsArg?.type === "ObjectExpression") for (const prop of optionsArg.properties) {
|
|
1642
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1643
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1644
|
+
switch (prop.key.name) {
|
|
1645
|
+
case "path":
|
|
1646
|
+
if (prop.value.type === "StringLiteral") routeDef.path = normalizeTanStackPath(prop.value.value);
|
|
1647
|
+
else {
|
|
1648
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1649
|
+
warnings.push(`Dynamic path value (${prop.value.type})${loc}. Only string literal paths can be statically analyzed.`);
|
|
1650
|
+
}
|
|
1651
|
+
break;
|
|
1652
|
+
case "component":
|
|
1653
|
+
routeDef.component = extractComponentValue(prop.value, baseDir, warnings);
|
|
1654
|
+
break;
|
|
1655
|
+
case "getParentRoute":
|
|
1656
|
+
if (prop.value.type === "ArrowFunctionExpression") {
|
|
1657
|
+
const body = prop.value.body;
|
|
1658
|
+
if (body.type === "Identifier") routeDef.parentVariableName = body.name;
|
|
1659
|
+
else {
|
|
1660
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1661
|
+
warnings.push(`Dynamic getParentRoute${loc}. Only static route references can be analyzed.`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
break;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return routeDef;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Extract component value from different patterns
|
|
1671
|
+
* Returns undefined with a warning for unrecognized patterns
|
|
1672
|
+
*/
|
|
1673
|
+
function extractComponentValue(node, baseDir, warnings) {
|
|
1674
|
+
if (node.type === "Identifier") return node.name;
|
|
1675
|
+
if (node.type === "CallExpression") {
|
|
1676
|
+
const callee = node.callee;
|
|
1677
|
+
if (callee.type === "Identifier" && callee.name === "lazyRouteComponent") {
|
|
1678
|
+
const importArg = node.arguments[0];
|
|
1679
|
+
if (importArg) return extractLazyImportPath$1(importArg, baseDir, warnings);
|
|
1680
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1681
|
+
warnings.push(`lazyRouteComponent called without arguments${loc}. Expected arrow function with import().`);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1687
|
+
if (node.body.type === "JSXElement") {
|
|
1688
|
+
const openingElement = node.body.openingElement;
|
|
1689
|
+
if (openingElement?.name?.type === "JSXIdentifier") return openingElement.name.name;
|
|
1690
|
+
if (openingElement?.name?.type === "JSXMemberExpression") {
|
|
1691
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1692
|
+
warnings.push(`Namespaced JSX component${loc}. Component extraction not fully supported for member expressions.`);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (node.body.type === "BlockStatement") {
|
|
1697
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1698
|
+
warnings.push(`Arrow function with block body${loc}. Only concise arrow functions returning JSX directly can be analyzed.`);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (node.body.type === "JSXFragment") {
|
|
1702
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1703
|
+
warnings.push(`JSX Fragment detected${loc}. Cannot extract component name from fragments.`);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Extract import path from lazy function patterns
|
|
1710
|
+
*/
|
|
1711
|
+
function extractLazyImportPath$1(node, baseDir, warnings) {
|
|
1712
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1713
|
+
const body = node.body;
|
|
1714
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
1715
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1716
|
+
}
|
|
1717
|
+
if (body.type === "CallExpression" && body.callee.type === "MemberExpression" && body.callee.object?.type === "CallExpression" && body.callee.object.callee?.type === "Import") {
|
|
1718
|
+
const importCall = body.callee.object;
|
|
1719
|
+
if (importCall.arguments[0]?.type === "StringLiteral") return resolveImportPath(importCall.arguments[0].value, baseDir);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1723
|
+
warnings.push(`Unrecognized lazy pattern (${node.type})${loc}. Expected arrow function with import().`);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Process addChildren calls to establish parent-child relationships
|
|
1727
|
+
*/
|
|
1728
|
+
function processAddChildrenCalls(node, routeMap, warnings) {
|
|
1729
|
+
if (node.type === "VariableDeclaration") for (const decl of node.declarations) processAddChildrenExpression(decl.init, routeMap, warnings);
|
|
1730
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") for (const decl of node.declaration.declarations) processAddChildrenExpression(decl.init, routeMap, warnings);
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Recursively process addChildren expressions
|
|
1734
|
+
*/
|
|
1735
|
+
function processAddChildrenExpression(expr, routeMap, warnings) {
|
|
1736
|
+
if (!expr) return void 0;
|
|
1737
|
+
if (expr.type === "CallExpression" && expr.callee?.type === "MemberExpression" && expr.callee.property?.type === "Identifier" && expr.callee.property.name === "addChildren") {
|
|
1738
|
+
let parentVarName;
|
|
1739
|
+
if (expr.callee.object?.type === "Identifier") parentVarName = expr.callee.object.name;
|
|
1740
|
+
else if (expr.callee.object?.type === "CallExpression") parentVarName = processAddChildrenExpression(expr.callee.object, routeMap, warnings);
|
|
1741
|
+
if (!parentVarName) return void 0;
|
|
1742
|
+
const parentDef = routeMap.get(parentVarName);
|
|
1743
|
+
if (!parentDef) {
|
|
1744
|
+
const loc = expr.loc ? ` at line ${expr.loc.start.line}` : "";
|
|
1745
|
+
warnings.push(`Parent route "${parentVarName}" not found${loc}. Ensure it's defined with createRoute/createRootRoute.`);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
const childrenArg = expr.arguments[0];
|
|
1749
|
+
if (childrenArg?.type === "ArrayExpression") {
|
|
1750
|
+
const childNames = [];
|
|
1751
|
+
for (const element of childrenArg.elements) {
|
|
1752
|
+
if (!element) continue;
|
|
1753
|
+
if (element.type === "Identifier") childNames.push(element.name);
|
|
1754
|
+
else if (element.type === "CallExpression") {
|
|
1755
|
+
const nestedParent = processAddChildrenExpression(element, routeMap, warnings);
|
|
1756
|
+
if (nestedParent) childNames.push(nestedParent);
|
|
1757
|
+
} else if (element.type === "SpreadElement") {
|
|
1758
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1759
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
parentDef.children = childNames;
|
|
1763
|
+
}
|
|
1764
|
+
return parentVarName;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Build the route tree from collected definitions
|
|
1769
|
+
*/
|
|
1770
|
+
function buildRouteTree(routeMap, warnings) {
|
|
1771
|
+
const rootDefs = Array.from(routeMap.values()).filter((def) => def.isRoot);
|
|
1772
|
+
if (rootDefs.length === 0) return buildTreeFromParentRelations(routeMap, warnings);
|
|
1773
|
+
const routes = [];
|
|
1774
|
+
for (const rootDef of rootDefs) {
|
|
1775
|
+
const rootRoute = buildRouteFromDefinition(rootDef, routeMap, warnings, /* @__PURE__ */ new Set());
|
|
1776
|
+
if (rootRoute) {
|
|
1777
|
+
if (rootRoute.children && rootRoute.children.length > 0) routes.push(...rootRoute.children);
|
|
1778
|
+
else if (rootRoute.path) routes.push(rootRoute);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return routes;
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Build tree when there's no explicit root route
|
|
1785
|
+
* Falls back to finding routes that have no parent relationship defined
|
|
1786
|
+
*/
|
|
1787
|
+
function buildTreeFromParentRelations(routeMap, warnings) {
|
|
1788
|
+
const topLevelDefs = Array.from(routeMap.values()).filter((def) => !def.parentVariableName && !def.isRoot);
|
|
1789
|
+
const routes = [];
|
|
1790
|
+
for (const def of topLevelDefs) {
|
|
1791
|
+
const route = buildRouteFromDefinition(def, routeMap, warnings, /* @__PURE__ */ new Set());
|
|
1792
|
+
if (route) routes.push(route);
|
|
1793
|
+
}
|
|
1794
|
+
return routes;
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Build a ParsedRoute from a RouteDefinition
|
|
1798
|
+
* @param visited - Set of visited variable names for circular reference detection
|
|
1799
|
+
*/
|
|
1800
|
+
function buildRouteFromDefinition(def, routeMap, warnings, visited) {
|
|
1801
|
+
if (visited.has(def.variableName)) {
|
|
1802
|
+
warnings.push(`Circular reference detected: route "${def.variableName}" references itself in the route tree.`);
|
|
1803
|
+
return null;
|
|
1804
|
+
}
|
|
1805
|
+
visited.add(def.variableName);
|
|
1806
|
+
const route = {
|
|
1807
|
+
path: def.path ?? "",
|
|
1808
|
+
component: def.component
|
|
1809
|
+
};
|
|
1810
|
+
if (def.children && def.children.length > 0) {
|
|
1811
|
+
const children = [];
|
|
1812
|
+
for (const childName of def.children) {
|
|
1813
|
+
const childDef = routeMap.get(childName);
|
|
1814
|
+
if (childDef) {
|
|
1815
|
+
const childRoute = buildRouteFromDefinition(childDef, routeMap, warnings, visited);
|
|
1816
|
+
if (childRoute) children.push(childRoute);
|
|
1817
|
+
} else warnings.push(`Child route "${childName}" not found. Ensure it's defined with createRoute.`);
|
|
1818
|
+
}
|
|
1819
|
+
if (children.length > 0) route.children = children;
|
|
1820
|
+
}
|
|
1821
|
+
return route;
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Normalize TanStack Router path syntax to standard format
|
|
1825
|
+
* /$ (trailing catch-all) -> /*
|
|
1826
|
+
* $ (standalone splat) -> *
|
|
1827
|
+
* $param (dynamic segment) -> :param
|
|
1828
|
+
*/
|
|
1829
|
+
function normalizeTanStackPath(path) {
|
|
1830
|
+
return path.replace(/\/\$$/, "/*").replace(/^\$$/, "*").replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, ":$1");
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Detect if content is TanStack Router based on patterns
|
|
1834
|
+
*/
|
|
1835
|
+
function isTanStackRouterContent(content) {
|
|
1836
|
+
if (content.includes("@tanstack/react-router")) return true;
|
|
1837
|
+
if (content.includes("createRootRoute")) return true;
|
|
1838
|
+
if (content.includes("createRoute") && content.includes("getParentRoute")) return true;
|
|
1839
|
+
if (content.includes("lazyRouteComponent")) return true;
|
|
1840
|
+
if (/\.addChildren\s*\(/.test(content)) return true;
|
|
1841
|
+
return false;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
//#endregion
|
|
1845
|
+
//#region src/utils/reactRouterParser.ts
|
|
1846
|
+
/**
|
|
1847
|
+
* Router factory function names to detect
|
|
1848
|
+
*/
|
|
1849
|
+
const ROUTER_FACTORY_NAMES = [
|
|
1850
|
+
"createBrowserRouter",
|
|
1851
|
+
"createHashRouter",
|
|
1852
|
+
"createMemoryRouter"
|
|
1853
|
+
];
|
|
1854
|
+
/**
|
|
1855
|
+
* Parse React Router configuration file and extract routes
|
|
1856
|
+
*/
|
|
1857
|
+
function parseReactRouterConfig(filePath, preloadedContent) {
|
|
1858
|
+
const absolutePath = resolve(filePath);
|
|
1859
|
+
const routesFileDir = dirname(absolutePath);
|
|
1860
|
+
const warnings = [];
|
|
1861
|
+
let content;
|
|
1862
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1863
|
+
else try {
|
|
1864
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1867
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1868
|
+
}
|
|
1869
|
+
let ast;
|
|
1870
|
+
try {
|
|
1871
|
+
ast = parse(content, {
|
|
1872
|
+
sourceType: "module",
|
|
1873
|
+
plugins: ["typescript", "jsx"]
|
|
1874
|
+
});
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1877
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1878
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1879
|
+
}
|
|
1880
|
+
const routes = [];
|
|
1881
|
+
for (const node of ast.program.body) {
|
|
1882
|
+
if (node.type === "VariableDeclaration") {
|
|
1883
|
+
for (const decl of node.declarations) if (decl.init?.type === "CallExpression") {
|
|
1884
|
+
const callee = decl.init.callee;
|
|
1885
|
+
if (callee.type === "Identifier" && ROUTER_FACTORY_NAMES.includes(callee.name)) {
|
|
1886
|
+
const firstArg = decl.init.arguments[0];
|
|
1887
|
+
if (firstArg?.type === "ArrayExpression") {
|
|
1888
|
+
const parsed = parseRoutesArray$1(firstArg, routesFileDir, warnings);
|
|
1889
|
+
routes.push(...parsed);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") for (const decl of node.declaration.declarations) {
|
|
1895
|
+
if (decl.init?.type === "CallExpression") {
|
|
1896
|
+
const callee = decl.init.callee;
|
|
1897
|
+
if (callee.type === "Identifier" && ROUTER_FACTORY_NAMES.includes(callee.name)) {
|
|
1898
|
+
const firstArg = decl.init.arguments[0];
|
|
1899
|
+
if (firstArg?.type === "ArrayExpression") {
|
|
1900
|
+
const parsed = parseRoutesArray$1(firstArg, routesFileDir, warnings);
|
|
1901
|
+
routes.push(...parsed);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1906
|
+
const parsed = parseRoutesArray$1(decl.init, routesFileDir, warnings);
|
|
1907
|
+
routes.push(...parsed);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
if (node.type === "VariableDeclaration") {
|
|
1911
|
+
for (const decl of node.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1912
|
+
const parsed = parseRoutesArray$1(decl.init, routesFileDir, warnings);
|
|
1913
|
+
routes.push(...parsed);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
1917
|
+
const parsed = parseRoutesArray$1(node.declaration, routesFileDir, warnings);
|
|
1918
|
+
routes.push(...parsed);
|
|
1919
|
+
}
|
|
1920
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "TSSatisfiesExpression" && node.declaration.expression.type === "ArrayExpression") {
|
|
1921
|
+
const parsed = parseRoutesArray$1(node.declaration.expression, routesFileDir, warnings);
|
|
1922
|
+
routes.push(...parsed);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
if (routes.length === 0) warnings.push("No routes found. Supported patterns: 'createBrowserRouter([...])', 'export const routes = [...]', or 'export default [...]'");
|
|
1926
|
+
return {
|
|
1927
|
+
routes,
|
|
1928
|
+
warnings
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Parse an array expression containing route objects
|
|
1933
|
+
*/
|
|
1934
|
+
function parseRoutesArray$1(arrayNode, baseDir, warnings) {
|
|
1935
|
+
const routes = [];
|
|
1936
|
+
for (const element of arrayNode.elements) {
|
|
1937
|
+
if (!element) continue;
|
|
1938
|
+
if (element.type === "SpreadElement") {
|
|
1939
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1940
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1941
|
+
continue;
|
|
1942
|
+
}
|
|
1943
|
+
if (element.type === "ObjectExpression") {
|
|
1944
|
+
const route = parseRouteObject$1(element, baseDir, warnings);
|
|
1945
|
+
if (route) routes.push(route);
|
|
1946
|
+
} else {
|
|
1947
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1948
|
+
warnings.push(`Non-object route element (${element.type})${loc}. Only object literals can be statically analyzed.`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return routes;
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Parse a single route object expression
|
|
1955
|
+
*/
|
|
1956
|
+
function parseRouteObject$1(objectNode, baseDir, warnings) {
|
|
1957
|
+
const route = { path: "" };
|
|
1958
|
+
let isIndexRoute = false;
|
|
1959
|
+
let hasPath = false;
|
|
1960
|
+
for (const prop of objectNode.properties) {
|
|
1961
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1962
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1963
|
+
switch (prop.key.name) {
|
|
1964
|
+
case "path":
|
|
1965
|
+
if (prop.value.type === "StringLiteral") {
|
|
1966
|
+
route.path = prop.value.value;
|
|
1967
|
+
hasPath = true;
|
|
1968
|
+
} else {
|
|
1969
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1970
|
+
warnings.push(`Dynamic path value (${prop.value.type})${loc}. Only string literal paths can be statically analyzed.`);
|
|
1971
|
+
}
|
|
1972
|
+
break;
|
|
1973
|
+
case "index":
|
|
1974
|
+
if (prop.value.type === "BooleanLiteral" && prop.value.value === true) isIndexRoute = true;
|
|
1975
|
+
break;
|
|
1976
|
+
case "element":
|
|
1977
|
+
route.component = extractComponentFromJSX(prop.value, warnings);
|
|
1978
|
+
break;
|
|
1979
|
+
case "Component":
|
|
1980
|
+
if (prop.value.type === "Identifier") route.component = prop.value.name;
|
|
1981
|
+
break;
|
|
1982
|
+
case "lazy": {
|
|
1983
|
+
const lazyPath = extractLazyImportPath(prop.value, baseDir, warnings);
|
|
1984
|
+
if (lazyPath) route.component = lazyPath;
|
|
1985
|
+
break;
|
|
1986
|
+
}
|
|
1987
|
+
case "children":
|
|
1988
|
+
if (prop.value.type === "ArrayExpression") route.children = parseRoutesArray$1(prop.value, baseDir, warnings);
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
if (isIndexRoute) {
|
|
1993
|
+
route.path = "";
|
|
1994
|
+
return route;
|
|
1995
|
+
}
|
|
1996
|
+
if (!hasPath && !isIndexRoute) {
|
|
1997
|
+
if (route.children && route.children.length > 0) {
|
|
1998
|
+
route.path = "";
|
|
1999
|
+
return route;
|
|
2000
|
+
}
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
return route;
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Extract component name from JSX element
|
|
2007
|
+
* element: <Dashboard /> -> "Dashboard"
|
|
2008
|
+
*/
|
|
2009
|
+
function extractComponentFromJSX(node, warnings) {
|
|
2010
|
+
if (node.type === "JSXElement") {
|
|
2011
|
+
const openingElement = node.openingElement;
|
|
2012
|
+
if (openingElement?.name?.type === "JSXIdentifier") return openingElement.name.name;
|
|
2013
|
+
if (openingElement?.name?.type === "JSXMemberExpression") {
|
|
2014
|
+
const parts = [];
|
|
2015
|
+
let current = openingElement.name;
|
|
2016
|
+
while (current) {
|
|
2017
|
+
if (current.type === "JSXIdentifier") {
|
|
2018
|
+
parts.unshift(current.name);
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
2021
|
+
if (current.type === "JSXMemberExpression") {
|
|
2022
|
+
if (current.property?.type === "JSXIdentifier") parts.unshift(current.property.name);
|
|
2023
|
+
current = current.object;
|
|
2024
|
+
} else break;
|
|
2025
|
+
}
|
|
2026
|
+
return parts.join(".");
|
|
2027
|
+
}
|
|
2028
|
+
if (node.children && node.children.length > 0 && openingElement?.name?.type === "JSXIdentifier") {
|
|
2029
|
+
const wrapperName = openingElement.name.name;
|
|
2030
|
+
for (const child of node.children) if (child.type === "JSXElement") {
|
|
2031
|
+
if (extractComponentFromJSX(child, warnings)) {
|
|
2032
|
+
warnings.push(`Wrapper component detected: <${wrapperName}>. Using wrapper name for screen.`);
|
|
2033
|
+
return wrapperName;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (node.type === "JSXFragment") {
|
|
2039
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
2040
|
+
warnings.push(`JSX Fragment detected${loc}. Cannot extract component name from fragments. Consider wrapping in a named component.`);
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (node) {
|
|
2044
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
2045
|
+
warnings.push(`Unrecognized element pattern (${node.type})${loc}. Component will not be extracted.`);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Extract import path from lazy function
|
|
2050
|
+
* lazy: () => import("./pages/Dashboard") -> resolved path
|
|
2051
|
+
*/
|
|
2052
|
+
function extractLazyImportPath(node, baseDir, warnings) {
|
|
2053
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
2054
|
+
const body = node.body;
|
|
2055
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
2056
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
2057
|
+
const loc$1 = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
2058
|
+
warnings.push(`Lazy import with dynamic path${loc$1}. Only string literal imports can be statically analyzed.`);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
2063
|
+
warnings.push(`Unrecognized lazy pattern (${node.type})${loc}. Expected arrow function with import().`);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Detect if content is React Router based on patterns
|
|
2067
|
+
*/
|
|
2068
|
+
function isReactRouterContent(content) {
|
|
2069
|
+
if (content.includes("createBrowserRouter") || content.includes("createHashRouter") || content.includes("createMemoryRouter") || content.includes("RouteObject")) return true;
|
|
2070
|
+
if (/element:\s*</.test(content)) return true;
|
|
2071
|
+
if (/Component:\s*[A-Z]/.test(content)) return true;
|
|
2072
|
+
return false;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Detect if content is Vue Router based on patterns
|
|
2076
|
+
*/
|
|
2077
|
+
function isVueRouterContent(content) {
|
|
2078
|
+
if (content.includes("RouteRecordRaw") || content.includes("vue-router") || content.includes(".vue")) return true;
|
|
2079
|
+
return false;
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Detect router type from file content.
|
|
2083
|
+
* Detection order: TanStack Router -> Solid Router -> Angular Router -> React Router -> Vue Router.
|
|
2084
|
+
* This order ensures more specific patterns are checked before more generic ones.
|
|
2085
|
+
*/
|
|
2086
|
+
function detectRouterType(content) {
|
|
2087
|
+
if (isTanStackRouterContent(content)) return "tanstack-router";
|
|
2088
|
+
if (isSolidRouterContent(content)) return "solid-router";
|
|
2089
|
+
if (isAngularRouterContent(content)) return "angular-router";
|
|
2090
|
+
if (isReactRouterContent(content)) return "react-router";
|
|
2091
|
+
if (isVueRouterContent(content)) return "vue-router";
|
|
2092
|
+
return "unknown";
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
//#endregion
|
|
2096
|
+
//#region src/utils/vueRouterParser.ts
|
|
2097
|
+
/**
|
|
2098
|
+
* Parse Vue Router configuration file and extract routes
|
|
2099
|
+
*/
|
|
2100
|
+
function parseVueRouterConfig(filePath, preloadedContent) {
|
|
2101
|
+
const absolutePath = resolve(filePath);
|
|
2102
|
+
const routesFileDir = dirname(absolutePath);
|
|
2103
|
+
const warnings = [];
|
|
2104
|
+
let content;
|
|
2105
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
2106
|
+
else try {
|
|
2107
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2110
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
2111
|
+
}
|
|
2112
|
+
let ast;
|
|
2113
|
+
try {
|
|
2114
|
+
ast = parse(content, {
|
|
2115
|
+
sourceType: "module",
|
|
2116
|
+
plugins: ["typescript", "jsx"]
|
|
2117
|
+
});
|
|
2118
|
+
} catch (error) {
|
|
2119
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
2120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2121
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
2122
|
+
}
|
|
2123
|
+
const routes = [];
|
|
2124
|
+
for (const node of ast.program.body) {
|
|
2125
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
2126
|
+
for (const decl of node.declaration.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
2127
|
+
const parsed = parseRoutesArray(decl.init, routesFileDir, warnings);
|
|
2128
|
+
routes.push(...parsed);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
if (node.type === "VariableDeclaration") {
|
|
2132
|
+
for (const decl of node.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
2133
|
+
const parsed = parseRoutesArray(decl.init, routesFileDir, warnings);
|
|
2134
|
+
routes.push(...parsed);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
2138
|
+
const parsed = parseRoutesArray(node.declaration, routesFileDir, warnings);
|
|
2139
|
+
routes.push(...parsed);
|
|
2140
|
+
}
|
|
2141
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "TSSatisfiesExpression" && node.declaration.expression.type === "ArrayExpression") {
|
|
2142
|
+
const parsed = parseRoutesArray(node.declaration.expression, routesFileDir, warnings);
|
|
2143
|
+
routes.push(...parsed);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
if (routes.length === 0) warnings.push("No routes array found. Supported patterns: 'export const routes = [...]', 'export default [...]', or 'export default [...] satisfies RouteRecordRaw[]'");
|
|
2147
|
+
return {
|
|
2148
|
+
routes,
|
|
2149
|
+
warnings
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Parse an array expression containing route objects
|
|
2154
|
+
*/
|
|
2155
|
+
function parseRoutesArray(arrayNode, baseDir, warnings) {
|
|
2156
|
+
const routes = [];
|
|
2157
|
+
for (const element of arrayNode.elements) {
|
|
2158
|
+
if (!element) continue;
|
|
2159
|
+
if (element.type === "SpreadElement") {
|
|
2160
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
2161
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
if (element.type === "ObjectExpression") {
|
|
2165
|
+
const route = parseRouteObject(element, baseDir, warnings);
|
|
2166
|
+
if (route) routes.push(route);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
return routes;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Parse a single route object expression
|
|
2173
|
+
*/
|
|
2174
|
+
function parseRouteObject(objectNode, baseDir, warnings) {
|
|
2175
|
+
const route = { path: "" };
|
|
2176
|
+
for (const prop of objectNode.properties) {
|
|
2177
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
2178
|
+
if (prop.key.type !== "Identifier") continue;
|
|
2179
|
+
switch (prop.key.name) {
|
|
2180
|
+
case "path":
|
|
2181
|
+
if (prop.value.type === "StringLiteral") route.path = prop.value.value;
|
|
2182
|
+
break;
|
|
2183
|
+
case "name":
|
|
2184
|
+
if (prop.value.type === "StringLiteral") route.name = prop.value.value;
|
|
2185
|
+
break;
|
|
2186
|
+
case "redirect":
|
|
2187
|
+
if (prop.value.type === "StringLiteral") route.redirect = prop.value.value;
|
|
2188
|
+
break;
|
|
2189
|
+
case "component":
|
|
2190
|
+
route.component = extractComponentPath(prop.value, baseDir);
|
|
2191
|
+
break;
|
|
2192
|
+
case "children":
|
|
2193
|
+
if (prop.value.type === "ArrayExpression") route.children = parseRoutesArray(prop.value, baseDir, warnings);
|
|
2194
|
+
break;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
if (!route.path) return null;
|
|
2198
|
+
return route;
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Extract component path from various component definitions
|
|
2202
|
+
*/
|
|
2203
|
+
function extractComponentPath(node, baseDir) {
|
|
2204
|
+
if (node.type === "Identifier") return;
|
|
2205
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
2206
|
+
const body = node.body;
|
|
2207
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
2208
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
2209
|
+
}
|
|
2210
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
2211
|
+
for (const arg of body.arguments) if (arg.type === "StringLiteral") return resolveImportPath(arg.value, baseDir);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
//#endregion
|
|
2217
|
+
//#region src/commands/generate.ts
|
|
2218
|
+
const generateCommand = define({
|
|
2219
|
+
name: "generate",
|
|
2220
|
+
description: "Auto-generate screen.meta.ts files from route files",
|
|
2221
|
+
args: {
|
|
2222
|
+
config: {
|
|
2223
|
+
type: "string",
|
|
2224
|
+
short: "c",
|
|
2225
|
+
description: "Path to config file"
|
|
2226
|
+
},
|
|
2227
|
+
dryRun: {
|
|
2228
|
+
type: "boolean",
|
|
2229
|
+
short: "n",
|
|
2230
|
+
description: "Show what would be generated without writing files",
|
|
2231
|
+
default: false
|
|
2232
|
+
},
|
|
2233
|
+
force: {
|
|
2234
|
+
type: "boolean",
|
|
2235
|
+
short: "f",
|
|
2236
|
+
description: "Overwrite existing screen.meta.ts files",
|
|
2237
|
+
default: false
|
|
2238
|
+
},
|
|
2239
|
+
interactive: {
|
|
2240
|
+
type: "boolean",
|
|
2241
|
+
short: "i",
|
|
2242
|
+
description: "Interactively confirm or modify each screen",
|
|
2243
|
+
default: false
|
|
2244
|
+
}
|
|
2245
|
+
},
|
|
2246
|
+
run: async (ctx) => {
|
|
2247
|
+
const config = await loadConfig(ctx.values.config);
|
|
2248
|
+
const cwd = process.cwd();
|
|
2249
|
+
const dryRun = ctx.values.dryRun ?? false;
|
|
2250
|
+
const force = ctx.values.force ?? false;
|
|
2251
|
+
const interactive = ctx.values.interactive ?? false;
|
|
2252
|
+
if (!config.routesPattern && !config.routesFile) {
|
|
2253
|
+
logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
|
|
2254
|
+
process.exit(1);
|
|
2255
|
+
}
|
|
2256
|
+
if (config.routesFile) {
|
|
2257
|
+
await generateFromRoutesFile(config.routesFile, cwd, {
|
|
2258
|
+
dryRun,
|
|
2259
|
+
force,
|
|
2260
|
+
interactive
|
|
2261
|
+
});
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
await generateFromRoutesPattern(config.routesPattern, cwd, {
|
|
2265
|
+
dryRun,
|
|
2266
|
+
force,
|
|
2267
|
+
interactive,
|
|
2268
|
+
ignore: config.ignore
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
});
|
|
2272
|
+
/**
|
|
2273
|
+
* Generate screen.meta.ts files from a router config file (Vue Router or React Router)
|
|
2274
|
+
*/
|
|
2275
|
+
async function generateFromRoutesFile(routesFile, cwd, options) {
|
|
2276
|
+
const { dryRun, force, interactive } = options;
|
|
2277
|
+
const absoluteRoutesFile = resolve(cwd, routesFile);
|
|
2278
|
+
if (!existsSync(absoluteRoutesFile)) {
|
|
2279
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_NOT_FOUND(routesFile));
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
}
|
|
2282
|
+
const routerType = detectRouterType(readFileSync(absoluteRoutesFile, "utf-8"));
|
|
2283
|
+
const routerTypeDisplay = routerType === "tanstack-router" ? "TanStack Router" : routerType === "solid-router" ? "Solid Router" : routerType === "angular-router" ? "Angular Router" : routerType === "react-router" ? "React Router" : routerType === "vue-router" ? "Vue Router" : "unknown";
|
|
2284
|
+
logger.info(`Parsing routes from ${logger.path(routesFile)} (${routerTypeDisplay})...`);
|
|
2285
|
+
logger.blank();
|
|
2286
|
+
let parseResult;
|
|
2287
|
+
try {
|
|
2288
|
+
if (routerType === "tanstack-router") parseResult = parseTanStackRouterConfig(absoluteRoutesFile);
|
|
2289
|
+
else if (routerType === "solid-router") parseResult = parseSolidRouterConfig(absoluteRoutesFile);
|
|
2290
|
+
else if (routerType === "angular-router") parseResult = parseAngularRouterConfig(absoluteRoutesFile);
|
|
2291
|
+
else if (routerType === "react-router") parseResult = parseReactRouterConfig(absoluteRoutesFile);
|
|
2292
|
+
else if (routerType === "vue-router") parseResult = parseVueRouterConfig(absoluteRoutesFile);
|
|
2293
|
+
else {
|
|
2294
|
+
logger.warn(`Could not auto-detect router type for ${logger.path(routesFile)}. Attempting to parse as Vue Router.`);
|
|
2295
|
+
logger.log(` ${logger.dim("If parsing fails, check that your router imports are explicit.")}`);
|
|
2296
|
+
parseResult = parseVueRouterConfig(absoluteRoutesFile);
|
|
2297
|
+
}
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2300
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_PARSE_ERROR(routesFile, message));
|
|
2301
|
+
process.exit(1);
|
|
2302
|
+
}
|
|
2303
|
+
for (const warning of parseResult.warnings) logger.warn(warning);
|
|
2304
|
+
const flatRoutes = flattenRoutes(parseResult.routes);
|
|
2305
|
+
if (flatRoutes.length === 0) {
|
|
2306
|
+
logger.warn("No routes found in the config file");
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
logger.log(`Found ${flatRoutes.length} routes`);
|
|
2310
|
+
logger.blank();
|
|
2311
|
+
let created = 0;
|
|
2312
|
+
let skipped = 0;
|
|
2313
|
+
for (const route of flatRoutes) {
|
|
2314
|
+
const metaPath = determineMetaPath(route, cwd);
|
|
2315
|
+
const absoluteMetaPath = resolve(cwd, metaPath);
|
|
2316
|
+
if (!force && existsSync(absoluteMetaPath)) {
|
|
2317
|
+
if (!interactive) {
|
|
2318
|
+
skipped++;
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
|
|
2322
|
+
skipped++;
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
const screenMeta = {
|
|
2326
|
+
id: route.screenId,
|
|
2327
|
+
title: route.screenTitle,
|
|
2328
|
+
route: route.fullPath
|
|
2329
|
+
};
|
|
2330
|
+
if (interactive) {
|
|
2331
|
+
const result = await promptForScreen(route.fullPath, screenMeta);
|
|
2332
|
+
if (result.skip) {
|
|
2333
|
+
logger.itemWarn(`Skipped: ${logger.path(metaPath)}`);
|
|
2334
|
+
skipped++;
|
|
2335
|
+
continue;
|
|
2336
|
+
}
|
|
2337
|
+
const content = generateScreenMetaContent(result.meta, {
|
|
2338
|
+
owner: result.owner,
|
|
2339
|
+
tags: result.tags
|
|
2340
|
+
});
|
|
2341
|
+
if (dryRun) {
|
|
2342
|
+
logDryRunOutput(metaPath, result.meta, result.owner, result.tags);
|
|
2343
|
+
created++;
|
|
2344
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
2345
|
+
} else {
|
|
2346
|
+
const content = generateScreenMetaContent(screenMeta);
|
|
2347
|
+
if (dryRun) {
|
|
2348
|
+
logDryRunOutput(metaPath, screenMeta);
|
|
2349
|
+
created++;
|
|
2350
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
logSummary(created, skipped, dryRun);
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Generate screen.meta.ts files from route files matching a glob pattern
|
|
2357
|
+
*/
|
|
2358
|
+
async function generateFromRoutesPattern(routesPattern, cwd, options) {
|
|
2359
|
+
const { dryRun, force, interactive, ignore } = options;
|
|
2360
|
+
logger.info("Scanning for route files...");
|
|
2361
|
+
logger.blank();
|
|
2362
|
+
const routeFiles = await glob(routesPattern, {
|
|
2363
|
+
cwd,
|
|
2364
|
+
ignore
|
|
2365
|
+
});
|
|
2366
|
+
if (routeFiles.length === 0) {
|
|
2367
|
+
logger.warn(`No route files found matching: ${routesPattern}`);
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
logger.log(`Found ${routeFiles.length} route files`);
|
|
2371
|
+
logger.blank();
|
|
2372
|
+
let created = 0;
|
|
2373
|
+
let skipped = 0;
|
|
2374
|
+
for (const routeFile of routeFiles) {
|
|
2375
|
+
const routeDir = dirname(routeFile);
|
|
2376
|
+
const metaPath = join(routeDir, "screen.meta.ts");
|
|
2377
|
+
const absoluteMetaPath = join(cwd, metaPath);
|
|
2378
|
+
if (!force && existsSync(absoluteMetaPath)) {
|
|
2379
|
+
if (!interactive) {
|
|
2380
|
+
skipped++;
|
|
2381
|
+
continue;
|
|
2382
|
+
}
|
|
2383
|
+
logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
|
|
2384
|
+
skipped++;
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
const screenMeta = inferScreenMeta(routeDir, routesPattern);
|
|
2388
|
+
if (interactive) {
|
|
2389
|
+
const result = await promptForScreen(routeFile, screenMeta);
|
|
2390
|
+
if (result.skip) {
|
|
2391
|
+
logger.itemWarn(`Skipped: ${logger.path(metaPath)}`);
|
|
2392
|
+
skipped++;
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const content = generateScreenMetaContent(result.meta, {
|
|
2396
|
+
owner: result.owner,
|
|
2397
|
+
tags: result.tags
|
|
2398
|
+
});
|
|
2399
|
+
if (dryRun) {
|
|
2400
|
+
logDryRunOutput(metaPath, result.meta, result.owner, result.tags);
|
|
2401
|
+
created++;
|
|
2402
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
2403
|
+
} else {
|
|
2404
|
+
const content = generateScreenMetaContent(screenMeta);
|
|
2405
|
+
if (dryRun) {
|
|
2406
|
+
logDryRunOutput(metaPath, screenMeta);
|
|
2407
|
+
created++;
|
|
2408
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
logSummary(created, skipped, dryRun);
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* Determine where to place screen.meta.ts for a route
|
|
2415
|
+
*/
|
|
2416
|
+
function determineMetaPath(route, cwd) {
|
|
2417
|
+
if (route.componentPath) {
|
|
2418
|
+
const relativePath = relative(cwd, dirname(route.componentPath));
|
|
2419
|
+
if (!relativePath.startsWith("..")) return join(relativePath, "screen.meta.ts");
|
|
2420
|
+
}
|
|
2421
|
+
return join("src", "screens", route.screenId.replace(/\./g, "/"), "screen.meta.ts");
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Ensure the directory for a file exists
|
|
2425
|
+
*/
|
|
2426
|
+
function ensureDirectoryExists(filePath) {
|
|
2427
|
+
const dir = dirname(filePath);
|
|
2428
|
+
if (!existsSync(dir)) try {
|
|
2429
|
+
mkdirSync(dir, { recursive: true });
|
|
2430
|
+
} catch (error) {
|
|
2431
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2432
|
+
throw new Error(`Failed to create directory "${dir}": ${message}`);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
/**
|
|
2436
|
+
* Safely write a file with error handling
|
|
2437
|
+
* Returns true if successful, false if failed
|
|
2438
|
+
*/
|
|
2439
|
+
function safeWriteFile(absolutePath, relativePath, content) {
|
|
2440
|
+
try {
|
|
2441
|
+
ensureDirectoryExists(absolutePath);
|
|
2442
|
+
writeFileSync(absolutePath, content);
|
|
2443
|
+
logger.itemSuccess(`Created: ${logger.path(relativePath)}`);
|
|
2444
|
+
return true;
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2447
|
+
logger.itemError(`Failed to create ${logger.path(relativePath)}: ${message}`);
|
|
2448
|
+
return false;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Log dry run output for a screen
|
|
2453
|
+
*/
|
|
2454
|
+
function logDryRunOutput(metaPath, meta, owner, tags) {
|
|
2455
|
+
logger.step(`Would create: ${logger.path(metaPath)}`);
|
|
2456
|
+
logger.log(` ${logger.dim(`id: "${meta.id}"`)}`);
|
|
2457
|
+
logger.log(` ${logger.dim(`title: "${meta.title}"`)}`);
|
|
2458
|
+
logger.log(` ${logger.dim(`route: "${meta.route}"`)}`);
|
|
2459
|
+
if (owner && owner.length > 0) logger.log(` ${logger.dim(`owner: [${owner.map((o) => `"${o}"`).join(", ")}]`)}`);
|
|
2460
|
+
if (tags && tags.length > 0) logger.log(` ${logger.dim(`tags: [${tags.map((t) => `"${t}"`).join(", ")}]`)}`);
|
|
2461
|
+
logger.blank();
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Log summary after generation
|
|
2465
|
+
*/
|
|
2466
|
+
function logSummary(created, skipped, dryRun) {
|
|
2467
|
+
logger.blank();
|
|
2468
|
+
if (dryRun) {
|
|
2469
|
+
logger.info(`Would create ${created} files (${skipped} already exist)`);
|
|
2470
|
+
logger.blank();
|
|
2471
|
+
logger.log(`Run without ${logger.code("--dry-run")} to create files`);
|
|
2472
|
+
} else {
|
|
2473
|
+
logger.done(`Created ${created} files (${skipped} skipped)`);
|
|
2474
|
+
if (created > 0) {
|
|
2475
|
+
logger.blank();
|
|
2476
|
+
logger.log(logger.bold("Next steps:"));
|
|
2477
|
+
logger.log(" 1. Review and customize the generated screen.meta.ts files");
|
|
2478
|
+
logger.log(` 2. Run ${logger.code("screenbook dev")} to view your screen catalog`);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Parse comma-separated string into array
|
|
2484
|
+
*/
|
|
2485
|
+
function parseCommaSeparated(input) {
|
|
2486
|
+
if (!input.trim()) return [];
|
|
2487
|
+
return input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Prompt user for screen metadata in interactive mode
|
|
2491
|
+
*/
|
|
2492
|
+
async function promptForScreen(routeFile, inferred) {
|
|
2493
|
+
logger.blank();
|
|
2494
|
+
logger.info(`Found: ${logger.path(routeFile)}`);
|
|
2495
|
+
logger.blank();
|
|
2496
|
+
logger.log(` ${logger.dim("ID:")} ${inferred.id} ${logger.dim("(inferred)")}`);
|
|
2497
|
+
logger.log(` ${logger.dim("Title:")} ${inferred.title} ${logger.dim("(inferred)")}`);
|
|
2498
|
+
logger.log(` ${logger.dim("Route:")} ${inferred.route} ${logger.dim("(inferred)")}`);
|
|
2499
|
+
logger.blank();
|
|
2500
|
+
const response = await prompts([
|
|
2501
|
+
{
|
|
2502
|
+
type: "confirm",
|
|
2503
|
+
name: "proceed",
|
|
2504
|
+
message: "Generate this screen?",
|
|
2505
|
+
initial: true
|
|
2506
|
+
},
|
|
2507
|
+
{
|
|
2508
|
+
type: (prev) => prev ? "text" : null,
|
|
2509
|
+
name: "id",
|
|
2510
|
+
message: "ID",
|
|
2511
|
+
initial: inferred.id
|
|
2512
|
+
},
|
|
2513
|
+
{
|
|
2514
|
+
type: (_prev, values) => values.proceed ? "text" : null,
|
|
2515
|
+
name: "title",
|
|
2516
|
+
message: "Title",
|
|
2517
|
+
initial: inferred.title
|
|
2518
|
+
},
|
|
2519
|
+
{
|
|
2520
|
+
type: (_prev, values) => values.proceed ? "text" : null,
|
|
2521
|
+
name: "owner",
|
|
2522
|
+
message: "Owner (comma-separated)",
|
|
2523
|
+
initial: ""
|
|
2524
|
+
},
|
|
2525
|
+
{
|
|
2526
|
+
type: (_prev, values) => values.proceed ? "text" : null,
|
|
2527
|
+
name: "tags",
|
|
2528
|
+
message: "Tags (comma-separated)",
|
|
2529
|
+
initial: inferred.id.split(".")[0] || ""
|
|
2530
|
+
}
|
|
2531
|
+
]);
|
|
2532
|
+
if (!response.proceed) return {
|
|
2533
|
+
skip: true,
|
|
2534
|
+
meta: inferred,
|
|
2535
|
+
owner: [],
|
|
2536
|
+
tags: []
|
|
2537
|
+
};
|
|
2538
|
+
return {
|
|
2539
|
+
skip: false,
|
|
2540
|
+
meta: {
|
|
2541
|
+
id: response.id || inferred.id,
|
|
2542
|
+
title: response.title || inferred.title,
|
|
2543
|
+
route: inferred.route
|
|
2544
|
+
},
|
|
2545
|
+
owner: parseCommaSeparated(response.owner || ""),
|
|
2546
|
+
tags: parseCommaSeparated(response.tags || "")
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Infer screen metadata from the route file path
|
|
2551
|
+
*/
|
|
2552
|
+
function inferScreenMeta(routeDir, routesPattern) {
|
|
2553
|
+
const relativePath = relative(routesPattern.split("*")[0]?.replace(/\/$/, "") ?? "", routeDir);
|
|
2554
|
+
if (!relativePath || relativePath === ".") return {
|
|
2555
|
+
id: "home",
|
|
2556
|
+
title: "Home",
|
|
2557
|
+
route: "/"
|
|
2558
|
+
};
|
|
2559
|
+
const segments = relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => s.replace(/^\[\.\.\..*\]$/, "catchall").replace(/^\[(.+)\]$/, "$1"));
|
|
2560
|
+
return {
|
|
2561
|
+
id: segments.join("."),
|
|
2562
|
+
title: (segments[segments.length - 1] || "home").split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "),
|
|
2563
|
+
route: `/${relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => {
|
|
2564
|
+
if (s.startsWith("[...") && s.endsWith("]")) return "*";
|
|
2565
|
+
if (s.startsWith("[") && s.endsWith("]")) return `:${s.slice(1, -1)}`;
|
|
2566
|
+
return s;
|
|
2567
|
+
}).join("/")}`
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Generate screen.meta.ts file content
|
|
2572
|
+
*/
|
|
2573
|
+
function generateScreenMetaContent(meta, options) {
|
|
2574
|
+
const owner = options?.owner ?? [];
|
|
2575
|
+
const tags = options?.tags && options.tags.length > 0 ? options.tags : [meta.id.split(".")[0] || "general"];
|
|
2576
|
+
const ownerStr = owner.length > 0 ? `[${owner.map((o) => `"${o}"`).join(", ")}]` : "[]";
|
|
2577
|
+
const tagsStr = `[${tags.map((t) => `"${t}"`).join(", ")}]`;
|
|
2578
|
+
return `import { defineScreen } from "@screenbook/core"
|
|
2579
|
+
|
|
2580
|
+
export const screen = defineScreen({
|
|
2581
|
+
id: "${meta.id}",
|
|
2582
|
+
title: "${meta.title}",
|
|
2583
|
+
route: "${meta.route}",
|
|
2584
|
+
|
|
2585
|
+
// Team or individual responsible for this screen
|
|
2586
|
+
owner: ${ownerStr},
|
|
2587
|
+
|
|
2588
|
+
// Tags for filtering in the catalog
|
|
2589
|
+
tags: ${tagsStr},
|
|
2590
|
+
|
|
2591
|
+
// APIs/services this screen depends on (for impact analysis)
|
|
2592
|
+
// Example: ["UserAPI.getProfile", "PaymentService.checkout"]
|
|
2593
|
+
dependsOn: [],
|
|
2594
|
+
|
|
2595
|
+
// Screen IDs that can navigate to this screen
|
|
2596
|
+
entryPoints: [],
|
|
2597
|
+
|
|
2598
|
+
// Screen IDs this screen can navigate to
|
|
2599
|
+
next: [],
|
|
2600
|
+
})
|
|
2601
|
+
`;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
//#endregion
|
|
2605
|
+
//#region src/utils/impactAnalysis.ts
|
|
2606
|
+
/**
|
|
2607
|
+
* Check if a screen's dependsOn matches the API name (supports partial matching).
|
|
2608
|
+
* - "InvoiceAPI" matches "InvoiceAPI.getDetail"
|
|
2609
|
+
* - "InvoiceAPI.getDetail" matches "InvoiceAPI.getDetail"
|
|
2610
|
+
*/
|
|
2611
|
+
function matchesDependency(dependency, apiName) {
|
|
2612
|
+
if (dependency === apiName) return true;
|
|
2613
|
+
if (dependency.startsWith(`${apiName}.`)) return true;
|
|
2614
|
+
if (apiName.startsWith(`${dependency}.`)) return true;
|
|
2615
|
+
return false;
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Find screens that directly depend on the given API.
|
|
2619
|
+
*/
|
|
2620
|
+
function findDirectDependents(screens, apiName) {
|
|
2621
|
+
return screens.filter((screen) => screen.dependsOn?.some((dep) => matchesDependency(dep, apiName)));
|
|
1208
2622
|
}
|
|
1209
2623
|
/**
|
|
1210
2624
|
* Build a navigation graph from `next` field: screenId -> screens it can navigate to.
|
|
@@ -1472,6 +2886,34 @@ const FRAMEWORKS = [
|
|
|
1472
2886
|
routesPattern: "src/pages/**/*.astro",
|
|
1473
2887
|
metaPattern: "src/pages/**/screen.meta.ts"
|
|
1474
2888
|
},
|
|
2889
|
+
{
|
|
2890
|
+
name: "SolidStart",
|
|
2891
|
+
packages: ["@solidjs/start"],
|
|
2892
|
+
configFiles: ["app.config.ts", "app.config.js"],
|
|
2893
|
+
routesPattern: "src/routes/**/*.tsx",
|
|
2894
|
+
metaPattern: "src/routes/**/screen.meta.ts",
|
|
2895
|
+
check: (cwd) => existsSync(join(cwd, "src/routes"))
|
|
2896
|
+
},
|
|
2897
|
+
{
|
|
2898
|
+
name: "QwikCity",
|
|
2899
|
+
packages: ["@builder.io/qwik-city"],
|
|
2900
|
+
configFiles: [
|
|
2901
|
+
"vite.config.ts",
|
|
2902
|
+
"vite.config.js",
|
|
2903
|
+
"vite.config.mjs"
|
|
2904
|
+
],
|
|
2905
|
+
routesPattern: "src/routes/**/index.tsx",
|
|
2906
|
+
metaPattern: "src/routes/**/screen.meta.ts",
|
|
2907
|
+
check: (cwd) => existsSync(join(cwd, "src/routes"))
|
|
2908
|
+
},
|
|
2909
|
+
{
|
|
2910
|
+
name: "TanStack Start",
|
|
2911
|
+
packages: ["@tanstack/react-start", "@tanstack/start"],
|
|
2912
|
+
configFiles: ["app.config.ts", "app.config.js"],
|
|
2913
|
+
routesPattern: "src/routes/**/*.tsx",
|
|
2914
|
+
metaPattern: "src/routes/**/screen.meta.ts",
|
|
2915
|
+
check: (cwd) => existsSync(join(cwd, "src/routes/__root.tsx"))
|
|
2916
|
+
},
|
|
1475
2917
|
{
|
|
1476
2918
|
name: "Vite + Vue",
|
|
1477
2919
|
packages: ["vite", "vue"],
|
|
@@ -1506,7 +2948,8 @@ function readPackageJson(cwd) {
|
|
|
1506
2948
|
try {
|
|
1507
2949
|
const content = readFileSync(packageJsonPath, "utf-8");
|
|
1508
2950
|
return JSON.parse(content);
|
|
1509
|
-
} catch {
|
|
2951
|
+
} catch (error) {
|
|
2952
|
+
console.warn(`Warning: Failed to parse package.json at ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1510
2953
|
return null;
|
|
1511
2954
|
}
|
|
1512
2955
|
}
|
|
@@ -1706,7 +3149,7 @@ const lintCommand = define({
|
|
|
1706
3149
|
const cwd = process.cwd();
|
|
1707
3150
|
const adoption = config.adoption ?? { mode: "full" };
|
|
1708
3151
|
let hasWarnings = false;
|
|
1709
|
-
if (!config.routesPattern) {
|
|
3152
|
+
if (!config.routesPattern && !config.routesFile) {
|
|
1710
3153
|
logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
|
|
1711
3154
|
process.exit(1);
|
|
1712
3155
|
}
|
|
@@ -1717,6 +3160,10 @@ const lintCommand = define({
|
|
|
1717
3160
|
if (adoption.minimumCoverage != null) logger.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
|
|
1718
3161
|
}
|
|
1719
3162
|
logger.blank();
|
|
3163
|
+
if (config.routesFile) {
|
|
3164
|
+
await lintRoutesFile(config.routesFile, cwd, config, adoption, ctx.values.allowCycles ?? false, ctx.values.strict ?? false);
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
1720
3167
|
let routeFiles = await glob(config.routesPattern, {
|
|
1721
3168
|
cwd,
|
|
1722
3169
|
ignore: config.ignore
|
|
@@ -1821,6 +3268,9 @@ const lintCommand = define({
|
|
|
1821
3268
|
} else if (error instanceof Error) {
|
|
1822
3269
|
logger.warn(`Failed to analyze screens.json: ${error.message}`);
|
|
1823
3270
|
hasWarnings = true;
|
|
3271
|
+
} else {
|
|
3272
|
+
logger.warn(`Failed to analyze screens.json: ${String(error)}`);
|
|
3273
|
+
hasWarnings = true;
|
|
1824
3274
|
}
|
|
1825
3275
|
}
|
|
1826
3276
|
if (hasWarnings) {
|
|
@@ -1830,6 +3280,196 @@ const lintCommand = define({
|
|
|
1830
3280
|
}
|
|
1831
3281
|
});
|
|
1832
3282
|
/**
|
|
3283
|
+
* Lint screen.meta.ts coverage for routesFile mode (config-based routing)
|
|
3284
|
+
*/
|
|
3285
|
+
async function lintRoutesFile(routesFile, cwd, config, adoption, allowCycles, strict) {
|
|
3286
|
+
let hasWarnings = false;
|
|
3287
|
+
const absoluteRoutesFile = resolve(cwd, routesFile);
|
|
3288
|
+
if (!existsSync(absoluteRoutesFile)) {
|
|
3289
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_NOT_FOUND(routesFile));
|
|
3290
|
+
process.exit(1);
|
|
3291
|
+
}
|
|
3292
|
+
logger.log(`Parsing routes from ${logger.path(routesFile)}...`);
|
|
3293
|
+
logger.blank();
|
|
3294
|
+
let flatRoutes;
|
|
3295
|
+
try {
|
|
3296
|
+
const content = readFileSync(absoluteRoutesFile, "utf-8");
|
|
3297
|
+
const routerType = detectRouterType(content);
|
|
3298
|
+
let parseResult;
|
|
3299
|
+
if (routerType === "tanstack-router") parseResult = parseTanStackRouterConfig(absoluteRoutesFile, content);
|
|
3300
|
+
else if (routerType === "solid-router") parseResult = parseSolidRouterConfig(absoluteRoutesFile, content);
|
|
3301
|
+
else if (routerType === "angular-router") parseResult = parseAngularRouterConfig(absoluteRoutesFile, content);
|
|
3302
|
+
else if (routerType === "react-router") parseResult = parseReactRouterConfig(absoluteRoutesFile, content);
|
|
3303
|
+
else if (routerType === "vue-router") parseResult = parseVueRouterConfig(absoluteRoutesFile, content);
|
|
3304
|
+
else {
|
|
3305
|
+
logger.warn(`Could not auto-detect router type for ${logger.path(routesFile)}. Attempting to parse as Vue Router.`);
|
|
3306
|
+
logger.log(` ${logger.dim("If parsing fails, check that your router imports are explicit.")}`);
|
|
3307
|
+
hasWarnings = true;
|
|
3308
|
+
parseResult = parseVueRouterConfig(absoluteRoutesFile, content);
|
|
3309
|
+
}
|
|
3310
|
+
for (const warning of parseResult.warnings) {
|
|
3311
|
+
logger.warn(warning);
|
|
3312
|
+
hasWarnings = true;
|
|
3313
|
+
}
|
|
3314
|
+
flatRoutes = flattenRoutes(parseResult.routes);
|
|
3315
|
+
} catch (error) {
|
|
3316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3317
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_PARSE_ERROR(routesFile, message));
|
|
3318
|
+
process.exit(1);
|
|
3319
|
+
}
|
|
3320
|
+
if (flatRoutes.length === 0) {
|
|
3321
|
+
logger.warn("No routes found in the config file");
|
|
3322
|
+
return hasWarnings;
|
|
3323
|
+
}
|
|
3324
|
+
const metaFiles = await glob(config.metaPattern, {
|
|
3325
|
+
cwd,
|
|
3326
|
+
ignore: config.ignore
|
|
3327
|
+
});
|
|
3328
|
+
const metaDirs = /* @__PURE__ */ new Set();
|
|
3329
|
+
const metaDirsByName = /* @__PURE__ */ new Map();
|
|
3330
|
+
for (const metaFile of metaFiles) {
|
|
3331
|
+
const dir = dirname(metaFile);
|
|
3332
|
+
metaDirs.add(dir);
|
|
3333
|
+
const baseName = dir.split("/").pop()?.toLowerCase() || "";
|
|
3334
|
+
if (baseName) metaDirsByName.set(baseName, dir);
|
|
3335
|
+
}
|
|
3336
|
+
const missingMeta = [];
|
|
3337
|
+
const covered = [];
|
|
3338
|
+
for (const route of flatRoutes) {
|
|
3339
|
+
if (route.componentPath?.endsWith("Layout")) continue;
|
|
3340
|
+
let matched = false;
|
|
3341
|
+
const metaPath = determineMetaDir(route, cwd);
|
|
3342
|
+
if (metaDirs.has(metaPath)) matched = true;
|
|
3343
|
+
if (!matched && route.componentPath) {
|
|
3344
|
+
const componentName = route.componentPath.toLowerCase();
|
|
3345
|
+
if (metaDirsByName.has(componentName)) matched = true;
|
|
3346
|
+
if (!matched) {
|
|
3347
|
+
const parts = route.componentPath.split(/(?=[A-Z])/);
|
|
3348
|
+
const lastPart = parts[parts.length - 1];
|
|
3349
|
+
if (parts.length > 1 && lastPart) {
|
|
3350
|
+
if (metaDirsByName.has(lastPart.toLowerCase())) matched = true;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
if (!matched) {
|
|
3355
|
+
const screenPath = route.screenId.replace(/\./g, "/");
|
|
3356
|
+
for (const dir of metaDirs) if (dir.endsWith(screenPath)) {
|
|
3357
|
+
matched = true;
|
|
3358
|
+
break;
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
if (matched) covered.push(route);
|
|
3362
|
+
else missingMeta.push(route);
|
|
3363
|
+
}
|
|
3364
|
+
const total = covered.length + missingMeta.length;
|
|
3365
|
+
const coveredCount = covered.length;
|
|
3366
|
+
const missingCount = missingMeta.length;
|
|
3367
|
+
const coveragePercent = Math.round(coveredCount / total * 100);
|
|
3368
|
+
logger.log(`Found ${total} routes`);
|
|
3369
|
+
logger.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
|
|
3370
|
+
logger.blank();
|
|
3371
|
+
const minimumCoverage = adoption.minimumCoverage ?? 100;
|
|
3372
|
+
const passedCoverage = coveragePercent >= minimumCoverage;
|
|
3373
|
+
if (missingCount > 0) {
|
|
3374
|
+
logger.log(`Missing screen.meta.ts (${missingCount} routes):`);
|
|
3375
|
+
logger.blank();
|
|
3376
|
+
for (const route of missingMeta) {
|
|
3377
|
+
const suggestedMetaPath = determineSuggestedMetaPath(route, cwd);
|
|
3378
|
+
logger.itemError(`${route.fullPath} ${logger.dim(`(${route.screenId})`)}`);
|
|
3379
|
+
logger.log(` ${logger.dim("→")} ${logger.path(suggestedMetaPath)}`);
|
|
3380
|
+
}
|
|
3381
|
+
logger.blank();
|
|
3382
|
+
}
|
|
3383
|
+
if (!passedCoverage) {
|
|
3384
|
+
logger.error(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
|
|
3385
|
+
process.exit(1);
|
|
3386
|
+
} else if (missingCount > 0) {
|
|
3387
|
+
logger.success(`Coverage ${coveragePercent}% meets minimum ${minimumCoverage}%`);
|
|
3388
|
+
if (adoption.mode === "progressive") logger.log(` ${logger.dim("Tip:")} Increase minimumCoverage in config to gradually improve coverage`);
|
|
3389
|
+
} else logger.done("All routes have screen.meta.ts files");
|
|
3390
|
+
const screensPath = join(cwd, config.outDir, "screens.json");
|
|
3391
|
+
if (existsSync(screensPath)) try {
|
|
3392
|
+
const content = readFileSync(screensPath, "utf-8");
|
|
3393
|
+
const screens = JSON.parse(content);
|
|
3394
|
+
const orphans = findOrphanScreens(screens);
|
|
3395
|
+
if (orphans.length > 0) {
|
|
3396
|
+
hasWarnings = true;
|
|
3397
|
+
logger.blank();
|
|
3398
|
+
logger.warn(`Orphan screens detected (${orphans.length}):`);
|
|
3399
|
+
logger.blank();
|
|
3400
|
+
logger.log(" These screens have no entryPoints and are not");
|
|
3401
|
+
logger.log(" referenced in any other screen's 'next' array.");
|
|
3402
|
+
logger.blank();
|
|
3403
|
+
for (const orphan of orphans) logger.itemWarn(`${orphan.id} ${logger.dim(orphan.route)}`);
|
|
3404
|
+
logger.blank();
|
|
3405
|
+
logger.log(` ${logger.dim("Consider adding entryPoints or removing these screens.")}`);
|
|
3406
|
+
}
|
|
3407
|
+
if (!allowCycles) {
|
|
3408
|
+
const cycleResult = detectCycles(screens);
|
|
3409
|
+
if (cycleResult.hasCycles) {
|
|
3410
|
+
hasWarnings = true;
|
|
3411
|
+
logger.blank();
|
|
3412
|
+
logger.warn(getCycleSummary(cycleResult));
|
|
3413
|
+
logger.log(formatCycleWarnings(cycleResult.cycles));
|
|
3414
|
+
logger.blank();
|
|
3415
|
+
if (cycleResult.disallowedCycles.length > 0) {
|
|
3416
|
+
logger.log(` ${logger.dim("Use 'allowCycles: true' in screen.meta.ts to allow intentional cycles.")}`);
|
|
3417
|
+
if (strict) {
|
|
3418
|
+
logger.blank();
|
|
3419
|
+
logger.errorWithHelp(ERRORS.CYCLES_DETECTED(cycleResult.disallowedCycles.length));
|
|
3420
|
+
process.exit(1);
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
const invalidNavs = findInvalidNavigations(screens);
|
|
3426
|
+
if (invalidNavs.length > 0) {
|
|
3427
|
+
hasWarnings = true;
|
|
3428
|
+
logger.blank();
|
|
3429
|
+
logger.warn(`Invalid navigation targets (${invalidNavs.length}):`);
|
|
3430
|
+
logger.blank();
|
|
3431
|
+
logger.log(" These navigation references point to non-existent screens.");
|
|
3432
|
+
logger.blank();
|
|
3433
|
+
for (const inv of invalidNavs) logger.itemWarn(`${inv.screenId} → ${logger.dim(inv.field)}: "${inv.target}"`);
|
|
3434
|
+
logger.blank();
|
|
3435
|
+
logger.log(` ${logger.dim("Check that these screen IDs exist in your codebase.")}`);
|
|
3436
|
+
}
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
if (error instanceof SyntaxError) {
|
|
3439
|
+
logger.warn("Failed to parse screens.json - file may be corrupted");
|
|
3440
|
+
logger.log(` ${logger.dim("Run 'screenbook build' to regenerate.")}`);
|
|
3441
|
+
hasWarnings = true;
|
|
3442
|
+
} else if (error instanceof Error) {
|
|
3443
|
+
logger.warn(`Failed to analyze screens.json: ${error.message}`);
|
|
3444
|
+
hasWarnings = true;
|
|
3445
|
+
} else {
|
|
3446
|
+
logger.warn(`Failed to analyze screens.json: ${String(error)}`);
|
|
3447
|
+
hasWarnings = true;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
if (hasWarnings) {
|
|
3451
|
+
logger.blank();
|
|
3452
|
+
logger.warn("Lint completed with warnings.");
|
|
3453
|
+
}
|
|
3454
|
+
return hasWarnings;
|
|
3455
|
+
}
|
|
3456
|
+
/**
|
|
3457
|
+
* Determine the directory where screen.meta.ts should be for a route
|
|
3458
|
+
*/
|
|
3459
|
+
function determineMetaDir(route, cwd) {
|
|
3460
|
+
if (route.componentPath) {
|
|
3461
|
+
const relativePath = relative(cwd, dirname(route.componentPath));
|
|
3462
|
+
if (!relativePath.startsWith("..")) return relativePath;
|
|
3463
|
+
}
|
|
3464
|
+
return join("src", "screens", route.screenId.replace(/\./g, "/"));
|
|
3465
|
+
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Determine the suggested screen.meta.ts path for a route
|
|
3468
|
+
*/
|
|
3469
|
+
function determineSuggestedMetaPath(route, cwd) {
|
|
3470
|
+
return join(determineMetaDir(route, cwd), "screen.meta.ts");
|
|
3471
|
+
}
|
|
3472
|
+
/**
|
|
1833
3473
|
* Find navigation references that point to non-existent screens.
|
|
1834
3474
|
* Checks `next`, `entryPoints` arrays and mock navigation targets.
|
|
1835
3475
|
*/
|
|
@@ -2069,6 +3709,8 @@ const prImpactCommand = define({
|
|
|
2069
3709
|
|
|
2070
3710
|
//#endregion
|
|
2071
3711
|
//#region src/index.ts
|
|
3712
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
3713
|
+
const version = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
2072
3714
|
const mainCommand = define({
|
|
2073
3715
|
name: "screenbook",
|
|
2074
3716
|
description: "Screen catalog and navigation graph generator",
|
|
@@ -2090,7 +3732,7 @@ const mainCommand = define({
|
|
|
2090
3732
|
});
|
|
2091
3733
|
await cli(process.argv.slice(2), mainCommand, {
|
|
2092
3734
|
name: "screenbook",
|
|
2093
|
-
version
|
|
3735
|
+
version,
|
|
2094
3736
|
subCommands: {
|
|
2095
3737
|
init: initCommand,
|
|
2096
3738
|
generate: generateCommand,
|